@dieulc/workflow 0.9.2 → 0.10.0

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/index.ts CHANGED
@@ -163,6 +163,8 @@ import {
163
163
  roleArgumentCompletions,
164
164
  saveRoleConfig,
165
165
  getRole,
166
+ userRolesPath,
167
+ projectRolesPath,
166
168
  formatRolesForDisplay,
167
169
  formatModelRef,
168
170
  parseModelRef,
@@ -204,11 +206,17 @@ import {
204
206
  PROFILE_ROLES,
205
207
  modelPickerItems,
206
208
  profileActionClosesPicker,
209
+ userProfilesPath,
210
+ projectProfilesPath,
207
211
  type ProfileFlowOutcome,
208
212
  type ProfileConfig,
209
213
  type Profile,
210
214
  type ProfileRoleName,
211
215
  } from "./profiles.ts";
216
+ import {
217
+ formatRetargetResult,
218
+ retargetProviderFiles,
219
+ } from "./provider-retarget.ts";
212
220
  import { FilterableList, type FilterableItem } from "./picker.ts";
213
221
  import { QUESTIONNAIRE_OVERLAY_MAX_HEIGHT } from "./questionnaire-layout.ts";
214
222
  import {
@@ -232,6 +240,7 @@ import {
232
240
  } from "./model-default.ts";
233
241
  import {
234
242
  applySubmittedPlan,
243
+ buildReviewerFallbacks,
235
244
  classifyReviewExecution,
236
245
  createReviewHandoffGate,
237
246
  DEFAULT_REVIEW_MODE_CONFIG,
@@ -253,6 +262,7 @@ import {
253
262
  reviewResumeNotice,
254
263
  reviewResumePlanChoice,
255
264
  reviewEscapeAction,
265
+ reviewElapsedMs,
256
266
  reviewHandoffParks,
257
267
  reviewResumeSettles,
258
268
  reviewStateSummary,
@@ -261,6 +271,7 @@ import {
261
271
  writeReviewModeConfig,
262
272
  writeReviewPrompt,
263
273
  type ReviewFinding,
274
+ type ReviewFallbackModel,
264
275
  type ReviewExecutionResult,
265
276
  type ReviewHandoffGate,
266
277
  type ReviewSdkLoader,
@@ -282,12 +293,35 @@ import {
282
293
  type ReviewLineBuffer,
283
294
  type ReviewModeBandData,
284
295
  } from "./review-ui.ts";
296
+ import {
297
+ pendingHandoffIsStale,
298
+ reviewHandoffRecovery,
299
+ shouldHandoffManualResult,
300
+ type ReviewHandoffSource,
301
+ } from "./review-handoff.ts";
285
302
  import { seedPromptHistory, WorkflowTabEditor } from "./tab-editor.ts";
286
303
 
287
304
  // ── Plannotator Bridge ───────────────────────────────────────────────
288
305
  const PLANNOTATOR_REQUEST = "plannotator:request" as const;
289
306
  const PLANNOTATOR_REVIEW_RESULT = "plannotator:review-result" as const;
290
307
  const PLANNOTATOR_PLAN_APPROVED = "plannotator:plan-approved" as const;
308
+ const PLANNOTATOR_SESSION_OPENED = "plannotator:session-opened" as const;
309
+ const PLANNOTATOR_SESSION_CLOSED = "plannotator:session-closed" as const;
310
+
311
+ /** Structural shape of the optional Plannotator lifecycle events. */
312
+ interface PlannotatorSessionEventLike {
313
+ id?: string;
314
+ action?: string;
315
+ url?: string;
316
+ reviewId?: string;
317
+ requestId?: string;
318
+ outcome?: "approved" | "submitted" | "closed";
319
+ closedBy?: string;
320
+ }
321
+
322
+ type PlanReviewRequestOutcome =
323
+ | { status: "handled"; reviewId?: string; url?: string }
324
+ | { status: "timeout" | "error" | "unavailable"; reason: string };
291
325
 
292
326
  // ── Helpers ──────────────────────────────────────────────────────────
293
327
 
@@ -499,6 +533,10 @@ function getFinalOutput(messages: any[]): string {
499
533
  */
500
534
  export interface WorkflowExtensionDeps {
501
535
  loadSdk?: ReviewSdkLoader;
536
+ /** Test seam: how long a plan-review request waits for Plannotator. */
537
+ plannotatorRequestTimeoutMs?: number;
538
+ /** Test seam: how long a reload recovery status query waits. */
539
+ plannotatorRecoveryTimeoutMs?: number;
502
540
  }
503
541
 
504
542
  export default function workflowExtension(
@@ -1517,6 +1555,48 @@ export default function workflowExtension(
1517
1555
  }
1518
1556
  }
1519
1557
 
1558
+ /**
1559
+ * Automatic provider-recovery chain: the parent model that just planned,
1560
+ * then the base reviewer and planner roles from roles.json (ignoring the
1561
+ * active profile). Deduped and thinking-clamped through the parent registry.
1562
+ */
1563
+ function reviewFallbackModels(): ReviewFallbackModel[] {
1564
+ try {
1565
+ ensureRoleConfig(reviewCwd());
1566
+ ensureProfilesConfig(reviewCwd());
1567
+ const parent = currentSessionCtx?.model;
1568
+ const parentThinking = currentSessionCtx?.thinkingLevel;
1569
+ const baseReviewer = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1570
+ const basePlanner = getRole(roleConfig, "planner")?.model;
1571
+ return buildReviewerFallbacks({
1572
+ ...(parent
1573
+ ? {
1574
+ parentModel: {
1575
+ provider: parent.provider,
1576
+ id: parent.id,
1577
+ thinking: parentThinking ?? "high",
1578
+ },
1579
+ }
1580
+ : {}),
1581
+ ...(baseReviewer ? { baseReviewer } : {}),
1582
+ ...(basePlanner ? { basePlanner } : {}),
1583
+ clampThinking: (model) => {
1584
+ const found = (currentSessionCtx as any)?.modelRegistry?.find?.(
1585
+ model.provider,
1586
+ model.id,
1587
+ );
1588
+ return clampThinkingForModel(
1589
+ found,
1590
+ isValidThinkingLevel(model.thinking) ? model.thinking : "high",
1591
+ );
1592
+ },
1593
+ });
1594
+ } catch (_e) {
1595
+ void _e;
1596
+ return [];
1597
+ }
1598
+ }
1599
+
1520
1600
  /** Reviewer model label for the mode band (best-effort). */
1521
1601
  function reviewModelLabel(): string {
1522
1602
  // While a round runs, the band names the snapshotted model — the one
@@ -1927,6 +2007,7 @@ export default function workflowExtension(
1927
2007
  if (reviewModelSnapshot) return reviewModelSnapshot;
1928
2008
  return resolveReviewerModel();
1929
2009
  },
2010
+ fallbackReviewerModels: () => reviewFallbackModels(),
1930
2011
  findModel: (provider: string, id: string) =>
1931
2012
  (currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
1932
2013
  // Structural probe of the live parent context, evaluated at reviewer
@@ -2180,6 +2261,9 @@ export default function workflowExtension(
2180
2261
  try {
2181
2262
  const ctx = currentSessionCtx;
2182
2263
  if (!ctx || !(ctx as any).hasUI) return;
2264
+ if (message !== undefined) {
2265
+ (ctx as any).ui?.setWorkingVisible?.(true);
2266
+ }
2183
2267
  (ctx as any).ui?.setWorkingMessage?.(message);
2184
2268
  } catch (_e) {
2185
2269
  void _e;
@@ -2251,6 +2335,9 @@ export default function workflowExtension(
2251
2335
  // follow `reviewState`; the spinner row needs an explicit message while
2252
2336
  // the child session streams off the main turn.
2253
2337
  setReviewWorkingMessage("🔍 Reviewing plan…");
2338
+ // Paint the live review line now, not after the child session's first
2339
+ // state event: a manual `/review` must look alive from the first tick.
2340
+ updateStatus(ctx as any);
2254
2341
  // One long-lived listener covers this round AND the paused window (see
2255
2342
  // ensureReviewEscListener for why it is no longer attached per round).
2256
2343
  ensureReviewEscListener(ctx);
@@ -2466,6 +2553,17 @@ export default function workflowExtension(
2466
2553
  }
2467
2554
  }
2468
2555
 
2556
+ // A new round can never inherit an older browser gate: the old tab's
2557
+ // decision would otherwise suppress this round's handoff after it lands.
2558
+ if (awaitingDecision) {
2559
+ supersedePendingHandoff(
2560
+ trigger === "manual"
2561
+ ? "a manual /review started"
2562
+ : "a new review round started",
2563
+ { force: true, nextPlanHash: hash, nextCycleId: reviewCycleId },
2564
+ );
2565
+ }
2566
+
2469
2567
  // A different plan file is a new target even inside one user run: reset the
2470
2568
  // budget/hashes/findings before the dedupe gates below.
2471
2569
  const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
@@ -2634,6 +2732,16 @@ export default function workflowExtension(
2634
2732
  const hadGate = reviewGatePhase !== "idle";
2635
2733
  // Synchronously first: any old handoff continuation is stale from here on.
2636
2734
  reviewCycleId += 1;
2735
+ if (
2736
+ opts.reason === "prompt" ||
2737
+ opts.reason === "build" ||
2738
+ opts.reason === "session"
2739
+ ) {
2740
+ supersedePendingHandoff(`a new ${opts.reason} cycle started`, {
2741
+ force: true,
2742
+ });
2743
+ manualReviewCycle = false;
2744
+ }
2637
2745
  if (hadActive || pausedReview !== undefined || hadGate) {
2638
2746
  try {
2639
2747
  await suspendReview();
@@ -2736,7 +2844,9 @@ export default function workflowExtension(
2736
2844
  * they are not guarded. Clearing the review state releases the derived
2737
2845
  * mode-switch lock (plan 2026-10-25).
2738
2846
  */
2739
- async function teardownReview(): Promise<void> {
2847
+ async function teardownReview(
2848
+ opts: { keepHandoff?: boolean } = {},
2849
+ ): Promise<void> {
2740
2850
  try {
2741
2851
  activeReview?.controller.abort();
2742
2852
  } catch (_e) {
@@ -2749,6 +2859,7 @@ export default function workflowExtension(
2749
2859
  }
2750
2860
  reviewRuntime = undefined;
2751
2861
  reviewState = undefined;
2862
+ if (opts.keepHandoff !== true) manualReviewCycle = false;
2752
2863
  lastReviewedPlanHash = undefined;
2753
2864
  reviewRound = 0;
2754
2865
  reviewPreviousFindings = undefined;
@@ -2763,11 +2874,12 @@ export default function workflowExtension(
2763
2874
  activeReview = undefined;
2764
2875
  abortedReviewHash = undefined;
2765
2876
  quietAbortHash = undefined;
2766
- // A teardown ends any pending handoff decision/correlation and the
2767
- // per-run provenance; a reload cannot inherit either.
2768
- const clearedDecision = clearPendingHandoffDecision();
2877
+ // A reload/resume keeps the browser correlation so the next session_start
2878
+ // can recover it; every other teardown ends the decision and provenance.
2879
+ const clearedDecision =
2880
+ opts.keepHandoff === true ? false : clearPendingHandoffDecision();
2769
2881
  resetPlanRunProvenance();
2770
- if (clearedDecision) {
2882
+ if (clearedDecision || opts.keepHandoff === true) {
2771
2883
  try {
2772
2884
  persistState();
2773
2885
  } catch (_e) {
@@ -3179,6 +3291,7 @@ export default function workflowExtension(
3179
3291
  [
3180
3292
  `Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
3181
3293
  " /review-resume to continue, /review-abort to stop.",
3294
+ pendingHandoffStatusLine(),
3182
3295
  reviewModeLockStatusLine(lockKind),
3183
3296
  ]
3184
3297
  .filter(Boolean)
@@ -3199,6 +3312,7 @@ export default function workflowExtension(
3199
3312
  reviewEnabled()
3200
3313
  ? "Review Mode is enabled but no review has run yet."
3201
3314
  : `Review Mode is OFF — automatic review disabled (/review-mode on to enable).${cfgLine} /review runs a one-shot review.`,
3315
+ pendingHandoffStatusLine(),
3202
3316
  reviewModeLockStatusLine(lockKind),
3203
3317
  ]
3204
3318
  .filter(Boolean)
@@ -3211,6 +3325,10 @@ export default function workflowExtension(
3211
3325
  reviewStateSummary(s),
3212
3326
  ` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
3213
3327
  ` model : ${s.modelLabel}`,
3328
+ ` attempt : ${s.recoveryAttempts?.length ? s.recoveryAttempts.join(" → ") : "single model"}`,
3329
+ ` elapsed : ${Math.round(reviewElapsedMs(s) / 1000)}s${
3330
+ s.providerError ? ` provider: ${s.providerError.slice(0, 200)}` : ""
3331
+ }`,
3214
3332
  ` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
3215
3333
  ` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
3216
3334
  s.verdict ? ` verdict : ${s.verdict}` : "",
@@ -3224,6 +3342,7 @@ export default function workflowExtension(
3224
3342
  reviewRuntime?.isRoundActive()
3225
3343
  ? " live : round in flight — /role and /profile switches apply next round; /review say <msg> to steer"
3226
3344
  : "",
3345
+ pendingHandoffStatusLine(),
3227
3346
  reviewModeLockStatusLine(lockKind),
3228
3347
  ...s.findings.map(
3229
3348
  (f) =>
@@ -3398,6 +3517,16 @@ export default function workflowExtension(
3398
3517
  const offSuffix = reviewEnabled()
3399
3518
  ? ""
3400
3519
  : " (Review Mode stays OFF — /review-mode on for automatic review.)";
3520
+ if (
3521
+ await openManualHandoff(
3522
+ result,
3523
+ target.abs,
3524
+ ctx as any,
3525
+ offSuffix,
3526
+ )
3527
+ ) {
3528
+ return;
3529
+ }
3401
3530
  ctx.ui.notify(`${reviewResumeNotice(result)}${offSuffix}`, "info");
3402
3531
  },
3403
3532
  });
@@ -3442,6 +3571,9 @@ export default function workflowExtension(
3442
3571
  ctx.ui.notify("A review round is already running.", "warning");
3443
3572
  return;
3444
3573
  }
3574
+ // A gated resume is settled by its parked runHandoff; a manual pause
3575
+ // (idle gate) opens its own browser handoff after the round lands.
3576
+ const gatedResume = reviewGatePhase !== "idle";
3445
3577
  // A resumed gated review is auditing again. Without this, the parked
3446
3578
  // handoff keeps `reviewGatePhase === "paused"` for the whole round and
3447
3579
  // the lock/footer report the wrong state (plan 2026-10-25 follow-up).
@@ -3485,7 +3617,15 @@ export default function workflowExtension(
3485
3617
  planText: result.planText,
3486
3618
  });
3487
3619
  }
3488
- ctx.ui.notify(reviewResumeNotice(result), "info");
3620
+ if (!gatedResume && reviewResumeSettles(result)) {
3621
+ await openManualHandoff(
3622
+ result,
3623
+ currentPlanFile ?? "",
3624
+ ctx as any,
3625
+ );
3626
+ } else {
3627
+ ctx.ui.notify(reviewResumeNotice(result), "info");
3628
+ }
3489
3629
  return;
3490
3630
  }
3491
3631
  pausedReview = undefined;
@@ -3510,7 +3650,11 @@ export default function workflowExtension(
3510
3650
  planText: result.planText,
3511
3651
  });
3512
3652
  }
3513
- ctx.ui.notify(reviewResumeNotice(result), "info");
3653
+ if (!gatedResume && reviewResumeSettles(result)) {
3654
+ await openManualHandoff(result, currentPlanFile ?? "", ctx as any);
3655
+ } else {
3656
+ ctx.ui.notify(reviewResumeNotice(result), "info");
3657
+ }
3514
3658
  },
3515
3659
  });
3516
3660
 
@@ -4014,6 +4158,16 @@ export default function workflowExtension(
4014
4158
  planFile: currentPlanFile,
4015
4159
  awaitingDecision,
4016
4160
  awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
4161
+ // Full correlation: a restored gate must know which browser review it
4162
+ // belongs to, or it can only suppress the next handoff.
4163
+ lastReviewId: awaitingDecision ? lastReviewId : null,
4164
+ pendingHandoffPlanPath: awaitingDecision ? pendingHandoffPlanPath : null,
4165
+ pendingHandoffPlanHash: awaitingDecision ? pendingHandoffPlanHash : null,
4166
+ pendingHandoffRequestId: awaitingDecision ? pendingHandoffRequestId : null,
4167
+ pendingHandoffSource: awaitingDecision ? pendingHandoffSource : null,
4168
+ pendingHandoffCycleId: awaitingDecision ? pendingHandoffCycleId : 0,
4169
+ pendingHandoffReviewUrl: awaitingDecision ? pendingHandoffReviewUrl : null,
4170
+ manualReviewCycle,
4017
4171
  role: {
4018
4172
  activeRole: roleConfig.activeRole,
4019
4173
  // Session-scoped profile activation (see setActiveProfile): explicit
@@ -4310,6 +4464,365 @@ export default function workflowExtension(
4310
4464
  return currentSessionCtx;
4311
4465
  }
4312
4466
 
4467
+ /**
4468
+ * Adopt the review id/url Plannotator announced. This is the only place the
4469
+ * transcript handoff entry is written, so a `respond` callback and a
4470
+ * `session-opened` event for the same review cannot duplicate it.
4471
+ */
4472
+ function adoptPlannotatorSession(event: PlannotatorSessionEventLike): void {
4473
+ if (!awaitingDecision) return;
4474
+ if (typeof event.reviewId === "string" && event.reviewId) {
4475
+ lastReviewId = event.reviewId;
4476
+ }
4477
+ if (!lastReviewId) return;
4478
+ if (typeof event.url === "string" && event.url) {
4479
+ pendingHandoffReviewUrl = event.url;
4480
+ }
4481
+ if (recordedHandoffReviewId !== lastReviewId) {
4482
+ recordedHandoffReviewId = lastReviewId;
4483
+ try {
4484
+ pi.appendEntry("workflow-handoff-plannotator", {
4485
+ at: Date.now(),
4486
+ planPath: pendingHandoffPlanPath ?? currentPlanFile,
4487
+ reviewId: lastReviewId,
4488
+ ...(pendingHandoffReviewUrl
4489
+ ? { reviewUrl: pendingHandoffReviewUrl }
4490
+ : {}),
4491
+ planTextLen: pendingHandoffPlanTextLen,
4492
+ source: pendingHandoffSource ?? "auto",
4493
+ });
4494
+ } catch (_e) {
4495
+ void _e;
4496
+ }
4497
+ notifyReview(
4498
+ "Plan opened in browser for review. Approve or annotate there.",
4499
+ "info",
4500
+ );
4501
+ }
4502
+ try {
4503
+ persistState();
4504
+ } catch (_e) {
4505
+ void _e;
4506
+ }
4507
+ try {
4508
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
4509
+ } catch (_e) {
4510
+ void _e;
4511
+ }
4512
+ }
4513
+
4514
+ /** Settle the one in-flight plan-review request exactly once. */
4515
+ function settlePlanReviewRequest(outcome: PlanReviewRequestOutcome): boolean {
4516
+ const pending = pendingPlanReviewRequest;
4517
+ if (!pending || pending.settled) return false;
4518
+ pending.settled = true;
4519
+ pendingPlanReviewRequest = undefined;
4520
+ pending.finish(outcome);
4521
+ return true;
4522
+ }
4523
+
4524
+ /**
4525
+ * Ask Plannotator to open a plan review and wait for its lifecycle.
4526
+ *
4527
+ * The `respond` callback is the fast path; `plannotator:session-opened` is
4528
+ * the authoritative one (it carries the URL and survives a slow handler).
4529
+ * A timeout clears the gate so the caller can render its own fallback; it
4530
+ * never retries, because a late event would then open a duplicate review.
4531
+ */
4532
+ async function requestPlanReview(input: {
4533
+ planText: string;
4534
+ planPath: string;
4535
+ source: ReviewHandoffSource;
4536
+ ctx: ExtensionContext;
4537
+ cycleId?: number;
4538
+ }): Promise<{ ok: true; reviewId?: string } | { ok: false; reason: string }> {
4539
+ const bus: any = (pi as any).events;
4540
+ if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
4541
+ return { ok: false, reason: "Plannotator is not available in this session" };
4542
+ }
4543
+ if (!input.planText.trim() || !input.planPath) {
4544
+ return { ok: false, reason: "the reviewed plan has no file to hand off" };
4545
+ }
4546
+ // A second request supersedes the first waiter without opening a race.
4547
+ settlePlanReviewRequest({
4548
+ status: "error",
4549
+ reason: "superseded by a newer handoff",
4550
+ });
4551
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4552
+ awaitingDecision = true;
4553
+ lastHandoffAt = Date.now();
4554
+ pendingHandoffPlanPath = input.planPath;
4555
+ pendingHandoffPlanHash = planHash(input.planText);
4556
+ pendingHandoffPlanTextLen = input.planText.length;
4557
+ pendingHandoffRequestId = requestId;
4558
+ pendingHandoffSource = input.source;
4559
+ pendingHandoffCycleId = input.cycleId ?? reviewCycleId;
4560
+ pendingHandoffReviewUrl = null;
4561
+ lastReviewId = null;
4562
+ recordedHandoffReviewId = null;
4563
+ persistState();
4564
+ const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
4565
+ const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
4566
+ let settled = false;
4567
+ const settle = (outcome: PlanReviewRequestOutcome): void => {
4568
+ if (settled) return;
4569
+ settled = true;
4570
+ clearTimeout(timer);
4571
+ if (pendingPlanReviewRequest?.requestId === requestId) {
4572
+ pendingPlanReviewRequest = undefined;
4573
+ }
4574
+ resolve(outcome);
4575
+ };
4576
+ const timer = setTimeout(() => {
4577
+ settle({
4578
+ status: "timeout",
4579
+ reason: `Plannotator did not answer within ${Math.round(timeoutMs / 1000)}s`,
4580
+ });
4581
+ }, timeoutMs);
4582
+ pendingPlanReviewRequest = {
4583
+ requestId,
4584
+ settled: false,
4585
+ finish: settle,
4586
+ };
4587
+ try {
4588
+ bus.emit(PLANNOTATOR_REQUEST, {
4589
+ requestId,
4590
+ action: "plan-review",
4591
+ payload: {
4592
+ planContent: input.planText,
4593
+ planFilePath: input.planPath,
4594
+ reviewPending: false,
4595
+ reviewSummary: reviewState
4596
+ ? {
4597
+ modelLabel: reviewState.modelLabel,
4598
+ round: reviewState.round,
4599
+ verdict: reviewState.verdict,
4600
+ findings: reviewState.findings.length,
4601
+ }
4602
+ : undefined,
4603
+ },
4604
+ respond: (res: any) => {
4605
+ if (
4606
+ res &&
4607
+ res.status === "handled" &&
4608
+ typeof res.result?.reviewId === "string"
4609
+ ) {
4610
+ settle({
4611
+ status: "handled",
4612
+ reviewId: res.result.reviewId,
4613
+ });
4614
+ } else {
4615
+ settle({
4616
+ status: "error",
4617
+ reason: `Plannotator answered ${String(res?.status ?? "nothing")}`,
4618
+ });
4619
+ }
4620
+ },
4621
+ });
4622
+ } catch (e: any) {
4623
+ settle({
4624
+ status: "error",
4625
+ reason: `Plannotator request failed: ${String(e?.message ?? e)}`,
4626
+ });
4627
+ }
4628
+ });
4629
+ if (response.status === "handled") {
4630
+ adoptPlannotatorSession({
4631
+ ...(response.reviewId ? { reviewId: response.reviewId } : {}),
4632
+ ...(response.url ? { url: response.url } : {}),
4633
+ });
4634
+ return { ok: true, ...(response.reviewId ? { reviewId: response.reviewId } : {}) };
4635
+ }
4636
+ const changed = clearPendingHandoffDecision();
4637
+ if (changed) persistState();
4638
+ try {
4639
+ pi.appendEntry("workflow-handoff-plannotator-failed", {
4640
+ at: Date.now(),
4641
+ reason: response.reason,
4642
+ source: input.source,
4643
+ planPath: input.planPath,
4644
+ });
4645
+ } catch (_e) {
4646
+ void _e;
4647
+ }
4648
+ notifyReview(`Plannotator handoff unavailable — ${response.reason}.`, "warning");
4649
+ return { ok: false, reason: response.reason };
4650
+ }
4651
+
4652
+ /** Ask Plannotator for the durable status of one review. */
4653
+ async function queryPlannotatorReviewStatus(reviewId: string): Promise<{
4654
+ status: "pending" | "completed" | "missing";
4655
+ approved?: boolean;
4656
+ feedback?: string;
4657
+ savedPath?: string;
4658
+ }> {
4659
+ const bus: any = (pi as any).events;
4660
+ if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
4661
+ return { status: "missing" };
4662
+ }
4663
+ const timeoutMs = Math.max(50, deps.plannotatorRecoveryTimeoutMs ?? 3_000);
4664
+ return await new Promise((resolve) => {
4665
+ let settled = false;
4666
+ const finish = (value: any): void => {
4667
+ if (settled) return;
4668
+ settled = true;
4669
+ clearTimeout(timer);
4670
+ resolve(
4671
+ value &&
4672
+ (value.status === "pending" ||
4673
+ value.status === "completed" ||
4674
+ value.status === "missing")
4675
+ ? value
4676
+ : { status: "missing" },
4677
+ );
4678
+ };
4679
+ const timer = setTimeout(() => finish({ status: "missing" }), timeoutMs);
4680
+ try {
4681
+ bus.emit(PLANNOTATOR_REQUEST, {
4682
+ requestId: `${Date.now()}-status`,
4683
+ action: "review-status",
4684
+ payload: { reviewId },
4685
+ respond: (res: any) => {
4686
+ if (res?.status === "handled" && res.result) finish(res.result);
4687
+ else finish({ status: "missing" });
4688
+ },
4689
+ });
4690
+ } catch {
4691
+ finish({ status: "missing" });
4692
+ }
4693
+ });
4694
+ }
4695
+
4696
+ /**
4697
+ * Recover the one pending browser handoff after a session reload/resume.
4698
+ *
4699
+ * `startup`/`new` cannot own the old loopback server, so they clear. For a
4700
+ * reload/resume/fork, Plannotator's durable `review-status` decides: pending
4701
+ * keeps the gate (when the plan hash still matches), completed applies the
4702
+ * recorded decision, and missing/changed clears with a visible reason.
4703
+ */
4704
+ async function recoverPendingHandoff(
4705
+ ctx: ExtensionContext,
4706
+ reason: string,
4707
+ ): Promise<void> {
4708
+ if (recoveryInFlight) return recoveryInFlight;
4709
+ recoveryInFlight = (async () => {
4710
+ const record = (action: string, detail: Record<string, unknown> = {}) => {
4711
+ try {
4712
+ pi.appendEntry("workflow-handoff-recovery", {
4713
+ action,
4714
+ reason,
4715
+ at: Date.now(),
4716
+ ...detail,
4717
+ });
4718
+ } catch (_e) {
4719
+ void _e;
4720
+ }
4721
+ };
4722
+ if (!awaitingDecision) return;
4723
+ if (reason === "startup" || reason === "new" || !lastReviewId) {
4724
+ const changed = clearPendingHandoffDecision();
4725
+ if (changed) persistState();
4726
+ record("clear", { why: "no restorable review" });
4727
+ return;
4728
+ }
4729
+ const currentFile = readCurrentPlanText(reviewCwd());
4730
+ const status = await queryPlannotatorReviewStatus(lastReviewId);
4731
+ const decision = reviewHandoffRecovery({
4732
+ status: status.status,
4733
+ approved: status.approved,
4734
+ feedback: status.feedback,
4735
+ savedPath: status.savedPath,
4736
+ pendingHash: pendingHandoffPlanHash ?? undefined,
4737
+ currentHash:
4738
+ currentFile !== undefined ? planHash(currentFile) : undefined,
4739
+ });
4740
+ if (decision.action === "keep") {
4741
+ if (pendingHandoffSource === "manual") manualReviewCycle = true;
4742
+ record("keep", {
4743
+ reviewId: lastReviewId,
4744
+ ...(pendingHandoffReviewUrl
4745
+ ? { reviewUrl: pendingHandoffReviewUrl }
4746
+ : {}),
4747
+ });
4748
+ notifyReview(
4749
+ `Browser review still open${pendingHandoffReviewUrl ? `: ${pendingHandoffReviewUrl}` : ""} — approve or annotate there.`,
4750
+ "info",
4751
+ );
4752
+ updateStatus(ctx as any);
4753
+ return;
4754
+ }
4755
+ if (decision.action === "apply") {
4756
+ record("apply", {
4757
+ reviewId: lastReviewId,
4758
+ approved: decision.approved,
4759
+ });
4760
+ if (decision.approved) {
4761
+ handlePlannotatorApprove(
4762
+ pendingHandoffPlanPath ?? currentPlanFile ?? "",
4763
+ currentFile ?? "",
4764
+ decision.feedback,
4765
+ ctx.cwd,
4766
+ );
4767
+ } else {
4768
+ await handlePlannotatorDeny(
4769
+ decision.feedback || "Plan needs revision.",
4770
+ );
4771
+ }
4772
+ return;
4773
+ }
4774
+ const changed = clearPendingHandoffDecision();
4775
+ if (changed) persistState();
4776
+ record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
4777
+ notifyReview(
4778
+ `Previous browser review could not be recovered — ${decision.reason}.`,
4779
+ "warning",
4780
+ );
4781
+ updateStatus(ctx as any);
4782
+ })();
4783
+ try {
4784
+ await recoveryInFlight;
4785
+ } finally {
4786
+ recoveryInFlight = null;
4787
+ }
4788
+ }
4789
+
4790
+ /**
4791
+ * Open the browser handoff for a settled manual `/review` or resume.
4792
+ * Returns true when the browser owns the decision (or the result is not
4793
+ * handoff-eligible); false when the round paused/aborted and the caller
4794
+ * should show its normal resume notice.
4795
+ */
4796
+ async function openManualHandoff(
4797
+ result: ReviewExecutionResult,
4798
+ planPath: string,
4799
+ ctx: ExtensionContext,
4800
+ suffix = "",
4801
+ ): Promise<boolean> {
4802
+ if (!shouldHandoffManualResult(result)) return false;
4803
+ const previousManualCycle = manualReviewCycle;
4804
+ manualReviewCycle = true;
4805
+ const handoff = await requestPlanReview({
4806
+ planText: result.planText,
4807
+ planPath,
4808
+ source: "manual",
4809
+ ctx,
4810
+ });
4811
+ if (handoff.ok) {
4812
+ ctx.ui.notify(
4813
+ "Plan opened in browser for review. Approve or annotate there." + suffix,
4814
+ "info",
4815
+ );
4816
+ return true;
4817
+ }
4818
+ manualReviewCycle = previousManualCycle;
4819
+ ctx.ui.notify(
4820
+ `Manual review finished, but Plannotator is unavailable (${handoff.reason}). The reviewed plan is at ${planPath}.`,
4821
+ "warning",
4822
+ );
4823
+ return true;
4824
+ }
4825
+
4313
4826
  function handlePlannotatorApprove(
4314
4827
  planFilePath: string,
4315
4828
  planContent: string,
@@ -4318,10 +4831,18 @@ export default function workflowExtension(
4318
4831
  ) {
4319
4832
  const ctx = getActiveCtx();
4320
4833
  if (!ctx) return;
4321
- if (workflowMode !== "plan") return;
4834
+ // A manual `/review` handoff may be approved from Default/Build; an
4835
+ // automatic handoff only exists in Plan mode.
4836
+ if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
4322
4837
  // A stale browser approve while a review runs must not clear the pending
4323
4838
  // decision or send the execute messages: the plan is still being audited.
4324
- if (blockModeSwitchDuringReview(ctx as any)) return;
4839
+ if (blockModeSwitchDuringReview(ctx as any)) {
4840
+ supersedePendingHandoff(
4841
+ "a browser decision arrived while a new review round was running",
4842
+ { force: true },
4843
+ );
4844
+ return;
4845
+ }
4325
4846
  // No prompt means no decision to consume. A fresh prompt/mode switch
4326
4847
  // clears this, so an old browser tab cannot approve the previous plan.
4327
4848
  if (!awaitingDecision) return;
@@ -4377,6 +4898,7 @@ export default function workflowExtension(
4377
4898
  } catch (_e) {
4378
4899
  void _e;
4379
4900
  }
4901
+ if (pendingHandoffSource === "manual") manualReviewCycle = false;
4380
4902
  clearPendingHandoffDecision();
4381
4903
  void enterBuildModeFromPlan(ctx as any, resolvedPlan)
4382
4904
  .then(() => updateStatus(ctx as any))
@@ -4428,8 +4950,28 @@ export default function workflowExtension(
4428
4950
  // Not a mode transition, but while a review owns the turn every browser
4429
4951
  // decision is stale: injecting feedback here would mutate the plan under
4430
4952
  // the reviewer. A legitimate deny only arrives after the gate resolves.
4431
- if (blockModeSwitchDuringReview(ctx as any)) return;
4953
+ if (blockModeSwitchDuringReview(ctx as any)) {
4954
+ supersedePendingHandoff(
4955
+ "a browser decision arrived while a new review round was running",
4956
+ { force: true },
4957
+ );
4958
+ return;
4959
+ }
4432
4960
  if (!awaitingDecision) return;
4961
+ const manualSource = pendingHandoffSource === "manual";
4962
+ if (manualSource && workflowMode !== "plan") {
4963
+ // A manual review can be launched from Default/Build. Its feedback is a
4964
+ // plan revision, so switch to Plan before the follow-up turn starts.
4965
+ try {
4966
+ await setPlanMode(ctx as any, { quiet: true });
4967
+ } catch (_e) {
4968
+ void _e;
4969
+ }
4970
+ notifyReview(
4971
+ "Review feedback — switched to Plan mode for the revision.",
4972
+ "info",
4973
+ );
4974
+ }
4433
4975
  clearPendingHandoffDecision();
4434
4976
  // Feedback cycle: the next plan revision gets a fresh review round, and the
4435
4977
  // outstanding findings are carried forward so the reviewer can confirm they
@@ -4519,6 +5061,52 @@ export default function workflowExtension(
4519
5061
  void _e;
4520
5062
  }
4521
5063
  });
5064
+ // session-opened is authoritative: it carries the review id and URL even
5065
+ // when the request's respond callback is slow or lost. It can arrive
5066
+ // before or after respond; both paths settle the same waiter once.
5067
+ bus.on(PLANNOTATOR_SESSION_OPENED, (event: any) => {
5068
+ try {
5069
+ if (!event || typeof event !== "object") return;
5070
+ const matchesWaiting =
5071
+ typeof event.requestId === "string" &&
5072
+ event.requestId === pendingPlanReviewRequest?.requestId;
5073
+ const matchesCurrent =
5074
+ typeof event.requestId === "string" &&
5075
+ event.requestId === pendingHandoffRequestId;
5076
+ if (!matchesWaiting && !matchesCurrent) return;
5077
+ adoptPlannotatorSession({
5078
+ reviewId: event.reviewId,
5079
+ url: event.url,
5080
+ });
5081
+ if (matchesWaiting) {
5082
+ settlePlanReviewRequest({
5083
+ status: "handled",
5084
+ reviewId:
5085
+ typeof event.reviewId === "string" ? event.reviewId : undefined,
5086
+ url: typeof event.url === "string" ? event.url : undefined,
5087
+ });
5088
+ }
5089
+ } catch (_e) {
5090
+ void _e;
5091
+ }
5092
+ });
5093
+ // A review closed without a decision must not leave a phantom gate that
5094
+ // suppresses the next round. approved/submitted decisions still flow
5095
+ // through review-result (and, after a reload, review-status).
5096
+ bus.on(PLANNOTATOR_SESSION_CLOSED, (event: any) => {
5097
+ try {
5098
+ if (!event || typeof event !== "object") return;
5099
+ if (event.outcome !== "closed") return;
5100
+ if (!lastReviewId || event.reviewId !== lastReviewId) return;
5101
+ if (!awaitingDecision) return;
5102
+ supersedePendingHandoff(
5103
+ "the browser review closed without a decision",
5104
+ { force: true },
5105
+ );
5106
+ } catch (_e) {
5107
+ void _e;
5108
+ }
5109
+ });
4522
5110
  plannotatorListenersRegistered = true;
4523
5111
  plannotatorActive = true;
4524
5112
  const ctx = getActiveCtx();
@@ -5416,7 +6004,8 @@ export default function workflowExtension(
5416
6004
  "Usage: /profile (picker) | /profile list | /profile show <name> | " +
5417
6005
  "/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
5418
6006
  "/profile rename <old> <new> | /profile delete <name> | " +
5419
- "/profile default [name|none] [global|project]";
6007
+ "/profile default [name|none] [global|project] | " +
6008
+ "/profile retarget-provider <old> <new> [global|project] [--apply]";
5420
6009
 
5421
6010
  /** Effective workflow.defaultProfile for display (never throws). */
5422
6011
  function currentDefaultProfileName(cwd?: string): string | undefined {
@@ -6827,6 +7416,79 @@ export default function workflowExtension(
6827
7416
  return;
6828
7417
  }
6829
7418
 
7419
+ if (sub === "retarget-provider" || sub === "retarget") {
7420
+ const [oldProvider, newProvider, ...flags] = rest;
7421
+ if (!oldProvider || !newProvider) {
7422
+ ctx.ui.notify(
7423
+ "Usage: /profile retarget-provider <old> <new> [global|project] [--apply]\n" +
7424
+ "Rewrite every role/profile model ref from one provider to another. " +
7425
+ "Without --apply it only prints the planned changes.",
7426
+ "info",
7427
+ );
7428
+ return;
7429
+ }
7430
+ if (oldProvider === newProvider) {
7431
+ ctx.ui.notify(
7432
+ `Provider is already "${oldProvider}" — nothing to retarget.`,
7433
+ "info",
7434
+ );
7435
+ return;
7436
+ }
7437
+ const apply = flags.some((flag) => {
7438
+ const value = flag.toLowerCase();
7439
+ return value === "--apply" || value === "apply";
7440
+ });
7441
+ const scope = flags.some((flag) => flag.toLowerCase() === "project")
7442
+ ? "project"
7443
+ : "global";
7444
+ const targetCwd = cwd ?? (ctx as any).cwd ?? process.cwd();
7445
+ const result = retargetProviderFiles({
7446
+ profilesPath:
7447
+ scope === "project"
7448
+ ? projectProfilesPath(targetCwd)
7449
+ : userProfilesPath(),
7450
+ rolesPath:
7451
+ scope === "project"
7452
+ ? projectRolesPath(targetCwd)
7453
+ : userRolesPath(),
7454
+ oldProvider,
7455
+ newProvider,
7456
+ dryRun: !apply,
7457
+ });
7458
+ const summary = formatRetargetResult(result);
7459
+ if (
7460
+ result.profiles.status === "invalid" ||
7461
+ result.roles.status === "invalid"
7462
+ ) {
7463
+ ctx.ui.notify(summary, "error");
7464
+ return;
7465
+ }
7466
+ if (!apply) {
7467
+ ctx.ui.notify(
7468
+ `${summary}\n\nScope: ${scope}. A malformed file blocks both writes.`,
7469
+ "info",
7470
+ );
7471
+ return;
7472
+ }
7473
+ // The files changed underneath the cached configs. Force a reload so
7474
+ // the active profile/role resolves the new provider immediately.
7475
+ roleConfigLoadedFor = undefined;
7476
+ profilesLoadedFor = undefined;
7477
+ invalidateEffectiveRoles();
7478
+ ensureRoleConfig(targetCwd);
7479
+ ensureProfilesConfig(targetCwd);
7480
+ updateStatus(ctx as any);
7481
+ if (workflowMode) {
7482
+ try {
7483
+ await applyModeModel(ctx as any);
7484
+ } catch (_e) {
7485
+ void _e;
7486
+ }
7487
+ }
7488
+ ctx.ui.notify(`${summary}\n\nScope: ${scope}.`, "info");
7489
+ return;
7490
+ }
7491
+
6830
7492
  if (sub === "default") {
6831
7493
  // /profile default [name|none] [global|project]
6832
7494
  const target = rest.find(
@@ -8351,31 +9013,39 @@ export default function workflowExtension(
8351
9013
  typeof d.awaitingDecisionAt === "number"
8352
9014
  ? d.awaitingDecisionAt
8353
9015
  : undefined;
8354
- // Stale guard: if awaitingDecision was persisted >60s ago (crash while modal open), reset
8355
- if (
8356
- awaitingDecision &&
8357
- lastHandoffAt &&
8358
- Date.now() - lastHandoffAt > 60_000
8359
- ) {
8360
- awaitingDecision = false;
8361
- lastHandoffAt = undefined;
8362
- try {
8363
- (ctx as any).ui?.notify?.(
8364
- "Recovered stale handoff gate — showing again",
8365
- "info",
8366
- );
8367
- } catch (_e) {
8368
- void _e;
8369
- }
8370
- try {
8371
- pi.appendEntry("workflow-handoff-stale-reset", {
8372
- at: Date.now(),
8373
- lastHandoffAt,
8374
- });
8375
- } catch (_e) {
8376
- void _e;
8377
- }
8378
- }
9016
+ // Restore the whole correlation, not just the boolean: recovery
9017
+ // and stale-browser correlation both need the review identity.
9018
+ lastReviewId =
9019
+ typeof (d as any).lastReviewId === "string"
9020
+ ? (d as any).lastReviewId
9021
+ : null;
9022
+ pendingHandoffPlanPath =
9023
+ typeof (d as any).pendingHandoffPlanPath === "string"
9024
+ ? (d as any).pendingHandoffPlanPath
9025
+ : null;
9026
+ pendingHandoffPlanHash =
9027
+ typeof (d as any).pendingHandoffPlanHash === "string"
9028
+ ? (d as any).pendingHandoffPlanHash
9029
+ : null;
9030
+ pendingHandoffRequestId =
9031
+ typeof (d as any).pendingHandoffRequestId === "string"
9032
+ ? (d as any).pendingHandoffRequestId
9033
+ : null;
9034
+ pendingHandoffSource =
9035
+ (d as any).pendingHandoffSource === "manual"
9036
+ ? "manual"
9037
+ : (d as any).pendingHandoffSource === "auto"
9038
+ ? "auto"
9039
+ : null;
9040
+ pendingHandoffCycleId =
9041
+ typeof (d as any).pendingHandoffCycleId === "number"
9042
+ ? (d as any).pendingHandoffCycleId
9043
+ : 0;
9044
+ pendingHandoffReviewUrl =
9045
+ typeof (d as any).pendingHandoffReviewUrl === "string"
9046
+ ? (d as any).pendingHandoffReviewUrl
9047
+ : null;
9048
+ manualReviewCycle = (d as any).manualReviewCycle === true;
8379
9049
  }
8380
9050
  }
8381
9051
  if (
@@ -8601,9 +9271,10 @@ export default function workflowExtension(
8601
9271
  resetPlanRunProvenance();
8602
9272
  lastPlanWritePath = currentPlanFile ?? null;
8603
9273
  lastPersistedPlanFile = currentPlanFile;
8604
- pendingHandoffPlanPath = null;
8605
- pendingHandoffPlanHash = null;
8606
- lastReviewId = null;
9274
+ // The pending correlation is restored above. Recovery (step 15) decides
9275
+ // whether it survives; do not null it here, or a restored gate can only
9276
+ // suppress the next handoff.
9277
+ if (!awaitingDecision) clearPendingHandoffDecision();
8607
9278
  currentSessionCtx = ctx as any;
8608
9279
  // Give Tab back to autocomplete while a suggestion list is visible; the raw
8609
9280
  // `tab` shortcut keeps cycling modes otherwise.
@@ -8636,10 +9307,14 @@ export default function workflowExtension(
8636
9307
  } catch (_e) {
8637
9308
  void _e;
8638
9309
  }
9310
+ // Recovery is asynchronous (it may query Plannotator); session start must
9311
+ // not wait up to the status timeout. The gate is restored/cleared before
9312
+ // the next user turn.
9313
+ void recoverPendingHandoff(ctx as ExtensionContext, event.reason);
8639
9314
  updateStatus(ctx as any);
8640
9315
  });
8641
9316
 
8642
- pi.on("session_shutdown", async (_event, ctx) => {
9317
+ pi.on("session_shutdown", async (event, ctx) => {
8643
9318
  // A write denial is run-scoped; the session is going away, so drop it.
8644
9319
  writeGateRunDenials.clear();
8645
9320
  writeGateTurnAnswer = null;
@@ -8658,11 +9333,17 @@ export default function workflowExtension(
8658
9333
  }
8659
9334
  }
8660
9335
  activeSubagents.clear();
8661
- // Ensure a handoff decision/provenance does not persist stale across restart
8662
- const clearedAtShutdown = clearPendingHandoffDecision();
9336
+ // A reload/resume/fork can recover a browser review in the replacement
9337
+ // session; quitting or starting a new session cannot own the old server.
9338
+ const keepPending =
9339
+ event.reason === "reload" ||
9340
+ event.reason === "resume" ||
9341
+ event.reason === "fork";
9342
+ const clearedAtShutdown =
9343
+ keepPending === true ? false : clearPendingHandoffDecision();
8663
9344
  resetPlanRunProvenance();
8664
9345
  resetSessionPlanProvenance();
8665
- if (clearedAtShutdown) {
9346
+ if (clearedAtShutdown || keepPending === true) {
8666
9347
  try {
8667
9348
  persistState();
8668
9349
  } catch (_e) {
@@ -8680,7 +9361,7 @@ export default function workflowExtension(
8680
9361
  pendingCommitFlow = null;
8681
9362
  // Release the reviewer session (and its custom tools) on shutdown so a
8682
9363
  // reload or quit never leaves an orphaned in-process session behind.
8683
- await teardownReview();
9364
+ await teardownReview({ keepHandoff: keepPending });
8684
9365
  currentSessionCtx = null;
8685
9366
  });
8686
9367
 
@@ -9895,6 +10576,25 @@ export default function workflowExtension(
9895
10576
  /** Plan path/hash the currently rendered handoff prompt belongs to. */
9896
10577
  let pendingHandoffPlanPath: string | null = null;
9897
10578
  let pendingHandoffPlanHash: string | null = null;
10579
+ let pendingHandoffRequestId: string | null = null;
10580
+ let pendingHandoffSource: ReviewHandoffSource | null = null;
10581
+ let pendingHandoffCycleId = 0;
10582
+ let pendingHandoffReviewUrl: string | null = null;
10583
+ let pendingHandoffPlanTextLen = 0;
10584
+ /** The one in-flight `plannotator:request` waiter; settles exactly once. */
10585
+ let pendingPlanReviewRequest:
10586
+ | {
10587
+ requestId: string;
10588
+ settled: boolean;
10589
+ finish: (outcome: PlanReviewRequestOutcome) => void;
10590
+ }
10591
+ | undefined;
10592
+ /** The review id already recorded in the transcript (one entry per review). */
10593
+ let recordedHandoffReviewId: string | null = null;
10594
+ /** A manual `/review` owns the following feedback cycle (even when auto is off). */
10595
+ let manualReviewCycle = false;
10596
+ /** One reload recovery at a time (session_start may re-enter). */
10597
+ let recoveryInFlight: Promise<void> | null = null;
9898
10598
  /** Last path announced by the suppressed-handoff notice (per run). */
9899
10599
  let lastPlanReadyNoticeKey: string | null = null;
9900
10600
 
@@ -9919,18 +10619,88 @@ export default function workflowExtension(
9919
10619
  * switch supersedes it; stale browser events must not approve the old plan.
9920
10620
  * Returns true when persisted state changed so the caller can persist once.
9921
10621
  */
10622
+ /** One `/review-status` row for a browser handoff awaiting a decision. */
10623
+ function pendingHandoffStatusLine(): string {
10624
+ if (!awaitingDecision) return "";
10625
+ const age = lastHandoffAt
10626
+ ? Math.max(0, Math.round((Date.now() - lastHandoffAt) / 1000))
10627
+ : 0;
10628
+ return ` handoff : browser pending — ${lastReviewId ?? "review id unknown"}${
10629
+ pendingHandoffReviewUrl ? ` @ ${pendingHandoffReviewUrl}` : ""
10630
+ } (${pendingHandoffSource ?? "auto"}, ${age}s)`;
10631
+ }
10632
+
9922
10633
  function clearPendingHandoffDecision(): boolean {
9923
10634
  const changed =
9924
10635
  awaitingDecision === true ||
9925
10636
  lastHandoffAt !== undefined ||
9926
10637
  lastReviewId !== null ||
9927
10638
  pendingHandoffPlanPath !== null ||
9928
- pendingHandoffPlanHash !== null;
10639
+ pendingHandoffPlanHash !== null ||
10640
+ pendingHandoffRequestId !== null ||
10641
+ pendingHandoffSource !== null ||
10642
+ pendingHandoffCycleId !== 0 ||
10643
+ pendingHandoffReviewUrl !== null;
9929
10644
  awaitingDecision = false;
9930
10645
  lastHandoffAt = undefined;
9931
10646
  lastReviewId = null;
9932
10647
  pendingHandoffPlanPath = null;
9933
10648
  pendingHandoffPlanHash = null;
10649
+ pendingHandoffRequestId = null;
10650
+ pendingHandoffSource = null;
10651
+ pendingHandoffCycleId = 0;
10652
+ pendingHandoffReviewUrl = null;
10653
+ pendingHandoffPlanTextLen = 0;
10654
+ recordedHandoffReviewId = null;
10655
+ return changed;
10656
+ }
10657
+
10658
+ /**
10659
+ * Drop a gate that cannot belong to the round/plan about to start.
10660
+ *
10661
+ * A new automatic round, a manual `/review`, or a browser decision arriving
10662
+ * while another round runs all supersede the old gate. A duplicate event for
10663
+ * the same plan outside a new round must not clear a legitimate pending
10664
+ * prompt, so the pure `pendingHandoffIsStale` predicate decides.
10665
+ */
10666
+ function supersedePendingHandoff(
10667
+ reason: string,
10668
+ opts: {
10669
+ force?: boolean;
10670
+ nextCycleId?: number;
10671
+ nextPlanHash?: string;
10672
+ } = {},
10673
+ ): boolean {
10674
+ if (
10675
+ !pendingHandoffIsStale({
10676
+ awaiting: awaitingDecision,
10677
+ force: opts.force,
10678
+ pendingCycleId:
10679
+ pendingHandoffCycleId > 0 ? pendingHandoffCycleId : undefined,
10680
+ currentCycleId: opts.nextCycleId ?? reviewCycleId,
10681
+ pendingPlanHash: pendingHandoffPlanHash ?? undefined,
10682
+ nextPlanHash: opts.nextPlanHash,
10683
+ })
10684
+ ) {
10685
+ return false;
10686
+ }
10687
+ const changed = clearPendingHandoffDecision();
10688
+ if (changed) {
10689
+ notifyReview(`Plan handoff superseded — ${reason}.`, "info");
10690
+ try {
10691
+ pi.appendEntry("workflow-handoff-superseded", {
10692
+ reason,
10693
+ at: Date.now(),
10694
+ });
10695
+ } catch (_e) {
10696
+ void _e;
10697
+ }
10698
+ try {
10699
+ persistState();
10700
+ } catch (_e) {
10701
+ void _e;
10702
+ }
10703
+ }
9934
10704
  return changed;
9935
10705
  }
9936
10706
 
@@ -10462,7 +11232,8 @@ export default function workflowExtension(
10462
11232
  void _e;
10463
11233
  }
10464
11234
  };
10465
- if (planTextForRender && reviewEnabled()) {
11235
+ const reviewThisCycle = reviewEnabled() || manualReviewCycle;
11236
+ if (planTextForRender && reviewThisCycle) {
10466
11237
  const authorPlan: string = planTextForRender;
10467
11238
  reviewHandoffGate.reset();
10468
11239
  reviewGatePhase = "auditing";
@@ -10472,7 +11243,7 @@ export default function workflowExtension(
10472
11243
  authorPlan,
10473
11244
  ctx as ExtensionContext,
10474
11245
  (ctx as any).signal as AbortSignal | undefined,
10475
- "auto",
11246
+ manualReviewCycle ? "manual" : "auto",
10476
11247
  );
10477
11248
  if (handoffCycle !== reviewCycleId) return;
10478
11249
  const reviewedText = reviewed.planText;
@@ -10587,114 +11358,15 @@ export default function workflowExtension(
10587
11358
  if (planTextForRender && hasUI && !awaitingDecision) {
10588
11359
  // ── Plannotator webview branch (browser review) ──────────
10589
11360
  if (plannotatorActive) {
10590
- awaitingDecision = true;
10591
- lastHandoffAt = Date.now();
10592
- pendingHandoffPlanPath = planPath;
10593
- pendingHandoffPlanHash = planHash(planTextForRender);
10594
- persistState();
10595
- try {
10596
- const bus: any = (pi as any).events;
10597
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10598
- const response: any = await new Promise((resolve) => {
10599
- let settled = false;
10600
- const timer = setTimeout(() => {
10601
- if (!settled) {
10602
- settled = true;
10603
- resolve({ status: "timeout" });
10604
- }
10605
- }, 5000);
10606
- try {
10607
- bus.emit(PLANNOTATOR_REQUEST, {
10608
- requestId,
10609
- action: "plan-review",
10610
- payload: {
10611
- planContent: planTextForRender!,
10612
- planFilePath: planPath,
10613
- // The handoff only renders after the gated review
10614
- // resolves, so a pending review is impossible here;
10615
- // the field stays for the external browser app.
10616
- reviewPending: false,
10617
- // Provenance so the browser/inline view can show that this
10618
- // plan was independently reviewed (and by what).
10619
- reviewSummary: reviewState
10620
- ? {
10621
- modelLabel: reviewState.modelLabel,
10622
- round: reviewState.round,
10623
- verdict: reviewState.verdict,
10624
- findings: reviewState.findings.length,
10625
- }
10626
- : undefined,
10627
- },
10628
- respond: (res: any) => {
10629
- if (!settled) {
10630
- settled = true;
10631
- clearTimeout(timer);
10632
- resolve(res);
10633
- }
10634
- },
10635
- });
10636
- } catch (e) {
10637
- if (!settled) {
10638
- settled = true;
10639
- clearTimeout(timer);
10640
- resolve({ status: "error", error: String(e) });
10641
- }
10642
- }
10643
- });
10644
- if (
10645
- response &&
10646
- response.status === "handled" &&
10647
- response.result &&
10648
- response.result.reviewId
10649
- ) {
10650
- lastReviewId = response.result.reviewId;
10651
- try {
10652
- pi.appendEntry("workflow-handoff-plannotator", {
10653
- at: Date.now(),
10654
- planPath,
10655
- reviewId: lastReviewId,
10656
- planTextLen: planTextForRender!.length,
10657
- source: planSource,
10658
- });
10659
- } catch (_e) {
10660
- void _e;
10661
- }
10662
- try {
10663
- currentSessionCtx = ctx as any;
10664
- (ctx as any).ui?.notify?.(
10665
- "Plan opened in browser for review. Approve or annotate there.",
10666
- "info",
10667
- );
10668
- updateStatus(ctx as any);
10669
- } catch (_e) {
10670
- void _e;
10671
- }
10672
- return; // plannotator owns the review — result arrives via plannotator:review-result / plan-approved events
10673
- }
10674
- if (response && response.status !== "timeout") {
10675
- try {
10676
- (ctx as any).ui?.notify?.(
10677
- `Plannotator unavailable (${response.status}), falling back to inline review`,
10678
- "warning",
10679
- );
10680
- } catch (_e) {
10681
- void _e;
10682
- }
10683
- }
10684
- } catch (e) {
10685
- try {
10686
- (ctx as any).ui?.notify?.(
10687
- `Plannotator request failed, falling back to inline: ${String(e).slice(0, 80)}`,
10688
- "warning",
10689
- );
10690
- } catch (_e) {
10691
- void _e;
10692
- }
10693
- }
10694
- // Browser branch failed — clear gate so inline can show
10695
- clearPendingHandoffDecision();
10696
- persistState();
10697
- // fall through to inline handoff below
11361
+ const handoff = await requestPlanReview({
11362
+ planText: planTextForRender,
11363
+ planPath,
11364
+ source: "auto",
11365
+ ctx: ctx as ExtensionContext,
11366
+ cycleId: handoffCycle,
11367
+ });
11368
+ if (handoff.ok) return;
11369
+ // The helper already cleared the gate; fall through to inline.
10698
11370
  }
10699
11371
  awaitingDecision = true;
10700
11372
  lastHandoffAt = Date.now();