@dieulc/workflow 0.9.2 → 0.11.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
@@ -14,7 +14,7 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
- import { spawn } from "node:child_process";
17
+ import { spawn, spawnSync } from "node:child_process";
18
18
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
19
  import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
20
20
  import type {
@@ -41,7 +41,6 @@ import {
41
41
  type EditorTheme,
42
42
  type SelectItem,
43
43
  wrapTextWithAnsi,
44
- truncateToWidth,
45
44
  } from "@earendil-works/pi-tui";
46
45
  import { Type } from "typebox";
47
46
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -121,6 +120,49 @@ import {
121
120
  type PendingCommitFlow,
122
121
  type StepCommitCandidate,
123
122
  } from "./commit-policy.ts";
123
+ import {
124
+ baseFolderRoots,
125
+ decideFolderGate,
126
+ describeFolderEvaluation,
127
+ detectRepoRoot,
128
+ isBroadGrantRoot,
129
+ outsideProjectRootForTarget,
130
+ evaluateFolderTargets,
131
+ matchSensitivePath,
132
+ readFolderPolicyConfig,
133
+ writeFolderPolicyConfig,
134
+ folderPolicyArgumentCompletions,
135
+ folderPolicyConfigPaths,
136
+ DEFAULT_SENSITIVE_PATTERNS,
137
+ type FolderPolicyConfig,
138
+ type FolderTarget,
139
+ } from "./folder-policy.ts";
140
+ import { analyzeShellPaths } from "./shell-paths.ts";
141
+ import {
142
+ buildReadDenyReason,
143
+ buildReadGuidanceMessage,
144
+ decideReadGate,
145
+ parseReadGateChoice,
146
+ readGateItemFromTarget,
147
+ readGateItemsKind,
148
+ readGateOptions,
149
+ readGateTargetKey,
150
+ readGateTargetsLabel,
151
+ readGateTitle,
152
+ readReadPolicyConfig,
153
+ sanitizeReadInstruction,
154
+ writeReadPolicyConfig,
155
+ readPolicyArgumentCompletions,
156
+ readPolicyConfigPaths,
157
+ revokeReadPolicyPath,
158
+ describeReadPolicy,
159
+ READ_INSTRUCTION_PROMPT_PLACEHOLDER,
160
+ READ_INSTRUCTION_PROMPT_TITLE,
161
+ type ReadGateItem,
162
+ type ReadPolicyConfig,
163
+ } from "./read-policy.ts";
164
+ import { classifyReadCall } from "./read-policy-tools.ts";
165
+ import { redactToolResult } from "./read-policy-redact.ts";
124
166
  import {
125
167
  buildWriteDenyReason,
126
168
  buildWriteGuidanceMessage,
@@ -128,6 +170,7 @@ import {
128
170
  decideWriteGate,
129
171
  describeWritePolicyMode,
130
172
  isWriteCallAllowed,
173
+ normalizeWritePath,
131
174
  parseWriteGateChoice,
132
175
  readWritePolicyConfig,
133
176
  sanitizeWriteInstruction,
@@ -143,6 +186,7 @@ import {
143
186
  WRITE_INSTRUCTION_PROMPT_TITLE,
144
187
  type WriteGateChoice,
145
188
  type WriteGateItem,
189
+ type WriteGateSource,
146
190
  type WriteCallClassification,
147
191
  type WritePolicyConfig,
148
192
  type WritePolicyMode,
@@ -163,6 +207,8 @@ import {
163
207
  roleArgumentCompletions,
164
208
  saveRoleConfig,
165
209
  getRole,
210
+ userRolesPath,
211
+ projectRolesPath,
166
212
  formatRolesForDisplay,
167
213
  formatModelRef,
168
214
  parseModelRef,
@@ -185,6 +231,17 @@ import {
185
231
  setWorkflowStatus,
186
232
  WORKFLOW_STATUS_KEYS,
187
233
  } from "./workflow-status.ts";
234
+ import {
235
+ buildTodoRows,
236
+ deriveTodoPanel,
237
+ isTodoListSettled,
238
+ readPersistedTodoPanel,
239
+ renderTodoSummary,
240
+ todoCompletionSignature,
241
+ todoListKey,
242
+ type TodoPanelOverride,
243
+ type TodoPanelState,
244
+ } from "./todo-panel.ts";
188
245
  import {
189
246
  defaultProfileConfig,
190
247
  loadProfilesDetailed,
@@ -204,11 +261,17 @@ import {
204
261
  PROFILE_ROLES,
205
262
  modelPickerItems,
206
263
  profileActionClosesPicker,
264
+ userProfilesPath,
265
+ projectProfilesPath,
207
266
  type ProfileFlowOutcome,
208
267
  type ProfileConfig,
209
268
  type Profile,
210
269
  type ProfileRoleName,
211
270
  } from "./profiles.ts";
271
+ import {
272
+ formatRetargetResult,
273
+ retargetProviderFiles,
274
+ } from "./provider-retarget.ts";
212
275
  import { FilterableList, type FilterableItem } from "./picker.ts";
213
276
  import { QUESTIONNAIRE_OVERLAY_MAX_HEIGHT } from "./questionnaire-layout.ts";
214
277
  import {
@@ -232,6 +295,7 @@ import {
232
295
  } from "./model-default.ts";
233
296
  import {
234
297
  applySubmittedPlan,
298
+ buildReviewerFallbacks,
235
299
  classifyReviewExecution,
236
300
  createReviewHandoffGate,
237
301
  DEFAULT_REVIEW_MODE_CONFIG,
@@ -247,12 +311,16 @@ import {
247
311
  reviewModeLockKind,
248
312
  reviewModeLockNotice,
249
313
  reviewModeLockStatusLine,
314
+ reviewInputDecision,
315
+ reviewRoundOwnsTurn,
316
+ reviewTurnOwns,
250
317
  reviewNoticeText,
251
318
  reviewPauseIsCurrent,
252
319
  reviewResumeGateOutcome,
253
320
  reviewResumeNotice,
254
321
  reviewResumePlanChoice,
255
322
  reviewEscapeAction,
323
+ reviewElapsedMs,
256
324
  reviewHandoffParks,
257
325
  reviewResumeSettles,
258
326
  reviewStateSummary,
@@ -261,12 +329,14 @@ import {
261
329
  writeReviewModeConfig,
262
330
  writeReviewPrompt,
263
331
  type ReviewFinding,
332
+ type ReviewFallbackModel,
264
333
  type ReviewExecutionResult,
265
334
  type ReviewHandoffGate,
266
335
  type ReviewSdkLoader,
267
336
  type ReviewHandoffPhase,
268
337
  type ReviewHandoffResult,
269
338
  type ReviewModeLockKind,
339
+ type ReviewTurnPhase,
270
340
  type ReviewState,
271
341
  type ReviewTranscriptOp,
272
342
  type ReviewTrigger,
@@ -282,12 +352,35 @@ import {
282
352
  type ReviewLineBuffer,
283
353
  type ReviewModeBandData,
284
354
  } from "./review-ui.ts";
355
+ import {
356
+ pendingHandoffIsStale,
357
+ reviewHandoffRecovery,
358
+ shouldHandoffManualResult,
359
+ type ReviewHandoffSource,
360
+ } from "./review-handoff.ts";
285
361
  import { seedPromptHistory, WorkflowTabEditor } from "./tab-editor.ts";
286
362
 
287
363
  // ── Plannotator Bridge ───────────────────────────────────────────────
288
364
  const PLANNOTATOR_REQUEST = "plannotator:request" as const;
289
365
  const PLANNOTATOR_REVIEW_RESULT = "plannotator:review-result" as const;
290
366
  const PLANNOTATOR_PLAN_APPROVED = "plannotator:plan-approved" as const;
367
+ const PLANNOTATOR_SESSION_OPENED = "plannotator:session-opened" as const;
368
+ const PLANNOTATOR_SESSION_CLOSED = "plannotator:session-closed" as const;
369
+
370
+ /** Structural shape of the optional Plannotator lifecycle events. */
371
+ interface PlannotatorSessionEventLike {
372
+ id?: string;
373
+ action?: string;
374
+ url?: string;
375
+ reviewId?: string;
376
+ requestId?: string;
377
+ outcome?: "approved" | "submitted" | "closed";
378
+ closedBy?: string;
379
+ }
380
+
381
+ type PlanReviewRequestOutcome =
382
+ | { status: "handled"; reviewId?: string; url?: string }
383
+ | { status: "timeout" | "error" | "unavailable"; reason: string };
291
384
 
292
385
  // ── Helpers ──────────────────────────────────────────────────────────
293
386
 
@@ -415,8 +508,17 @@ async function runSingleAgent(
415
508
  shell: false,
416
509
  stdio: ["ignore", "pipe", "pipe"],
417
510
  // Carry the role into the child so its workflow extension gates bash
418
- // with the same policy the parent applies (see permissions.ts).
419
- env: subagentEnv(process.env, agent.role),
511
+ // with the same policy the parent applies (see permissions.ts), and
512
+ // lock the folder roots so the child cannot expand via a sibling
513
+ // project's settings.
514
+ env: {
515
+ ...subagentEnv(process.env, agent.role),
516
+ PI_FOLDER_POLICY_LOCKED: "1",
517
+ PI_FOLDER_POLICY_ROOTS: JSON.stringify([
518
+ detectRepoRoot(cwd) ?? cwd,
519
+ ]),
520
+ PI_FOLDER_POLICY_SOURCE: "parent",
521
+ },
420
522
  },
421
523
  );
422
524
  // Track for cleanup on session_shutdown — prevents orphaned subagents
@@ -499,6 +601,10 @@ function getFinalOutput(messages: any[]): string {
499
601
  */
500
602
  export interface WorkflowExtensionDeps {
501
603
  loadSdk?: ReviewSdkLoader;
604
+ /** Test seam: how long a plan-review request waits for Plannotator. */
605
+ plannotatorRequestTimeoutMs?: number;
606
+ /** Test seam: how long a reload recovery status query waits. */
607
+ plannotatorRecoveryTimeoutMs?: number;
502
608
  }
503
609
 
504
610
  export default function workflowExtension(
@@ -569,12 +675,33 @@ export default function workflowExtension(
569
675
  null;
570
676
  let writeGateTurnSteered = false;
571
677
  const writeGateRunDenials = new Map<string, { instruction: string }>();
678
+ // ── Read-policy gate state (turn-scoped grants, run-scoped denials) ──
679
+ let readGateBatchCalls: { name: string; args: unknown }[] = [];
680
+ let readGateBatchCallIds = new Set<string>();
681
+ const readGateTurnGrantedRoots = new Set<string>();
682
+ const readGateTurnUncertainAllowed = new Set<string>();
683
+ let readGateTurnAnswer: {
684
+ choice: "allow-once" | "always" | "deny";
685
+ instruction: string;
686
+ } | null = null;
687
+ let readGateTurnSteered = false;
688
+ const readGateRunDenials = new Map<string, { instruction: string }>();
572
689
  // ── Reminder throttle + staleness tracking (passive enforcement layer) ──
573
690
  let turnsSinceLastTodoWrite = 0;
574
691
  let turnsSinceLastReminder = 0;
575
692
  let mutatedSinceLastTodoWrite = false;
576
693
  let lastRefMisses: { refs: string[] } | undefined;
577
694
  let todoStale = false;
695
+ // ── Todo panel presentation (pinned widget above the editor) ──
696
+ // The widget can never scroll with the transcript, so it collapses to one
697
+ // line when the plan settles and hides at the next user prompt. Overrides
698
+ // are scoped to `todoPanelOverrideKey` (ref/text/group identity), so status
699
+ // churn keeps the user's choice while a new/renamed list expires it.
700
+ let todoPanelOverride: TodoPanelOverride = null;
701
+ let todoPanelOverrideKey: string | null = null;
702
+ let todoPanelPromptAfterSettle = false;
703
+ let lastCompletedSignature: string | null = null;
704
+ let todoPanelAppliedState: TodoPanelState | "unregistered" = "unregistered";
578
705
  let footerInjectedThisTurn = false;
579
706
  let lastStaleNoticeAt: number | undefined;
580
707
  // Path+content hash of the last plan-file reconciliation (skip no-op merges).
@@ -1151,83 +1278,81 @@ export default function workflowExtension(
1151
1278
  todoStale = false;
1152
1279
  }
1153
1280
 
1154
- /** Display ref for a todo row (stable canonical GLOBAL step). */
1155
- function todoLabel(it: TodoItem, index: number): string {
1156
- return todoRef(it) || String(it.step || index + 1);
1157
- }
1158
-
1159
1281
  /**
1160
- * Rows for the todo widget: group headers + labelled items, anchored on the
1161
- * first incomplete item (including its group header), capped for the terminal.
1282
+ * Reconcile the pinned todo widget with the derived presentation state.
1283
+ * Called from `updateStatus` after the footer writes; only re-registers the
1284
+ * component when the state changes (marking a step done repaints through
1285
+ * the host, so the widget is not disposed/rebuilt on every refresh).
1162
1286
  */
1163
- function buildTodoRows(
1164
- theme: any,
1165
- width: number,
1166
- mode: WorkflowMode | null,
1167
- ): string[] {
1168
- if (todoItems.length === 0) return [];
1169
- const MAX_WIDGET_LINES = 15;
1170
- const contentWidth = Math.max(1, width - 2);
1171
- type Row = { group?: string; item?: TodoItem; index: number };
1172
- const rows: Row[] = [];
1173
- let lastGroup: string | undefined;
1174
- todoItems.forEach((item, index) => {
1175
- if (item.group && item.group !== lastGroup) {
1176
- rows.push({ group: item.group, index });
1177
- lastGroup = item.group;
1178
- }
1179
- rows.push({ item, index });
1287
+ function refreshTodoPanel(ctx: ExtensionContext) {
1288
+ const view = deriveTodoPanel({
1289
+ items: todoItems,
1290
+ override: todoPanelOverride,
1291
+ overrideKey: todoPanelOverrideKey,
1292
+ promptAfterSettle: todoPanelPromptAfterSettle,
1293
+ stale: todoStale,
1180
1294
  });
1181
- const firstUndone = todoItems.findIndex(
1182
- (t) => !isDone(t) && itemStatus(t) !== "cancelled",
1183
- );
1184
- const anchorIndex = firstUndone < 0 ? 0 : firstUndone;
1185
- let startRow = rows.findIndex((r) => r.item && r.index === anchorIndex);
1186
- if (startRow < 0) startRow = 0;
1187
- if (startRow > 0 && rows[startRow - 1]?.group) startRow -= 1;
1188
- const visible = rows.slice(startRow, startRow + MAX_WIDGET_LINES);
1189
- const rendered = visible.map((row) => {
1190
- if (row.group) {
1191
- return truncateToWidth(
1192
- theme.fg("muted", `── ${row.group}`),
1193
- contentWidth,
1194
- "…",
1195
- );
1196
- }
1197
- const item = row.item as TodoItem;
1198
- const ref = todoLabel(item, row.index);
1199
- const status = itemStatus(item);
1200
- const isCurrent = row.index === anchorIndex && !isDone(item);
1201
- const prefix =
1202
- isCurrent || status === "in_progress"
1203
- ? theme.fg("accent", "▶ ")
1204
- : status === "completed"
1205
- ? theme.fg("success", "☑ ")
1206
- : status === "cancelled"
1207
- ? theme.fg("dim", "✖ ")
1208
- : theme.fg("dim", "☐ ");
1209
- const labelPart =
1210
- item.label !== undefined && String(item.label) !== ref
1211
- ? theme.fg("muted", ` (${item.label})`)
1212
- : "";
1213
- const body = `${ref}. ${item.text}${labelPart}`;
1214
- let styled: string;
1215
- if (isDone(item)) {
1216
- styled = theme.fg("muted", theme.strikethrough(body));
1217
- } else if (isCurrent && mode === "build") {
1218
- styled = theme.fg("accent", body);
1219
- } else if (mode === "plan" || mode === null) {
1220
- styled = theme.fg("muted", body);
1221
- } else {
1222
- styled = body;
1295
+ // A choice made for a different list must not hide a new task.
1296
+ if (todoPanelOverrideKey !== view.listKey) {
1297
+ todoPanelOverride = null;
1298
+ todoPanelOverrideKey = null;
1299
+ }
1300
+ if (!view.settled && todoPanelPromptAfterSettle) {
1301
+ todoPanelPromptAfterSettle = false;
1302
+ }
1303
+ // Reopened list: a later completion may emit its own card again.
1304
+ if (!view.settled && lastCompletedSignature !== null) {
1305
+ lastCompletedSignature = null;
1306
+ }
1307
+ if (view.settled && todoStale) todoStale = false;
1308
+ if (view.state === todoPanelAppliedState) return;
1309
+ todoPanelAppliedState = view.state;
1310
+ try {
1311
+ if (view.state === "hidden") {
1312
+ ctx.ui.setWidget("workflow-todos", undefined);
1313
+ return;
1223
1314
  }
1224
- return truncateToWidth(`${prefix}${styled}`, contentWidth, "…");
1225
- });
1226
- const remaining = rows.length - (startRow + visible.length);
1227
- if (remaining > 0) {
1228
- rendered.push(theme.fg("muted", `… +${remaining} more`));
1315
+ ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
1316
+ const render = (w: number) => {
1317
+ // Re-derive so the component always shows live items and the live
1318
+ // mode, even though it was registered for an earlier state.
1319
+ const live = deriveTodoPanel({
1320
+ items: todoItems,
1321
+ override: todoPanelOverride,
1322
+ overrideKey: todoPanelOverrideKey,
1323
+ promptAfterSettle: todoPanelPromptAfterSettle,
1324
+ stale: todoStale,
1325
+ });
1326
+ if (live.state === "hidden") return [];
1327
+ return live.state === "collapsed"
1328
+ ? [renderTodoSummary(theme, w, live)]
1329
+ : buildTodoRows(theme, w, todoItems, workflowMode);
1330
+ };
1331
+ return { render, invalidate: () => {}, dispose: () => {} };
1332
+ });
1333
+ } catch (_e) {
1334
+ void _e;
1335
+ }
1336
+ }
1337
+
1338
+ function setTodoPanelOverride(
1339
+ ctx: ExtensionContext,
1340
+ override: TodoPanelOverride,
1341
+ note?: string,
1342
+ ) {
1343
+ if (!todoItems.length && override === "expanded") {
1344
+ ctx.ui.notify("No todos.", "info");
1345
+ return;
1346
+ }
1347
+ todoPanelOverride = override;
1348
+ todoPanelOverrideKey = override === null ? null : todoListKey(todoItems);
1349
+ updateStatus(ctx);
1350
+ try {
1351
+ persistState();
1352
+ } catch (_e) {
1353
+ void _e;
1229
1354
  }
1230
- return rendered;
1355
+ if (note) ctx.ui.notify(note, "info");
1231
1356
  }
1232
1357
 
1233
1358
  function updateStatus(ctx: ExtensionContext) {
@@ -1242,6 +1367,7 @@ export default function workflowExtension(
1242
1367
  commitMode = readCommitPolicyConfig(
1243
1368
  (ctx as any).cwd ?? process.cwd(),
1244
1369
  getAgentDir(),
1370
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1245
1371
  ).config.mode;
1246
1372
  } catch {
1247
1373
  commitMode = "never";
@@ -1255,27 +1381,41 @@ export default function workflowExtension(
1255
1381
  writeMode = readWritePolicyConfig(
1256
1382
  (ctx as any).cwd ?? process.cwd(),
1257
1383
  getAgentDir(),
1384
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1258
1385
  ).config.mode;
1259
1386
  } catch {
1260
1387
  writeMode = "allow";
1261
1388
  }
1389
+ let folderMode: string | null = null;
1390
+ try {
1391
+ folderMode = readFolderPolicyConfig(
1392
+ (ctx as any).cwd ?? process.cwd(),
1393
+ getAgentDir(),
1394
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1395
+ ).config.mode;
1396
+ } catch {
1397
+ folderMode = null;
1398
+ }
1262
1399
  const segments = buildWorkflowStatusSegments({
1263
1400
  mode: workflowMode,
1264
- plannotatorActive,
1401
+ reviewPhase: reviewTurn,
1265
1402
  done: todoItems.filter((t) => isDone(t)).length,
1266
1403
  total: todoItems.length,
1267
- stale: todoStale,
1404
+ stale: todoStale && !isTodoListSettled(todoItems),
1268
1405
  profileLabel,
1269
1406
  roleLabel,
1270
1407
  commitMode,
1271
1408
  writeMode: workflowMode === null ? writeMode : null,
1409
+ folderMode,
1272
1410
  });
1273
1411
  const modeTone =
1274
- workflowMode === "build"
1412
+ reviewTurn !== "idle"
1275
1413
  ? "accent"
1276
- : workflowMode === "plan"
1277
- ? "warning"
1278
- : "muted";
1414
+ : workflowMode === "build"
1415
+ ? "accent"
1416
+ : workflowMode === "plan"
1417
+ ? "warning"
1418
+ : "muted";
1279
1419
  setWorkflowStatus(
1280
1420
  ctx.ui,
1281
1421
  WORKFLOW_STATUS_KEYS.mode,
@@ -1292,15 +1432,10 @@ export default function workflowExtension(
1292
1432
  ctx.ui.theme.fg("dim", segments.policy),
1293
1433
  );
1294
1434
  if (todoItems.length > 0) {
1295
- ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
1296
- return {
1297
- render: (w: number) => buildTodoRows(theme, w, workflowMode),
1298
- invalidate: () => {},
1299
- dispose: () => {},
1300
- };
1301
- });
1435
+ refreshTodoPanel(ctx);
1302
1436
  } else {
1303
1437
  ctx.ui.setWidget("workflow-todos", undefined);
1438
+ todoPanelAppliedState = "hidden";
1304
1439
  }
1305
1440
  // One status owner: every footer refresh (mode switch, model change, round
1306
1441
  // phase, pause) also re-evaluates the review widget. This is what clears a
@@ -1377,6 +1512,15 @@ export default function workflowExtension(
1377
1512
  let activeReview:
1378
1513
  | { planText: string; round: number; controller: AbortController }
1379
1514
  | undefined;
1515
+ /**
1516
+ * The exclusive Review turn (plan 2026-11-14). Unlike the old derived lock,
1517
+ * this also covers the browser/inline handoff, where no child session runs.
1518
+ * In-memory: a reload restores `handoff` from `awaitingDecision`, otherwise
1519
+ * idle (a running round cannot survive the process).
1520
+ */
1521
+ let reviewTurn: ReviewTurnPhase = "idle";
1522
+ /** The next plan turn is the feedback/refine continuation of a Deny. */
1523
+ let reviewFeedbackTurn = false;
1380
1524
  /** Long-lived ESC listener covering a round and the paused window. */
1381
1525
  let detachReviewEscListener: (() => void) | undefined;
1382
1526
  /**
@@ -1428,9 +1572,47 @@ export default function workflowExtension(
1428
1572
  roundActive,
1429
1573
  paused,
1430
1574
  gatePhase,
1575
+ handoff: reviewTurn === "handoff" || awaitingDecision,
1576
+ transition: reviewTurn === "transition",
1577
+ });
1578
+ }
1579
+
1580
+ /** True while a reviewer round or a real pause owns the turn (not handoff). */
1581
+ function reviewRoundInFlight(): boolean {
1582
+ let roundActive = false;
1583
+ try {
1584
+ roundActive = reviewRuntime?.isRoundActive() === true;
1585
+ } catch (_e) {
1586
+ void _e;
1587
+ }
1588
+ return reviewRoundOwnsTurn({
1589
+ roundInFlight: activeReview !== undefined,
1590
+ roundActive,
1591
+ paused: currentPausedReview() !== undefined,
1592
+ gatePhase:
1593
+ reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle",
1431
1594
  });
1432
1595
  }
1433
1596
 
1597
+ /** One status refresh for every review-turn transition. */
1598
+ function setReviewTurn(phase: ReviewTurnPhase): void {
1599
+ if (reviewTurn === phase) return;
1600
+ reviewTurn = phase;
1601
+ try {
1602
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
1603
+ } catch (_e) {
1604
+ void _e;
1605
+ }
1606
+ }
1607
+
1608
+ function beginReviewTurn(): void {
1609
+ setReviewTurn("running");
1610
+ }
1611
+
1612
+ function endReviewTurn(): void {
1613
+ setReviewTurn("idle");
1614
+ }
1615
+
1434
1616
  /** Refuse a mode transition while a review round or pause owns the turn. */
1435
1617
  function blockModeSwitchDuringReview(
1436
1618
  ctx?: ExtensionContext,
@@ -1517,6 +1699,48 @@ export default function workflowExtension(
1517
1699
  }
1518
1700
  }
1519
1701
 
1702
+ /**
1703
+ * Automatic provider-recovery chain: the parent model that just planned,
1704
+ * then the base reviewer and planner roles from roles.json (ignoring the
1705
+ * active profile). Deduped and thinking-clamped through the parent registry.
1706
+ */
1707
+ function reviewFallbackModels(): ReviewFallbackModel[] {
1708
+ try {
1709
+ ensureRoleConfig(reviewCwd());
1710
+ ensureProfilesConfig(reviewCwd());
1711
+ const parent = currentSessionCtx?.model;
1712
+ const parentThinking = currentSessionCtx?.thinkingLevel;
1713
+ const baseReviewer = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
1714
+ const basePlanner = getRole(roleConfig, "planner")?.model;
1715
+ return buildReviewerFallbacks({
1716
+ ...(parent
1717
+ ? {
1718
+ parentModel: {
1719
+ provider: parent.provider,
1720
+ id: parent.id,
1721
+ thinking: parentThinking ?? "high",
1722
+ },
1723
+ }
1724
+ : {}),
1725
+ ...(baseReviewer ? { baseReviewer } : {}),
1726
+ ...(basePlanner ? { basePlanner } : {}),
1727
+ clampThinking: (model) => {
1728
+ const found = (currentSessionCtx as any)?.modelRegistry?.find?.(
1729
+ model.provider,
1730
+ model.id,
1731
+ );
1732
+ return clampThinkingForModel(
1733
+ found,
1734
+ isValidThinkingLevel(model.thinking) ? model.thinking : "high",
1735
+ );
1736
+ },
1737
+ });
1738
+ } catch (_e) {
1739
+ void _e;
1740
+ return [];
1741
+ }
1742
+ }
1743
+
1520
1744
  /** Reviewer model label for the mode band (best-effort). */
1521
1745
  function reviewModelLabel(): string {
1522
1746
  // While a round runs, the band names the snapshotted model — the one
@@ -1927,6 +2151,7 @@ export default function workflowExtension(
1927
2151
  if (reviewModelSnapshot) return reviewModelSnapshot;
1928
2152
  return resolveReviewerModel();
1929
2153
  },
2154
+ fallbackReviewerModels: () => reviewFallbackModels(),
1930
2155
  findModel: (provider: string, id: string) =>
1931
2156
  (currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
1932
2157
  // Structural probe of the live parent context, evaluated at reviewer
@@ -2043,6 +2268,7 @@ export default function workflowExtension(
2043
2268
  // A gated audit becomes a paused gate (the handoff keeps waiting). A
2044
2269
  // manual review (idle gate) leaves the phase alone.
2045
2270
  if (reviewGatePhase === "auditing") reviewGatePhase = "paused";
2271
+ setReviewTurn("paused");
2046
2272
  refreshReviewPauseUi();
2047
2273
  return true;
2048
2274
  }
@@ -2180,6 +2406,9 @@ export default function workflowExtension(
2180
2406
  try {
2181
2407
  const ctx = currentSessionCtx;
2182
2408
  if (!ctx || !(ctx as any).hasUI) return;
2409
+ if (message !== undefined) {
2410
+ (ctx as any).ui?.setWorkingVisible?.(true);
2411
+ }
2183
2412
  (ctx as any).ui?.setWorkingMessage?.(message);
2184
2413
  } catch (_e) {
2185
2414
  void _e;
@@ -2251,6 +2480,10 @@ export default function workflowExtension(
2251
2480
  // follow `reviewState`; the spinner row needs an explicit message while
2252
2481
  // the child session streams off the main turn.
2253
2482
  setReviewWorkingMessage("🔍 Reviewing plan…");
2483
+ setReviewTurn("running");
2484
+ // Paint the live review line now, not after the child session's first
2485
+ // state event: a manual `/review` must look alive from the first tick.
2486
+ updateStatus(ctx as any);
2254
2487
  // One long-lived listener covers this round AND the paused window (see
2255
2488
  // ensureReviewEscListener for why it is no longer attached per round).
2256
2489
  ensureReviewEscListener(ctx);
@@ -2466,6 +2699,17 @@ export default function workflowExtension(
2466
2699
  }
2467
2700
  }
2468
2701
 
2702
+ // A new round can never inherit an older browser gate: the old tab's
2703
+ // decision would otherwise suppress this round's handoff after it lands.
2704
+ if (awaitingDecision) {
2705
+ supersedePendingHandoff(
2706
+ trigger === "manual"
2707
+ ? "a manual /review started"
2708
+ : "a new review round started",
2709
+ { force: true, nextPlanHash: hash, nextCycleId: reviewCycleId },
2710
+ );
2711
+ }
2712
+
2469
2713
  // A different plan file is a new target even inside one user run: reset the
2470
2714
  // budget/hashes/findings before the dedupe gates below.
2471
2715
  const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
@@ -2634,6 +2878,16 @@ export default function workflowExtension(
2634
2878
  const hadGate = reviewGatePhase !== "idle";
2635
2879
  // Synchronously first: any old handoff continuation is stale from here on.
2636
2880
  reviewCycleId += 1;
2881
+ if (
2882
+ opts.reason === "prompt" ||
2883
+ opts.reason === "build" ||
2884
+ opts.reason === "session"
2885
+ ) {
2886
+ supersedePendingHandoff(`a new ${opts.reason} cycle started`, {
2887
+ force: true,
2888
+ });
2889
+ manualReviewCycle = false;
2890
+ }
2637
2891
  if (hadActive || pausedReview !== undefined || hadGate) {
2638
2892
  try {
2639
2893
  await suspendReview();
@@ -2736,7 +2990,9 @@ export default function workflowExtension(
2736
2990
  * they are not guarded. Clearing the review state releases the derived
2737
2991
  * mode-switch lock (plan 2026-10-25).
2738
2992
  */
2739
- async function teardownReview(): Promise<void> {
2993
+ async function teardownReview(
2994
+ opts: { keepHandoff?: boolean } = {},
2995
+ ): Promise<void> {
2740
2996
  try {
2741
2997
  activeReview?.controller.abort();
2742
2998
  } catch (_e) {
@@ -2749,6 +3005,7 @@ export default function workflowExtension(
2749
3005
  }
2750
3006
  reviewRuntime = undefined;
2751
3007
  reviewState = undefined;
3008
+ if (opts.keepHandoff !== true) manualReviewCycle = false;
2752
3009
  lastReviewedPlanHash = undefined;
2753
3010
  reviewRound = 0;
2754
3011
  reviewPreviousFindings = undefined;
@@ -2763,11 +3020,12 @@ export default function workflowExtension(
2763
3020
  activeReview = undefined;
2764
3021
  abortedReviewHash = undefined;
2765
3022
  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();
3023
+ // A reload/resume keeps the browser correlation so the next session_start
3024
+ // can recover it; every other teardown ends the decision and provenance.
3025
+ const clearedDecision =
3026
+ opts.keepHandoff === true ? false : clearPendingHandoffDecision();
2769
3027
  resetPlanRunProvenance();
2770
- if (clearedDecision) {
3028
+ if (clearedDecision || opts.keepHandoff === true) {
2771
3029
  try {
2772
3030
  persistState();
2773
3031
  } catch (_e) {
@@ -2778,6 +3036,7 @@ export default function workflowExtension(
2778
3036
  flushReviewEntries(true);
2779
3037
  resetReviewStream();
2780
3038
  detachReviewEscListenerNow();
3039
+ endReviewTurn();
2781
3040
  try {
2782
3041
  currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
2783
3042
  } catch (_e) {
@@ -2903,6 +3162,15 @@ export default function workflowExtension(
2903
3162
 
2904
3163
  const scope = scopeFor("global");
2905
3164
  const enable = sub === "on";
3165
+ // Disabling would tear down the review (and its pending handoff) behind
3166
+ // the lock. Abort is the explicit exit while Review owns the turn.
3167
+ if (!enable && reviewTurnOwns(reviewTurn)) {
3168
+ ctx.ui.notify(
3169
+ "Review is active — /review-abort first (or resume/approve in the review tab).",
3170
+ "info",
3171
+ );
3172
+ return;
3173
+ }
2906
3174
 
2907
3175
  const res = writeReviewModeConfig(scope, cwd, agentDir, {
2908
3176
  enabled: enable,
@@ -2954,7 +3222,9 @@ export default function workflowExtension(
2954
3222
  : "global";
2955
3223
 
2956
3224
  if (!sub || sub === "status") {
2957
- const resolved = readCommitPolicyConfig(cwd, agentDir);
3225
+ const resolved = readCommitPolicyConfig(cwd, agentDir, {
3226
+ trusted: (ctx as any).isProjectTrusted?.() === true,
3227
+ });
2958
3228
  const paths = commitConfigPaths(cwd, agentDir);
2959
3229
  const where =
2960
3230
  resolved.sources.mode === "default"
@@ -3013,8 +3283,9 @@ export default function workflowExtension(
3013
3283
  });
3014
3284
 
3015
3285
  // ── /write-policy: view/set the write permission (workflow.writePolicy) ──
3016
- // Available in every mode: it only writes settings JSON; the gate itself
3017
- // lives in tool_call and applies in Default mode only.
3286
+ // Available in every mode: it only writes settings JSON. The write *mode*
3287
+ // applies in Default mode only; the shared folder floor applies in every
3288
+ // mode, Build included.
3018
3289
  pi.registerCommand("write-policy", {
3019
3290
  description:
3020
3291
  "View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
@@ -3032,7 +3303,9 @@ export default function workflowExtension(
3032
3303
  : "global";
3033
3304
 
3034
3305
  if (!sub || sub === "status") {
3035
- const resolved = readWritePolicyConfig(cwd, agentDir);
3306
+ const resolved = readWritePolicyConfig(cwd, agentDir, {
3307
+ trusted: (ctx as any).isProjectTrusted?.() === true,
3308
+ });
3036
3309
  const paths = writePolicyConfigPaths(cwd, agentDir);
3037
3310
  const where =
3038
3311
  resolved.sources.mode === "default"
@@ -3041,7 +3314,8 @@ export default function workflowExtension(
3041
3314
  const lines = [
3042
3315
  `Write policy: ${resolved.config.mode} (${where})`,
3043
3316
  ` ${describeWritePolicyMode(resolved.config.mode)}`,
3044
- " Applies to Default mode only — Plan mode is read-only already, /build is the execution escape hatch.",
3317
+ " The write mode applies to Default mode only; the shared folder floor applies in every mode.",
3318
+ " Plan mode is read-only already; /build is the execution escape hatch.",
3045
3319
  ];
3046
3320
  if (resolved.config.allowedPaths.length > 0) {
3047
3321
  lines.push(
@@ -3115,6 +3389,133 @@ export default function workflowExtension(
3115
3389
  // gate: it reads the same analyzeCommand/explainCommandRefusal the gates
3116
3390
  // use, so the explanation cannot describe a policy the gate no longer
3117
3391
  // enforces. Read-only, so it is safe in every mode.
3392
+ pi.registerCommand("folder-policy", {
3393
+ description: "Shared folder scope for read/write/commit: repo|cwd|parent|any, outside ask|block, sensitive list",
3394
+ handler: async (args, ctx) => {
3395
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3396
+ const agentDir = getAgentDir();
3397
+ const trusted = (ctx as any).isProjectTrusted?.() === true;
3398
+ const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
3399
+ const sub = (tokens[0] || "status").toLowerCase();
3400
+ const second = (tokens[1] || "").toLowerCase();
3401
+ const scope: "global" | "project" = tokens.includes("project")
3402
+ ? "project"
3403
+ : "global";
3404
+ const resolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
3405
+ if (sub === "status") {
3406
+ const paths = folderPolicyConfigPaths(cwd, agentDir);
3407
+ const lines = [
3408
+ `Folder policy: ${resolved.config.mode} (${resolved.sources.mode === "default" ? "default" : resolved.sources.mode + " settings"})`,
3409
+ ` outside: ${resolved.config.outside}; grantScope: ${resolved.config.grantScope}; templates: ${resolved.config.sensitiveTemplates}`,
3410
+ ` base: ${detectRepoRoot(cwd) ?? cwd}`,
3411
+ " unverifiable writes: not prompted (use /write-policy ask for strictness)",
3412
+ ` sensitive: ${DEFAULT_SENSITIVE_PATTERNS.length} default, ${resolved.config.sensitivePatterns.length} extra, ${resolved.config.sensitiveExemptions.length} exemption(s)`,
3413
+ `Global: ${paths.global}`,
3414
+ `Project: ${paths.project}${resolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
3415
+ "Usage: /folder-policy repo|cwd|parent|any [global|project] · outside ask|block · grant project|global · templates allow|block · sensitive",
3416
+ ];
3417
+ ctx.ui.notify(lines.join("\n"), "info");
3418
+ return;
3419
+ }
3420
+ if (sub === "sensitive") {
3421
+ ctx.ui.notify(
3422
+ [
3423
+ "Sensitive deny list (always blocked for read/write/commit):",
3424
+ ...DEFAULT_SENSITIVE_PATTERNS.map((p) => ` ${p}`),
3425
+ ...(resolved.config.sensitivePatterns.length
3426
+ ? ["Extra:", ...resolved.config.sensitivePatterns.map((p) => ` ${p}`)]
3427
+ : []),
3428
+ ...(resolved.config.sensitiveExemptions.length
3429
+ ? ["Exemptions (manual):", ...resolved.config.sensitiveExemptions.map((p) => ` ${p}`)]
3430
+ : []),
3431
+ ].join("\n"),
3432
+ "info",
3433
+ );
3434
+ return;
3435
+ }
3436
+ let patch: Record<string, unknown> | undefined;
3437
+ if (["repo", "cwd", "parent", "any"].includes(sub)) patch = { mode: sub };
3438
+ else if (sub === "outside" && ["ask", "block"].includes(second)) patch = { outside: second };
3439
+ else if (sub === "grant" && ["project", "global"].includes(second)) patch = { grantScope: second };
3440
+ else if (sub === "templates" && ["allow", "block"].includes(second)) patch = { sensitiveTemplates: second };
3441
+ if (!patch) {
3442
+ ctx.ui.notify("Usage: /folder-policy repo|cwd|parent|any · outside ask|block · grant project|global · templates allow|block · sensitive", "info");
3443
+ return;
3444
+ }
3445
+ const written = writeFolderPolicyConfig(scope, cwd, agentDir, patch as any);
3446
+ ctx.ui.notify(
3447
+ written.ok
3448
+ ? `Folder policy updated (${scope}): ${written.path}`
3449
+ : `Could not write folder policy: ${written.error ?? "unknown error"}`,
3450
+ written.ok ? "info" : "error",
3451
+ );
3452
+ updateStatus(ctx as any);
3453
+ },
3454
+ getArgumentCompletions: (prefix: string) => folderPolicyArgumentCompletions(prefix),
3455
+ });
3456
+
3457
+ pi.registerCommand("read-policy", {
3458
+ description: "Read permission: grants, shell paths|strict, guarded tools and the shared folder scope",
3459
+ handler: async (args, ctx) => {
3460
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3461
+ const agentDir = getAgentDir();
3462
+ const trusted = (ctx as any).isProjectTrusted?.() === true;
3463
+ const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
3464
+ const sub = (tokens[0] || "status").toLowerCase();
3465
+ const readResolved = readReadPolicyConfig(cwd, agentDir, { trusted });
3466
+ const folderResolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
3467
+ const paths = readPolicyConfigPaths(cwd, agentDir);
3468
+ if (sub === "status" || !sub) {
3469
+ ctx.ui.notify(
3470
+ [
3471
+ describeReadPolicy(readResolved.config, folderResolved.config),
3472
+ ` grants: ${readResolved.config.allowedPaths.join(", ") || "none"}`,
3473
+ ` guarded tools: ${readResolved.config.guardedTools.join(", ") || "none"}`,
3474
+ `Global: ${paths.global}`,
3475
+ `Project: ${paths.project}${readResolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
3476
+ "Usage: /read-policy allow <path> [global|project] · revoke <path> · shell paths|strict · status",
3477
+ ].join("\n"),
3478
+ "info",
3479
+ );
3480
+ return;
3481
+ }
3482
+ const scope: "global" | "project" = tokens.includes("project")
3483
+ ? "project"
3484
+ : "global";
3485
+ if (sub === "shell") {
3486
+ const value = (tokens[1] || "").toLowerCase();
3487
+ if (value !== "paths" && value !== "strict") {
3488
+ ctx.ui.notify("Usage: /read-policy shell paths|strict [global|project]", "info");
3489
+ return;
3490
+ }
3491
+ const written = writeReadPolicyConfig(scope, cwd, agentDir, { shell: value });
3492
+ ctx.ui.notify(written.ok ? `Read shell → ${value} (${scope})` : `Could not save: ${written.error}`, written.ok ? "info" : "error");
3493
+ updateStatus(ctx as any);
3494
+ return;
3495
+ }
3496
+ if (sub === "allow" || sub === "revoke") {
3497
+ const target = tokens.slice(1).find((t) => t !== "global" && t !== "project");
3498
+ if (!target) {
3499
+ ctx.ui.notify(`Usage: /read-policy ${sub} <path> [global|project]`, "info");
3500
+ return;
3501
+ }
3502
+ const result =
3503
+ sub === "allow"
3504
+ ? writeReadPolicyConfig(scope, cwd, agentDir, { allowedPaths: [target] })
3505
+ : revokeReadPolicyPath(scope, cwd, agentDir, target);
3506
+ ctx.ui.notify(
3507
+ result.ok
3508
+ ? `Read grant ${sub === "allow" ? "added" : "removed"} (${scope}): ${target}`
3509
+ : `Could not save read grant: ${result.error}`,
3510
+ result.ok ? "info" : "error",
3511
+ );
3512
+ return;
3513
+ }
3514
+ ctx.ui.notify("Usage: /read-policy status|allow <path>|revoke <path>|shell paths|strict", "info");
3515
+ },
3516
+ getArgumentCompletions: (prefix: string) => readPolicyArgumentCompletions(prefix),
3517
+ });
3518
+
3118
3519
  pi.registerCommand("permissions", {
3119
3520
  description:
3120
3521
  "Explain why a shell command would be allowed or refused: /permissions explain <command>",
@@ -3123,7 +3524,55 @@ export default function workflowExtension(
3123
3524
  const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
3124
3525
  // `/permissions explain <cmd>` and `/permissions <cmd>` both work.
3125
3526
  const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
3126
- ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
3527
+ const lines = describeCommandPolicy(command);
3528
+ if (command.trim()) {
3529
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3530
+ const trusted = (ctx as any).isProjectTrusted?.() === true;
3531
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
3532
+ trusted,
3533
+ }).config;
3534
+ const writeConfig = readWritePolicyConfig(cwd, getAgentDir(), {
3535
+ trusted,
3536
+ }).config;
3537
+ const readConfig = readReadPolicyConfig(cwd, getAgentDir(), {
3538
+ trusted,
3539
+ }).config;
3540
+ const analysis = analyzeShellPaths(command, cwd);
3541
+ const classification = classifyWriteCall(
3542
+ "bash",
3543
+ { command },
3544
+ writeConfig,
3545
+ );
3546
+ const writeTargets = writeFolderTargetsFor(
3547
+ "bash",
3548
+ { command },
3549
+ classification,
3550
+ cwd,
3551
+ );
3552
+ const evaluation = evaluateFolderTargets(
3553
+ [...analysis.reads, ...writeTargets],
3554
+ {
3555
+ config: folderConfig,
3556
+ grants: [...writeConfig.allowedPaths, ...readConfig.allowedPaths],
3557
+ cwd,
3558
+ action: "write",
3559
+ },
3560
+ );
3561
+ lines.push(
3562
+ "",
3563
+ ...describeFolderEvaluation(
3564
+ evaluation,
3565
+ detectRepoRoot(cwd) ?? cwd,
3566
+ ),
3567
+ );
3568
+ if (analysis.uncertain.length > 0) {
3569
+ lines.push(` shell analysis: ${analysis.uncertain.join("; ")}`);
3570
+ }
3571
+ lines.push(
3572
+ " unverifiable writes are not prompted under writePolicy allow/Build; use /write-policy ask for strictness",
3573
+ );
3574
+ }
3575
+ ctx.ui.notify(lines.join("\n"), "info");
3127
3576
  },
3128
3577
  getArgumentCompletions: (prefix: string) =>
3129
3578
  permissionsArgumentCompletions(prefix),
@@ -3179,6 +3628,7 @@ export default function workflowExtension(
3179
3628
  [
3180
3629
  `Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
3181
3630
  " /review-resume to continue, /review-abort to stop.",
3631
+ pendingHandoffStatusLine(),
3182
3632
  reviewModeLockStatusLine(lockKind),
3183
3633
  ]
3184
3634
  .filter(Boolean)
@@ -3199,6 +3649,7 @@ export default function workflowExtension(
3199
3649
  reviewEnabled()
3200
3650
  ? "Review Mode is enabled but no review has run yet."
3201
3651
  : `Review Mode is OFF — automatic review disabled (/review-mode on to enable).${cfgLine} /review runs a one-shot review.`,
3652
+ pendingHandoffStatusLine(),
3202
3653
  reviewModeLockStatusLine(lockKind),
3203
3654
  ]
3204
3655
  .filter(Boolean)
@@ -3211,6 +3662,10 @@ export default function workflowExtension(
3211
3662
  reviewStateSummary(s),
3212
3663
  ` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
3213
3664
  ` model : ${s.modelLabel}`,
3665
+ ` attempt : ${s.recoveryAttempts?.length ? s.recoveryAttempts.join(" → ") : "single model"}`,
3666
+ ` elapsed : ${Math.round(reviewElapsedMs(s) / 1000)}s${
3667
+ s.providerError ? ` provider: ${s.providerError.slice(0, 200)}` : ""
3668
+ }`,
3214
3669
  ` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
3215
3670
  ` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
3216
3671
  s.verdict ? ` verdict : ${s.verdict}` : "",
@@ -3224,6 +3679,7 @@ export default function workflowExtension(
3224
3679
  reviewRuntime?.isRoundActive()
3225
3680
  ? " live : round in flight — /role and /profile switches apply next round; /review say <msg> to steer"
3226
3681
  : "",
3682
+ pendingHandoffStatusLine(),
3227
3683
  reviewModeLockStatusLine(lockKind),
3228
3684
  ...s.findings.map(
3229
3685
  (f) =>
@@ -3387,6 +3843,9 @@ export default function workflowExtension(
3387
3843
  abortedReviewHash = undefined;
3388
3844
  pausedReview = undefined;
3389
3845
  reviewRound = 0;
3846
+ // Manual /review is a Review turn from the first tick: the mode status,
3847
+ // input gate and mode lock must be active before the child boots.
3848
+ beginReviewTurn();
3390
3849
  const result = await runReviewStep(
3391
3850
  target.text,
3392
3851
  ctx as any,
@@ -3398,6 +3857,16 @@ export default function workflowExtension(
3398
3857
  const offSuffix = reviewEnabled()
3399
3858
  ? ""
3400
3859
  : " (Review Mode stays OFF — /review-mode on for automatic review.)";
3860
+ if (
3861
+ await openManualHandoff(
3862
+ result,
3863
+ target.abs,
3864
+ ctx as any,
3865
+ offSuffix,
3866
+ )
3867
+ ) {
3868
+ return;
3869
+ }
3401
3870
  ctx.ui.notify(`${reviewResumeNotice(result)}${offSuffix}`, "info");
3402
3871
  },
3403
3872
  });
@@ -3442,12 +3911,16 @@ export default function workflowExtension(
3442
3911
  ctx.ui.notify("A review round is already running.", "warning");
3443
3912
  return;
3444
3913
  }
3914
+ // A gated resume is settled by its parked runHandoff; a manual pause
3915
+ // (idle gate) opens its own browser handoff after the round lands.
3916
+ const gatedResume = reviewGatePhase !== "idle";
3445
3917
  // A resumed gated review is auditing again. Without this, the parked
3446
3918
  // handoff keeps `reviewGatePhase === "paused"` for the whole round and
3447
3919
  // the lock/footer report the wrong state (plan 2026-10-25 follow-up).
3448
3920
  // A manual pause (idle gate) is unchanged; the runtime/activeReview
3449
3921
  // flags carry that lock.
3450
3922
  reviewGatePhase = reviewGatePhaseOnResume(reviewGatePhase);
3923
+ setReviewTurn("running");
3451
3924
  // A stale quiet-abort marker must not silence this explicit resume.
3452
3925
  quietAbortHash = undefined;
3453
3926
  // If the plan file moved on while paused, review the new revision fresh
@@ -3485,7 +3958,15 @@ export default function workflowExtension(
3485
3958
  planText: result.planText,
3486
3959
  });
3487
3960
  }
3488
- ctx.ui.notify(reviewResumeNotice(result), "info");
3961
+ if (!gatedResume && reviewResumeSettles(result)) {
3962
+ await openManualHandoff(
3963
+ result,
3964
+ currentPlanFile ?? "",
3965
+ ctx as any,
3966
+ );
3967
+ } else {
3968
+ ctx.ui.notify(reviewResumeNotice(result), "info");
3969
+ }
3489
3970
  return;
3490
3971
  }
3491
3972
  pausedReview = undefined;
@@ -3510,7 +3991,11 @@ export default function workflowExtension(
3510
3991
  planText: result.planText,
3511
3992
  });
3512
3993
  }
3513
- ctx.ui.notify(reviewResumeNotice(result), "info");
3994
+ if (!gatedResume && reviewResumeSettles(result)) {
3995
+ await openManualHandoff(result, currentPlanFile ?? "", ctx as any);
3996
+ } else {
3997
+ ctx.ui.notify(reviewResumeNotice(result), "info");
3998
+ }
3514
3999
  },
3515
4000
  });
3516
4001
 
@@ -3525,6 +4010,46 @@ export default function workflowExtension(
3525
4010
  const active =
3526
4011
  activeReview !== undefined || rt?.isRoundActive() === true;
3527
4012
  const paused = currentPausedReview();
4013
+ // A browser/inline decision can own the turn with no round and no pause.
4014
+ if (!active && !paused && awaitingDecision) {
4015
+ const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
4016
+ const planText =
4017
+ (planPath &&
4018
+ (() => {
4019
+ try {
4020
+ return fs.existsSync(planPath)
4021
+ ? fs.readFileSync(planPath, "utf8")
4022
+ : undefined;
4023
+ } catch {
4024
+ return undefined;
4025
+ }
4026
+ })()) ??
4027
+ "";
4028
+ if (pendingHandoffReviewUrl) {
4029
+ setReviewTurn("handoff");
4030
+ ctx.ui.notify(
4031
+ "Review skipped — approve or annotate in the review tab.",
4032
+ "info",
4033
+ );
4034
+ return;
4035
+ }
4036
+ if (planText && planPath) {
4037
+ const handed = await openManualHandoff(
4038
+ { planText, outcome: "aborted", paused: false },
4039
+ planPath,
4040
+ ctx as any,
4041
+ );
4042
+ if (!handed) setReviewTurn("handoff");
4043
+ return;
4044
+ }
4045
+ clearPendingHandoffDecision();
4046
+ setReviewTurn("idle");
4047
+ ctx.ui.notify(
4048
+ `Review skipped — the plan file is ${planPath || "unavailable"}.`,
4049
+ "info",
4050
+ );
4051
+ return;
4052
+ }
3528
4053
  if (!active && !paused) {
3529
4054
  ctx.ui.notify("No review running or paused.", "info");
3530
4055
  return;
@@ -3536,6 +4061,10 @@ export default function workflowExtension(
3536
4061
  // abort lands; falls back to the author's draft when unknown).
3537
4062
  const abortedText =
3538
4063
  paused?.planText ?? activeReview?.planText;
4064
+ const pausedPlanPath = currentPlanFile ?? "";
4065
+ // A parked automatic handoff owns its own presentation: settle the gate
4066
+ // and let `runHandoff` render, never present twice.
4067
+ const gated = reviewGatePhase !== "idle" && !reviewHandoffGate.settled();
3539
4068
  pausedReview = undefined;
3540
4069
  // An aborted revision is not auto-reviewed again until the plan changes.
3541
4070
  if (targetHash) abortedReviewHash = targetHash;
@@ -3558,9 +4087,15 @@ export default function workflowExtension(
3558
4087
  "Review stopped — the author's plan stands unreviewed.",
3559
4088
  "info",
3560
4089
  );
3561
- // A gated handoff was waiting on this round: hand off the author's
3562
- // plan through the normal renderer so the plan is always delivered.
3563
- settleReviewGate({ outcome: "unreviewed", planText: abortedText });
4090
+ if (gated) {
4091
+ setReviewTurn("handoff");
4092
+ settleReviewGate({ outcome: "unreviewed", planText: abortedText });
4093
+ return;
4094
+ }
4095
+ // A manual /review command is awaiting this round; it will present the
4096
+ // aborted plan through `openManualHandoff` (shouldHandoffManualResult
4097
+ // now includes aborted). Keep Review visible until then.
4098
+ setReviewTurn("handoff");
3564
4099
  return;
3565
4100
  }
3566
4101
  flushReviewEntries(true);
@@ -3569,8 +4104,20 @@ export default function workflowExtension(
3569
4104
  "Paused review discarded — the author's plan stands.",
3570
4105
  "info",
3571
4106
  );
3572
- settleReviewGate({ outcome: "unreviewed", planText: abortedText });
3573
- // No round will run its finally, so clear the live review line here.
4107
+ if (gated) {
4108
+ setReviewTurn("handoff");
4109
+ settleReviewGate({ outcome: "unreviewed", planText: abortedText });
4110
+ return;
4111
+ }
4112
+ if (abortedText && pausedPlanPath) {
4113
+ await openManualHandoff(
4114
+ { planText: abortedText, outcome: "aborted", paused: false },
4115
+ pausedPlanPath,
4116
+ ctx as any,
4117
+ );
4118
+ return;
4119
+ }
4120
+ setReviewTurn("idle");
3574
4121
  updateStatus(ctx as any);
3575
4122
  },
3576
4123
  });
@@ -3598,6 +4145,12 @@ export default function workflowExtension(
3598
4145
  */
3599
4146
  interface ModeSwitchOptions {
3600
4147
  quiet?: boolean;
4148
+ /**
4149
+ * Internal review-decision exit: the caller already validated the pending
4150
+ * decision and owns the `transition` state, so the full review lock must
4151
+ * not refuse it. Never set this from a user-facing mode switch.
4152
+ */
4153
+ fromReview?: boolean;
3601
4154
  }
3602
4155
 
3603
4156
  /** Pure helper for tests: resolve which RoleModel plan/build will switch to. */
@@ -4014,6 +4567,16 @@ export default function workflowExtension(
4014
4567
  planFile: currentPlanFile,
4015
4568
  awaitingDecision,
4016
4569
  awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
4570
+ // Full correlation: a restored gate must know which browser review it
4571
+ // belongs to, or it can only suppress the next handoff.
4572
+ lastReviewId: awaitingDecision ? lastReviewId : null,
4573
+ pendingHandoffPlanPath: awaitingDecision ? pendingHandoffPlanPath : null,
4574
+ pendingHandoffPlanHash: awaitingDecision ? pendingHandoffPlanHash : null,
4575
+ pendingHandoffRequestId: awaitingDecision ? pendingHandoffRequestId : null,
4576
+ pendingHandoffSource: awaitingDecision ? pendingHandoffSource : null,
4577
+ pendingHandoffCycleId: awaitingDecision ? pendingHandoffCycleId : 0,
4578
+ pendingHandoffReviewUrl: awaitingDecision ? pendingHandoffReviewUrl : null,
4579
+ manualReviewCycle,
4017
4580
  role: {
4018
4581
  activeRole: roleConfig.activeRole,
4019
4582
  // Session-scoped profile activation (see setActiveProfile): explicit
@@ -4023,6 +4586,12 @@ export default function workflowExtension(
4023
4586
  },
4024
4587
  defaultModel,
4025
4588
  lastModeSwitch,
4589
+ todoPanel: {
4590
+ override: todoPanelOverride,
4591
+ overrideKey: todoPanelOverrideKey,
4592
+ promptAfterSettle: todoPanelPromptAfterSettle,
4593
+ completedSignature: lastCompletedSignature,
4594
+ },
4026
4595
  } as any);
4027
4596
  lastPersistedPlanFile = currentPlanFile;
4028
4597
  }
@@ -4039,9 +4608,10 @@ export default function workflowExtension(
4039
4608
  return;
4040
4609
  }
4041
4610
  // A live review owns the turn: no mode transition until it lands, fails,
4042
- // pauses or is aborted. The post-review approve path enters Build through
4043
- // `enterBuildModeFromPlan`, which is no longer locked once the gate settles.
4044
- if (blockModeSwitchDuringReview(ctx)) return;
4611
+ // pauses or is aborted. A validated browser/inline decision passes
4612
+ // `fromReview` because it is the exit, not a competing mode switch.
4613
+ if (opts?.fromReview !== true && blockModeSwitchDuringReview(ctx)) return;
4614
+ if (opts?.fromReview === true && reviewRoundInFlight()) return;
4045
4615
  // Leaving Default mode: remember its model so it can be restored on return.
4046
4616
  if (workflowMode === null) captureDefaultModel(ctx);
4047
4617
  workflowMode = "plan";
@@ -4260,11 +4830,19 @@ export default function workflowExtension(
4260
4830
  async function enterBuildModeFromPlan(
4261
4831
  ctx: ExtensionContext,
4262
4832
  planPath: string,
4263
- ) {
4264
- // Defense in depth: the inline/browser approve paths only run after the
4265
- // gated review resolves, but a stale approve must never switch tools while
4266
- // a round or pause still owns the turn.
4267
- if (blockModeSwitchDuringReview(ctx)) return;
4833
+ opts?: { fromReview?: boolean },
4834
+ ): Promise<boolean> {
4835
+ // Defense in depth: a stale approve must never switch tools while a real
4836
+ // reviewer round/pause owns the turn. The handoff itself is consumed by
4837
+ // the caller (fromReview), so only `reviewRoundInFlight()` can refuse it.
4838
+ if (opts?.fromReview === true) {
4839
+ if (reviewRoundInFlight()) {
4840
+ ctx.ui.notify(reviewModeLockNotice("running"), "info");
4841
+ return false;
4842
+ }
4843
+ } else if (blockModeSwitchDuringReview(ctx)) {
4844
+ return false;
4845
+ }
4268
4846
  workflowMode = "build";
4269
4847
  syncLegacyFlags();
4270
4848
  // The plan is being handed to Build; this Plan cycle cannot hand off again.
@@ -4303,6 +4881,7 @@ export default function workflowExtension(
4303
4881
  : `Build mode: ${loaded.count} todos synced from plan (progress kept).`,
4304
4882
  "info",
4305
4883
  );
4884
+ return true;
4306
4885
  }
4307
4886
 
4308
4887
  // ── Plannotator bridge helpers ─────────────────────────────────
@@ -4310,83 +4889,377 @@ export default function workflowExtension(
4310
4889
  return currentSessionCtx;
4311
4890
  }
4312
4891
 
4313
- function handlePlannotatorApprove(
4314
- planFilePath: string,
4315
- planContent: string,
4316
- feedback: string | undefined,
4317
- cwd: string | undefined,
4318
- ) {
4319
- const ctx = getActiveCtx();
4320
- if (!ctx) return;
4321
- if (workflowMode !== "plan") return;
4322
- // A stale browser approve while a review runs must not clear the pending
4323
- // decision or send the execute messages: the plan is still being audited.
4324
- if (blockModeSwitchDuringReview(ctx as any)) return;
4325
- // No prompt means no decision to consume. A fresh prompt/mode switch
4326
- // clears this, so an old browser tab cannot approve the previous plan.
4892
+ /**
4893
+ * Adopt the review id/url Plannotator announced. This is the only place the
4894
+ * transcript handoff entry is written, so a `respond` callback and a
4895
+ * `session-opened` event for the same review cannot duplicate it.
4896
+ */
4897
+ function adoptPlannotatorSession(event: PlannotatorSessionEventLike): void {
4327
4898
  if (!awaitingDecision) return;
4328
- // Resolve plan path (plannotator gives cwd + relative path)
4329
- let resolvedPlan = planFilePath;
4330
- try {
4331
- if (cwd && !path.isAbsolute(planFilePath)) {
4332
- resolvedPlan = path.join(cwd, planFilePath);
4333
- } else if (!path.isAbsolute(planFilePath)) {
4334
- resolvedPlan = path.join(
4335
- (ctx as any).cwd ?? cwd ?? process.cwd(),
4336
- planFilePath,
4337
- );
4338
- }
4339
- } catch (_e) {
4340
- void _e;
4899
+ if (typeof event.reviewId === "string" && event.reviewId) {
4900
+ lastReviewId = event.reviewId;
4341
4901
  }
4342
- // Correlate the event with the plan the current handoff prompt showed.
4343
- // A stale browser tab must not approve an older plan after a new task.
4344
- try {
4345
- if (
4346
- !pendingHandoffPlanPath ||
4347
- path.resolve(resolvedPlan) !== path.resolve(pendingHandoffPlanPath)
4348
- ) {
4349
- ctx.ui.notify(
4350
- "Ignoring a stale browser approval from a previous plan.",
4351
- "info",
4352
- );
4353
- return;
4354
- }
4355
- } catch (_e) {
4356
- void _e;
4357
- if (!pendingHandoffPlanPath || resolvedPlan !== pendingHandoffPlanPath)
4358
- return;
4902
+ if (!lastReviewId) return;
4903
+ if (typeof event.url === "string" && event.url) {
4904
+ pendingHandoffReviewUrl = event.url;
4359
4905
  }
4360
- if (
4361
- planContent &&
4362
- pendingHandoffPlanHash &&
4363
- planHash(planContent) !== pendingHandoffPlanHash
4364
- ) {
4365
- ctx.ui.notify(
4366
- "Ignoring a stale browser approval — the plan content changed.",
4906
+ if (recordedHandoffReviewId !== lastReviewId) {
4907
+ recordedHandoffReviewId = lastReviewId;
4908
+ try {
4909
+ pi.appendEntry("workflow-handoff-plannotator", {
4910
+ at: Date.now(),
4911
+ planPath: pendingHandoffPlanPath ?? currentPlanFile,
4912
+ reviewId: lastReviewId,
4913
+ ...(pendingHandoffReviewUrl
4914
+ ? { reviewUrl: pendingHandoffReviewUrl }
4915
+ : {}),
4916
+ planTextLen: pendingHandoffPlanTextLen,
4917
+ source: pendingHandoffSource ?? "auto",
4918
+ });
4919
+ } catch (_e) {
4920
+ void _e;
4921
+ }
4922
+ notifyReview(
4923
+ "Plan opened in browser for review. Approve or annotate there.",
4367
4924
  "info",
4368
4925
  );
4369
- return;
4370
4926
  }
4371
- // Ensure file exists (fallback write if agent only gave content)
4372
4927
  try {
4373
- if (!fs.existsSync(resolvedPlan) && planContent) {
4374
- fs.mkdirSync(path.dirname(resolvedPlan), { recursive: true });
4375
- fs.writeFileSync(resolvedPlan, planContent, "utf8");
4928
+ persistState();
4929
+ } catch (_e) {
4930
+ void _e;
4931
+ }
4932
+ try {
4933
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
4934
+ } catch (_e) {
4935
+ void _e;
4936
+ }
4937
+ }
4938
+
4939
+ /** Settle the one in-flight plan-review request exactly once. */
4940
+ function settlePlanReviewRequest(outcome: PlanReviewRequestOutcome): boolean {
4941
+ const pending = pendingPlanReviewRequest;
4942
+ if (!pending || pending.settled) return false;
4943
+ pending.settled = true;
4944
+ pendingPlanReviewRequest = undefined;
4945
+ pending.finish(outcome);
4946
+ return true;
4947
+ }
4948
+
4949
+ /**
4950
+ * Ask Plannotator to open a plan review and wait for its lifecycle.
4951
+ *
4952
+ * The `respond` callback is the fast path; `plannotator:session-opened` is
4953
+ * the authoritative one (it carries the URL and survives a slow handler).
4954
+ * A timeout clears the gate so the caller can render its own fallback; it
4955
+ * never retries, because a late event would then open a duplicate review.
4956
+ */
4957
+ async function requestPlanReview(input: {
4958
+ planText: string;
4959
+ planPath: string;
4960
+ source: ReviewHandoffSource;
4961
+ ctx: ExtensionContext;
4962
+ cycleId?: number;
4963
+ }): Promise<{ ok: true; reviewId?: string } | { ok: false; reason: string }> {
4964
+ const bus: any = (pi as any).events;
4965
+ if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
4966
+ return { ok: false, reason: "Plannotator is not available in this session" };
4967
+ }
4968
+ if (!input.planText.trim() || !input.planPath) {
4969
+ return { ok: false, reason: "the reviewed plan has no file to hand off" };
4970
+ }
4971
+ // A second request supersedes the first waiter without opening a race.
4972
+ settlePlanReviewRequest({
4973
+ status: "error",
4974
+ reason: "superseded by a newer handoff",
4975
+ });
4976
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4977
+ awaitingDecision = true;
4978
+ lastHandoffAt = Date.now();
4979
+ pendingHandoffPlanPath = input.planPath;
4980
+ pendingHandoffPlanHash = planHash(input.planText);
4981
+ pendingHandoffPlanTextLen = input.planText.length;
4982
+ pendingHandoffRequestId = requestId;
4983
+ pendingHandoffSource = input.source;
4984
+ pendingHandoffCycleId = input.cycleId ?? reviewCycleId;
4985
+ pendingHandoffReviewUrl = null;
4986
+ lastReviewId = null;
4987
+ recordedHandoffReviewId = null;
4988
+ // Keep the Review mode visible while the browser/inline decision owns the
4989
+ // turn. The reviewer has finished, but prompts and modes stay locked.
4990
+ setReviewTurn("handoff");
4991
+ persistState();
4992
+ const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
4993
+ const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
4994
+ let settled = false;
4995
+ const settle = (outcome: PlanReviewRequestOutcome): void => {
4996
+ if (settled) return;
4997
+ settled = true;
4998
+ clearTimeout(timer);
4999
+ if (pendingPlanReviewRequest?.requestId === requestId) {
5000
+ pendingPlanReviewRequest = undefined;
5001
+ }
5002
+ resolve(outcome);
5003
+ };
5004
+ const timer = setTimeout(() => {
5005
+ settle({
5006
+ status: "timeout",
5007
+ reason: `Plannotator did not answer within ${Math.round(timeoutMs / 1000)}s`,
5008
+ });
5009
+ }, timeoutMs);
5010
+ pendingPlanReviewRequest = {
5011
+ requestId,
5012
+ settled: false,
5013
+ finish: settle,
5014
+ };
5015
+ try {
5016
+ bus.emit(PLANNOTATOR_REQUEST, {
5017
+ requestId,
5018
+ action: "plan-review",
5019
+ payload: {
5020
+ planContent: input.planText,
5021
+ planFilePath: input.planPath,
5022
+ reviewPending: false,
5023
+ reviewSummary: reviewState
5024
+ ? {
5025
+ modelLabel: reviewState.modelLabel,
5026
+ round: reviewState.round,
5027
+ verdict: reviewState.verdict,
5028
+ findings: reviewState.findings.length,
5029
+ }
5030
+ : undefined,
5031
+ },
5032
+ respond: (res: any) => {
5033
+ if (
5034
+ res &&
5035
+ res.status === "handled" &&
5036
+ typeof res.result?.reviewId === "string"
5037
+ ) {
5038
+ settle({
5039
+ status: "handled",
5040
+ reviewId: res.result.reviewId,
5041
+ });
5042
+ } else {
5043
+ settle({
5044
+ status: "error",
5045
+ reason: `Plannotator answered ${String(res?.status ?? "nothing")}`,
5046
+ });
5047
+ }
5048
+ },
5049
+ });
5050
+ } catch (e: any) {
5051
+ settle({
5052
+ status: "error",
5053
+ reason: `Plannotator request failed: ${String(e?.message ?? e)}`,
5054
+ });
4376
5055
  }
5056
+ });
5057
+ if (response.status === "handled") {
5058
+ adoptPlannotatorSession({
5059
+ ...(response.reviewId ? { reviewId: response.reviewId } : {}),
5060
+ ...(response.url ? { url: response.url } : {}),
5061
+ });
5062
+ return { ok: true, ...(response.reviewId ? { reviewId: response.reviewId } : {}) };
5063
+ }
5064
+ const changed = clearPendingHandoffDecision();
5065
+ if (changed) persistState();
5066
+ try {
5067
+ pi.appendEntry("workflow-handoff-plannotator-failed", {
5068
+ at: Date.now(),
5069
+ reason: response.reason,
5070
+ source: input.source,
5071
+ planPath: input.planPath,
5072
+ });
4377
5073
  } catch (_e) {
4378
5074
  void _e;
4379
5075
  }
4380
- clearPendingHandoffDecision();
4381
- void enterBuildModeFromPlan(ctx as any, resolvedPlan)
4382
- .then(() => updateStatus(ctx as any))
4383
- .catch(() => {
5076
+ notifyReview(`Plannotator handoff unavailable — ${response.reason}.`, "warning");
5077
+ return { ok: false, reason: response.reason };
5078
+ }
5079
+
5080
+ /** Ask Plannotator for the durable status of one review. */
5081
+ async function queryPlannotatorReviewStatus(reviewId: string): Promise<{
5082
+ status: "pending" | "completed" | "missing";
5083
+ approved?: boolean;
5084
+ feedback?: string;
5085
+ savedPath?: string;
5086
+ }> {
5087
+ const bus: any = (pi as any).events;
5088
+ if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
5089
+ return { status: "missing" };
5090
+ }
5091
+ const timeoutMs = Math.max(50, deps.plannotatorRecoveryTimeoutMs ?? 3_000);
5092
+ return await new Promise((resolve) => {
5093
+ let settled = false;
5094
+ const finish = (value: any): void => {
5095
+ if (settled) return;
5096
+ settled = true;
5097
+ clearTimeout(timer);
5098
+ resolve(
5099
+ value &&
5100
+ (value.status === "pending" ||
5101
+ value.status === "completed" ||
5102
+ value.status === "missing")
5103
+ ? value
5104
+ : { status: "missing" },
5105
+ );
5106
+ };
5107
+ const timer = setTimeout(() => finish({ status: "missing" }), timeoutMs);
5108
+ try {
5109
+ bus.emit(PLANNOTATOR_REQUEST, {
5110
+ requestId: `${Date.now()}-status`,
5111
+ action: "review-status",
5112
+ payload: { reviewId },
5113
+ respond: (res: any) => {
5114
+ if (res?.status === "handled" && res.result) finish(res.result);
5115
+ else finish({ status: "missing" });
5116
+ },
5117
+ });
5118
+ } catch {
5119
+ finish({ status: "missing" });
5120
+ }
5121
+ });
5122
+ }
5123
+
5124
+ /**
5125
+ * Recover the one pending browser handoff after a session reload/resume.
5126
+ *
5127
+ * `startup`/`new` cannot own the old loopback server, so they clear. For a
5128
+ * reload/resume/fork, Plannotator's durable `review-status` decides: pending
5129
+ * keeps the gate (when the plan hash still matches), completed applies the
5130
+ * recorded decision, and missing/changed clears with a visible reason.
5131
+ */
5132
+ async function recoverPendingHandoff(
5133
+ ctx: ExtensionContext,
5134
+ reason: string,
5135
+ ): Promise<void> {
5136
+ if (recoveryInFlight) return recoveryInFlight;
5137
+ recoveryInFlight = (async () => {
5138
+ const record = (action: string, detail: Record<string, unknown> = {}) => {
4384
5139
  try {
4385
- updateStatus(ctx as any);
5140
+ pi.appendEntry("workflow-handoff-recovery", {
5141
+ action,
5142
+ reason,
5143
+ at: Date.now(),
5144
+ ...detail,
5145
+ });
4386
5146
  } catch (_e) {
4387
5147
  void _e;
4388
5148
  }
5149
+ };
5150
+ if (!awaitingDecision) return;
5151
+ if (reason === "startup" || reason === "new" || !lastReviewId) {
5152
+ const changed = clearPendingHandoffDecision();
5153
+ if (changed) persistState();
5154
+ endReviewTurn();
5155
+ record("clear", { why: "no restorable review" });
5156
+ return;
5157
+ }
5158
+ const currentFile = readCurrentPlanText(reviewCwd());
5159
+ const status = await queryPlannotatorReviewStatus(lastReviewId);
5160
+ const decision = reviewHandoffRecovery({
5161
+ status: status.status,
5162
+ approved: status.approved,
5163
+ feedback: status.feedback,
5164
+ savedPath: status.savedPath,
5165
+ pendingHash: pendingHandoffPlanHash ?? undefined,
5166
+ currentHash:
5167
+ currentFile !== undefined ? planHash(currentFile) : undefined,
4389
5168
  });
5169
+ if (decision.action === "keep") {
5170
+ if (pendingHandoffSource === "manual") manualReviewCycle = true;
5171
+ setReviewTurn("handoff");
5172
+ record("keep", {
5173
+ reviewId: lastReviewId,
5174
+ ...(pendingHandoffReviewUrl
5175
+ ? { reviewUrl: pendingHandoffReviewUrl }
5176
+ : {}),
5177
+ });
5178
+ notifyReview(
5179
+ `Browser review still open${pendingHandoffReviewUrl ? `: ${pendingHandoffReviewUrl}` : ""} — approve or annotate there.`,
5180
+ "info",
5181
+ );
5182
+ updateStatus(ctx as any);
5183
+ return;
5184
+ }
5185
+ if (decision.action === "apply") {
5186
+ record("apply", {
5187
+ reviewId: lastReviewId,
5188
+ approved: decision.approved,
5189
+ });
5190
+ if (decision.approved) {
5191
+ await handlePlannotatorApprove(
5192
+ pendingHandoffPlanPath ?? currentPlanFile ?? "",
5193
+ currentFile ?? "",
5194
+ decision.feedback,
5195
+ ctx.cwd,
5196
+ );
5197
+ } else {
5198
+ await handlePlannotatorDeny(
5199
+ decision.feedback || "Plan needs revision.",
5200
+ );
5201
+ }
5202
+ return;
5203
+ }
5204
+ const changed = clearPendingHandoffDecision();
5205
+ if (changed) persistState();
5206
+ endReviewTurn();
5207
+ record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
5208
+ notifyReview(
5209
+ `Previous browser review could not be recovered — ${decision.reason}.`,
5210
+ "warning",
5211
+ );
5212
+ updateStatus(ctx as any);
5213
+ })();
5214
+ try {
5215
+ await recoveryInFlight;
5216
+ } finally {
5217
+ recoveryInFlight = null;
5218
+ }
5219
+ }
5220
+
5221
+ /**
5222
+ * Open the browser handoff for a settled manual `/review` or resume.
5223
+ * Returns true when the browser owns the decision (or the result is not
5224
+ * handoff-eligible); false when the round paused/aborted and the caller
5225
+ * should show its normal resume notice.
5226
+ */
5227
+ async function openManualHandoff(
5228
+ result: ReviewExecutionResult,
5229
+ planPath: string,
5230
+ ctx: ExtensionContext,
5231
+ suffix = "",
5232
+ ): Promise<boolean> {
5233
+ if (!shouldHandoffManualResult(result)) return false;
5234
+ const previousManualCycle = manualReviewCycle;
5235
+ manualReviewCycle = true;
5236
+ const handoff = await requestPlanReview({
5237
+ planText: result.planText,
5238
+ planPath,
5239
+ source: "manual",
5240
+ ctx,
5241
+ });
5242
+ if (handoff.ok) {
5243
+ ctx.ui.notify(
5244
+ "Plan opened in browser for review. Approve or annotate there." + suffix,
5245
+ "info",
5246
+ );
5247
+ return true;
5248
+ }
5249
+ manualReviewCycle = previousManualCycle;
5250
+ ctx.ui.notify(
5251
+ `Manual review finished, but Plannotator is unavailable (${handoff.reason}). The reviewed plan is at ${planPath}.`,
5252
+ "warning",
5253
+ );
5254
+ return true;
5255
+ }
5256
+
5257
+ /** One execute handoff: todo list + the Build-starting message. */
5258
+ function sendExecuteMessages(
5259
+ ctx: ExtensionContext,
5260
+ planPath: string,
5261
+ feedback?: string,
5262
+ ): void {
4390
5263
  try {
4391
5264
  const remainingList = formatTodoNumberedList(todoItems);
4392
5265
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -4415,26 +5288,157 @@ export default function workflowExtension(
4415
5288
  at: Date.now(),
4416
5289
  choice: "execute",
4417
5290
  source: "plannotator",
4418
- planPath: resolvedPlan,
5291
+ planPath,
4419
5292
  });
4420
5293
  } catch (_e) {
4421
5294
  void _e;
4422
5295
  }
4423
5296
  }
4424
5297
 
5298
+ async function handlePlannotatorApprove(
5299
+ planFilePath: string,
5300
+ planContent: string,
5301
+ feedback: string | undefined,
5302
+ cwd: string | undefined,
5303
+ ): Promise<void> {
5304
+ const ctx = getActiveCtx();
5305
+ if (!ctx) return;
5306
+ // A manual `/review` handoff may be approved from Default/Build; an
5307
+ // automatic handoff only exists in Plan mode.
5308
+ if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
5309
+ // A stale browser approve while a reviewer round/pause runs must not clear
5310
+ // the pending decision or send execute. The handoff itself is deliberately
5311
+ // NOT part of this predicate: it is the decision being consumed here.
5312
+ if (reviewRoundInFlight()) {
5313
+ supersedePendingHandoff(
5314
+ "a browser decision arrived while a new review round was running",
5315
+ { force: true },
5316
+ );
5317
+ return;
5318
+ }
5319
+ // No prompt means no decision to consume. A fresh prompt/mode switch
5320
+ // clears this, so an old browser tab cannot approve the previous plan.
5321
+ if (!awaitingDecision) return;
5322
+ // Resolve plan path (plannotator gives cwd + relative path)
5323
+ let resolvedPlan = planFilePath;
5324
+ try {
5325
+ if (cwd && !path.isAbsolute(planFilePath)) {
5326
+ resolvedPlan = path.join(cwd, planFilePath);
5327
+ } else if (!path.isAbsolute(planFilePath)) {
5328
+ resolvedPlan = path.join(
5329
+ (ctx as any).cwd ?? cwd ?? process.cwd(),
5330
+ planFilePath,
5331
+ );
5332
+ }
5333
+ } catch (_e) {
5334
+ void _e;
5335
+ }
5336
+ // Correlate the event with the plan the current handoff prompt showed.
5337
+ // A stale browser tab must not approve an older plan after a new task.
5338
+ try {
5339
+ if (
5340
+ !pendingHandoffPlanPath ||
5341
+ path.resolve(resolvedPlan) !== path.resolve(pendingHandoffPlanPath)
5342
+ ) {
5343
+ ctx.ui.notify(
5344
+ "Ignoring a stale browser approval from a previous plan.",
5345
+ "info",
5346
+ );
5347
+ return;
5348
+ }
5349
+ } catch (_e) {
5350
+ void _e;
5351
+ if (!pendingHandoffPlanPath || resolvedPlan !== pendingHandoffPlanPath)
5352
+ return;
5353
+ }
5354
+ if (
5355
+ planContent &&
5356
+ pendingHandoffPlanHash &&
5357
+ planHash(planContent) !== pendingHandoffPlanHash
5358
+ ) {
5359
+ ctx.ui.notify(
5360
+ "Ignoring a stale browser approval — the plan content changed.",
5361
+ "info",
5362
+ );
5363
+ return;
5364
+ }
5365
+ // Ensure file exists (fallback write if agent only gave content)
5366
+ try {
5367
+ if (!fs.existsSync(resolvedPlan) && planContent) {
5368
+ fs.mkdirSync(path.dirname(resolvedPlan), { recursive: true });
5369
+ fs.writeFileSync(resolvedPlan, planContent, "utf8");
5370
+ }
5371
+ } catch (_e) {
5372
+ void _e;
5373
+ }
5374
+ // Atomic decision exit: stop the reviewer and apply Build BEFORE any
5375
+ // follow-up message. `transition` keeps input/mode gates closed while the
5376
+ // async mode switch runs, so a queued prompt cannot race it.
5377
+ setReviewTurn("transition");
5378
+ if (pendingHandoffSource === "manual") manualReviewCycle = false;
5379
+ clearPendingHandoffDecision();
5380
+ let enteredBuild = false;
5381
+ try {
5382
+ enteredBuild = await enterBuildModeFromPlan(
5383
+ ctx as any,
5384
+ resolvedPlan,
5385
+ { fromReview: true },
5386
+ );
5387
+ } catch (e: any) {
5388
+ ctx.ui.notify(
5389
+ `Could not enter Build mode (${String(e?.message ?? e)}); the plan was not executed.`,
5390
+ "warning",
5391
+ );
5392
+ }
5393
+ if (!enteredBuild) {
5394
+ setReviewTurn("handoff");
5395
+ updateStatus(ctx as any);
5396
+ return;
5397
+ }
5398
+ setReviewTurn("idle");
5399
+ updateStatus(ctx as any);
5400
+ sendExecuteMessages(ctx as any, resolvedPlan, feedback);
5401
+ }
5402
+
4425
5403
  async function handlePlannotatorDeny(feedback: string) {
4426
5404
  const ctx = getActiveCtx();
4427
5405
  if (!ctx) return;
4428
- // Not a mode transition, but while a review owns the turn every browser
4429
- // decision is stale: injecting feedback here would mutate the plan under
4430
- // the reviewer. A legitimate deny only arrives after the gate resolves.
4431
- if (blockModeSwitchDuringReview(ctx as any)) return;
5406
+ // A stale deny while a new reviewer round/pause runs must not inject
5407
+ // feedback under the reviewer. The pending handoff (if any) is the decision
5408
+ // being consumed, so only the round predicate can refuse it.
5409
+ if (reviewRoundInFlight()) {
5410
+ supersedePendingHandoff(
5411
+ "a browser decision arrived while a new review round was running",
5412
+ { force: true },
5413
+ );
5414
+ return;
5415
+ }
4432
5416
  if (!awaitingDecision) return;
5417
+ const manualSource = pendingHandoffSource === "manual";
5418
+ setReviewTurn("transition");
5419
+ if (manualSource && workflowMode !== "plan") {
5420
+ // A manual review can be launched from Default/Build. Its feedback is a
5421
+ // plan revision, so switch to Plan before the follow-up turn starts.
5422
+ try {
5423
+ await setPlanMode(ctx as any, { quiet: true, fromReview: true });
5424
+ } catch (_e) {
5425
+ void _e;
5426
+ }
5427
+ notifyReview(
5428
+ "Review feedback — switched to Plan mode for the revision.",
5429
+ "info",
5430
+ );
5431
+ }
4433
5432
  clearPendingHandoffDecision();
4434
5433
  // Feedback cycle: the next plan revision gets a fresh review round, and the
4435
5434
  // outstanding findings are carried forward so the reviewer can confirm they
4436
5435
  // were actually addressed rather than re-raising them.
4437
5436
  await beginReviewCycle({ reason: "deny", keepFindings: true });
5437
+ setReviewTurn("idle");
5438
+ // The feedback turn is not a fresh user prompt: keeping the refine reason
5439
+ // preserves manualReviewCycle, so a manual review with automatic Review
5440
+ // Mode off still re-reviews the revision.
5441
+ reviewFeedbackTurn = true;
4438
5442
  persistState();
4439
5443
  try {
4440
5444
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -4468,6 +5472,91 @@ export default function workflowExtension(
4468
5472
  }
4469
5473
  }
4470
5474
 
5475
+ /**
5476
+ * The browser review closed without a decision. The user must still get a
5477
+ * handoff: present a compact inline Approve/Revise choice with the author's
5478
+ * plan. Reopening the browser here could loop on every close, so this path is
5479
+ * deliberately inline-only. Never leaves Review stuck: cancel/idle clears it.
5480
+ */
5481
+ async function presentClosedHandoff(
5482
+ ctx: ExtensionContext,
5483
+ planPath: string,
5484
+ planText: string,
5485
+ ): Promise<void> {
5486
+ clearPendingHandoffDecision();
5487
+ setReviewTurn("handoff");
5488
+ if (!planText || !planPath) {
5489
+ endReviewTurn();
5490
+ ctx.ui.notify(
5491
+ `Plan review closed — the plan file is ${planPath || "unavailable"}.`,
5492
+ "info",
5493
+ );
5494
+ return;
5495
+ }
5496
+ if ((ctx as any).hasUI !== true) {
5497
+ try {
5498
+ (pi as any).sendMessage?.(
5499
+ {
5500
+ customType: "workflow-handoff-fallback",
5501
+ content: `**Plan ready**\n\nPlan review closed without a decision. The plan is at ${planPath}.\n\nType "Execute the plan" to build, or tell me what to refine.`,
5502
+ display: true,
5503
+ },
5504
+ { triggerTurn: false },
5505
+ );
5506
+ } catch (_e) {
5507
+ void _e;
5508
+ }
5509
+ endReviewTurn();
5510
+ return;
5511
+ }
5512
+ const execute = "Execute the plan in Build Mode";
5513
+ const refine = "Refine the plan in Plan Mode";
5514
+ let choice: string | undefined;
5515
+ try {
5516
+ choice = await (ctx as any).ui.select(
5517
+ "Plan review closed — choose next step",
5518
+ [execute, refine],
5519
+ );
5520
+ } catch {
5521
+ choice = undefined;
5522
+ }
5523
+ if (choice === execute) {
5524
+ setReviewTurn("transition");
5525
+ const ok = await enterBuildModeFromPlan(ctx as any, planPath, {
5526
+ fromReview: true,
5527
+ });
5528
+ if (!ok) {
5529
+ setReviewTurn("handoff");
5530
+ return;
5531
+ }
5532
+ setReviewTurn("idle");
5533
+ sendExecuteMessages(ctx, planPath);
5534
+ return;
5535
+ }
5536
+ if (choice === refine) {
5537
+ setReviewTurn("transition");
5538
+ if (workflowMode !== "plan") {
5539
+ try {
5540
+ await setPlanMode(ctx as any, { quiet: true, fromReview: true });
5541
+ } catch (_e) {
5542
+ void _e;
5543
+ }
5544
+ }
5545
+ await beginReviewCycle({ reason: "refine", keepFindings: true });
5546
+ setReviewTurn("idle");
5547
+ ctx.ui.notify(
5548
+ "Plan review closed — staying in Plan mode. Tell me what to change.",
5549
+ "info",
5550
+ );
5551
+ return;
5552
+ }
5553
+ endReviewTurn();
5554
+ ctx.ui.notify(
5555
+ `Plan review closed — the plan is at ${planPath}.`,
5556
+ "info",
5557
+ );
5558
+ }
5559
+
4471
5560
  function ensurePlannotatorListeners() {
4472
5561
  if (plannotatorListenersRegistered) return;
4473
5562
  try {
@@ -4475,16 +5564,17 @@ export default function workflowExtension(
4475
5564
  if (!bus || typeof bus.on !== "function") return;
4476
5565
  // plan-approved (external executionMode) — direct handoff
4477
5566
  bus.on(PLANNOTATOR_PLAN_APPROVED, (event: any) => {
4478
- try {
4479
- handlePlannotatorApprove(
4480
- event.planFilePath,
4481
- event.planContent,
4482
- event.feedback,
4483
- event.cwd,
5567
+ void handlePlannotatorApprove(
5568
+ event?.planFilePath,
5569
+ event?.planContent,
5570
+ event?.feedback,
5571
+ event?.cwd,
5572
+ ).catch((e: unknown) => {
5573
+ notifyReview(
5574
+ `Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
5575
+ "error",
4484
5576
  );
4485
- } catch (_e) {
4486
- void _e;
4487
- }
5577
+ });
4488
5578
  });
4489
5579
  // review-result (plan-review shared API) — approve/deny from browser
4490
5580
  bus.on(PLANNOTATOR_REVIEW_RESULT, (event: any) => {
@@ -4504,12 +5594,17 @@ export default function workflowExtension(
4504
5594
  } catch (_e) {
4505
5595
  void _e;
4506
5596
  }
4507
- handlePlannotatorApprove(
5597
+ void handlePlannotatorApprove(
4508
5598
  planPath,
4509
5599
  content,
4510
5600
  event.feedback,
4511
5601
  (getActiveCtx() as any)?.cwd,
4512
- );
5602
+ ).catch((e: unknown) => {
5603
+ notifyReview(
5604
+ `Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
5605
+ "error",
5606
+ );
5607
+ });
4513
5608
  } else {
4514
5609
  void handlePlannotatorDeny(
4515
5610
  event.feedback || "Plan needs revision.",
@@ -4519,6 +5614,74 @@ export default function workflowExtension(
4519
5614
  void _e;
4520
5615
  }
4521
5616
  });
5617
+ // session-opened is authoritative: it carries the review id and URL even
5618
+ // when the request's respond callback is slow or lost. It can arrive
5619
+ // before or after respond; both paths settle the same waiter once.
5620
+ bus.on(PLANNOTATOR_SESSION_OPENED, (event: any) => {
5621
+ try {
5622
+ if (!event || typeof event !== "object") return;
5623
+ const matchesWaiting =
5624
+ typeof event.requestId === "string" &&
5625
+ event.requestId === pendingPlanReviewRequest?.requestId;
5626
+ const matchesCurrent =
5627
+ typeof event.requestId === "string" &&
5628
+ event.requestId === pendingHandoffRequestId;
5629
+ if (!matchesWaiting && !matchesCurrent) return;
5630
+ adoptPlannotatorSession({
5631
+ reviewId: event.reviewId,
5632
+ url: event.url,
5633
+ });
5634
+ if (matchesWaiting) {
5635
+ settlePlanReviewRequest({
5636
+ status: "handled",
5637
+ reviewId:
5638
+ typeof event.reviewId === "string" ? event.reviewId : undefined,
5639
+ url: typeof event.url === "string" ? event.url : undefined,
5640
+ });
5641
+ }
5642
+ } catch (_e) {
5643
+ void _e;
5644
+ }
5645
+ });
5646
+ // A review closed without a decision must not leave a phantom gate that
5647
+ // suppresses the next round. approved/submitted decisions still flow
5648
+ // through review-result (and, after a reload, review-status).
5649
+ bus.on(PLANNOTATOR_SESSION_CLOSED, (event: any) => {
5650
+ try {
5651
+ if (!event || typeof event !== "object") return;
5652
+ if (event.outcome !== "closed") return;
5653
+ if (!lastReviewId || event.reviewId !== lastReviewId) return;
5654
+ if (!awaitingDecision) return;
5655
+ const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
5656
+ let planText = "";
5657
+ try {
5658
+ if (planPath && fs.existsSync(planPath)) {
5659
+ planText = fs.readFileSync(planPath, "utf8");
5660
+ }
5661
+ } catch (_e) {
5662
+ void _e;
5663
+ }
5664
+ const ctx = getActiveCtx();
5665
+ if (!ctx) {
5666
+ supersedePendingHandoff(
5667
+ "the browser review closed without a decision",
5668
+ { force: true },
5669
+ );
5670
+ return;
5671
+ }
5672
+ void presentClosedHandoff(ctx as any, planPath, planText).catch(
5673
+ (e: unknown) => {
5674
+ notifyReview(
5675
+ `Plan handoff after review close failed: ${e instanceof Error ? e.message : String(e)}`,
5676
+ "warning",
5677
+ );
5678
+ endReviewTurn();
5679
+ },
5680
+ );
5681
+ } catch (_e) {
5682
+ void _e;
5683
+ }
5684
+ });
4522
5685
  plannotatorListenersRegistered = true;
4523
5686
  plannotatorActive = true;
4524
5687
  const ctx = getActiveCtx();
@@ -4708,6 +5871,34 @@ export default function workflowExtension(
4708
5871
  void _e;
4709
5872
  }
4710
5873
 
5874
+ // Todo panel toggle: expanded ↔ collapsed, and hidden → expanded.
5875
+ // Ctrl+Alt+T is the Linux default for opening a terminal, so this uses O.
5876
+ try {
5877
+ pi.registerShortcut(Key.ctrlAlt("o"), {
5878
+ description: "Toggle the workflow todo panel",
5879
+ handler: async (ctx: any) => {
5880
+ if (overlayDepth > 0) return;
5881
+ if (todoItems.length === 0) {
5882
+ ctx.ui.notify("No todos.", "info");
5883
+ return;
5884
+ }
5885
+ const view = deriveTodoPanel({
5886
+ items: todoItems,
5887
+ override: todoPanelOverride,
5888
+ overrideKey: todoPanelOverrideKey,
5889
+ promptAfterSettle: todoPanelPromptAfterSettle,
5890
+ stale: todoStale,
5891
+ });
5892
+ setTodoPanelOverride(
5893
+ ctx as any,
5894
+ view.state === "expanded" ? "collapsed" : "expanded",
5895
+ );
5896
+ },
5897
+ });
5898
+ } catch (_e) {
5899
+ void _e;
5900
+ }
5901
+
4711
5902
  // Tab cycles workflow modes (context-aware: not when an overlay is active).
4712
5903
  // WorkflowTabEditor gives Tab back to autocomplete while a suggestion list is
4713
5904
  // visible; this registration remains the no-list path. Single registration to
@@ -4971,7 +6162,10 @@ export default function workflowExtension(
4971
6162
  };
4972
6163
  todoItems.push(item);
4973
6164
  try {
4974
- if (currentSessionCtx) updateStatus(currentSessionCtx);
6165
+ if (currentSessionCtx) {
6166
+ updateStatus(currentSessionCtx);
6167
+ persistState();
6168
+ }
4975
6169
  } catch (_e) {
4976
6170
  void _e;
4977
6171
  }
@@ -5092,6 +6286,10 @@ export default function workflowExtension(
5092
6286
  const c = todoItems.length;
5093
6287
  todoItems = [];
5094
6288
  todoPlanPath = undefined;
6289
+ todoPanelOverride = null;
6290
+ todoPanelOverrideKey = null;
6291
+ todoPanelPromptAfterSettle = false;
6292
+ lastCompletedSignature = null;
5095
6293
  try {
5096
6294
  if (currentSessionCtx) {
5097
6295
  updateStatus(currentSessionCtx);
@@ -5123,19 +6321,119 @@ export default function workflowExtension(
5123
6321
  });
5124
6322
 
5125
6323
  pi.registerCommand("todos", {
5126
- description: "Show workflow todos",
5127
- handler: async (_args, ctx) => {
5128
- const list = todoItems.length
5129
- ? `Steps are numbered GLOBALLY 1..${todoItems.length}:
6324
+ description:
6325
+ "Show or control the todo panel: /todos [show|expand|collapse|hide|auto|clear]",
6326
+ handler: async (args, ctx) => {
6327
+ const tokens = String(args || "")
6328
+ .trim()
6329
+ .split(/\s+/)
6330
+ .filter(Boolean);
6331
+ const sub = (tokens[0] || "").toLowerCase();
6332
+ const usage = [
6333
+ "Usage: /todos [show|expand|collapse|hide|auto|clear]",
6334
+ " (no arg) list every step with global refs",
6335
+ " expand show the full pinned panel",
6336
+ " collapse keep one summary line",
6337
+ " hide remove the pinned panel (footer keeps progress)",
6338
+ " auto follow the automatic collapse/hide sequence",
6339
+ " clear delete the todo list (asks first when UI is available)",
6340
+ ].join("\n");
6341
+
6342
+ if (!sub) {
6343
+ const list = todoItems.length
6344
+ ? `Steps are numbered GLOBALLY 1..${todoItems.length}:
5130
6345
  ` +
5131
- todoItems
5132
- .map((t) => {
5133
- const g = t.group ? `[${t.group}] ` : "";
5134
- return `${g}${formatTodoLine(t)}`;
5135
- })
5136
- .join("\n")
5137
- : "No todos.";
5138
- ctx.ui.notify(`Todos (${todoItems.length}):\n${list}`, "info");
6346
+ todoItems
6347
+ .map((t) => {
6348
+ const g = t.group ? `[${t.group}] ` : "";
6349
+ return `${g}${formatTodoLine(t)}`;
6350
+ })
6351
+ .join("\n")
6352
+ : "No todos.";
6353
+ ctx.ui.notify(`Todos (${todoItems.length}):\n${list}`, "info");
6354
+ return;
6355
+ }
6356
+
6357
+ if (sub === "show" || sub === "expand") {
6358
+ setTodoPanelOverride(ctx, "expanded");
6359
+ return;
6360
+ }
6361
+ if (sub === "collapse") {
6362
+ setTodoPanelOverride(ctx, "collapsed");
6363
+ return;
6364
+ }
6365
+ if (sub === "hide") {
6366
+ setTodoPanelOverride(ctx, "hidden");
6367
+ return;
6368
+ }
6369
+ if (sub === "auto") {
6370
+ setTodoPanelOverride(ctx, null);
6371
+ return;
6372
+ }
6373
+ if (sub === "clear") {
6374
+ const count = todoItems.length;
6375
+ if (count === 0) {
6376
+ ctx.ui.notify("No todos.", "info");
6377
+ return;
6378
+ }
6379
+ // Headless confirm() resolves false and would silently turn this into
6380
+ // a no-op; the command is explicit, so only ask when there is UI.
6381
+ const confirmed =
6382
+ (ctx as any).hasUI === true
6383
+ ? await ctx.ui.confirm(
6384
+ "Clear todos?",
6385
+ "This resets the list. In Build mode, edits are blocked until a list exists again.",
6386
+ )
6387
+ : true;
6388
+ if (!confirmed) {
6389
+ ctx.ui.notify("Clear cancelled.", "info");
6390
+ return;
6391
+ }
6392
+ todoItems = [];
6393
+ todoPlanPath = undefined;
6394
+ todoPanelOverride = null;
6395
+ todoPanelOverrideKey = null;
6396
+ todoPanelPromptAfterSettle = false;
6397
+ lastCompletedSignature = null;
6398
+ updateStatus(ctx);
6399
+ try {
6400
+ persistState();
6401
+ } catch (_e) {
6402
+ void _e;
6403
+ }
6404
+ onTodoWrite();
6405
+ ctx.ui.notify(
6406
+ `Cleared ${count} todos. In Build mode, edits are blocked until a list exists again (approve a plan or add a step).`,
6407
+ "info",
6408
+ );
6409
+ return;
6410
+ }
6411
+ if (sub === "help") {
6412
+ ctx.ui.notify(usage, "info");
6413
+ return;
6414
+ }
6415
+ ctx.ui.notify(`Unknown /todos action: ${sub}\n${usage}`, "info");
6416
+ },
6417
+ getArgumentCompletions: (prefix: string) => {
6418
+ const raw = String(prefix ?? "");
6419
+ if (/\s/.test(raw.trim())) return null;
6420
+ const lower = raw.trim().toLowerCase();
6421
+ const verbs = [
6422
+ { name: "show", description: "Show the full pinned panel" },
6423
+ { name: "expand", description: "Show the full pinned panel" },
6424
+ { name: "collapse", description: "Keep one summary line" },
6425
+ { name: "hide", description: "Remove the pinned panel" },
6426
+ { name: "auto", description: "Automatic collapse/hide sequence" },
6427
+ { name: "clear", description: "Delete the todo list" },
6428
+ { name: "help", description: "Show usage" },
6429
+ ];
6430
+ const matching = verbs.filter((v) => v.name.startsWith(lower));
6431
+ if (matching.length === 0) return null;
6432
+ return matching.map((v) => ({
6433
+ value: `${v.name} `,
6434
+ label: v.name,
6435
+ description: v.description,
6436
+ }));
5139
6437
  },
5140
6438
  });
5141
6439
 
@@ -5416,7 +6714,8 @@ export default function workflowExtension(
5416
6714
  "Usage: /profile (picker) | /profile list | /profile show <name> | " +
5417
6715
  "/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
5418
6716
  "/profile rename <old> <new> | /profile delete <name> | " +
5419
- "/profile default [name|none] [global|project]";
6717
+ "/profile default [name|none] [global|project] | " +
6718
+ "/profile retarget-provider <old> <new> [global|project] [--apply]";
5420
6719
 
5421
6720
  /** Effective workflow.defaultProfile for display (never throws). */
5422
6721
  function currentDefaultProfileName(cwd?: string): string | undefined {
@@ -6827,6 +8126,79 @@ export default function workflowExtension(
6827
8126
  return;
6828
8127
  }
6829
8128
 
8129
+ if (sub === "retarget-provider" || sub === "retarget") {
8130
+ const [oldProvider, newProvider, ...flags] = rest;
8131
+ if (!oldProvider || !newProvider) {
8132
+ ctx.ui.notify(
8133
+ "Usage: /profile retarget-provider <old> <new> [global|project] [--apply]\n" +
8134
+ "Rewrite every role/profile model ref from one provider to another. " +
8135
+ "Without --apply it only prints the planned changes.",
8136
+ "info",
8137
+ );
8138
+ return;
8139
+ }
8140
+ if (oldProvider === newProvider) {
8141
+ ctx.ui.notify(
8142
+ `Provider is already "${oldProvider}" — nothing to retarget.`,
8143
+ "info",
8144
+ );
8145
+ return;
8146
+ }
8147
+ const apply = flags.some((flag) => {
8148
+ const value = flag.toLowerCase();
8149
+ return value === "--apply" || value === "apply";
8150
+ });
8151
+ const scope = flags.some((flag) => flag.toLowerCase() === "project")
8152
+ ? "project"
8153
+ : "global";
8154
+ const targetCwd = cwd ?? (ctx as any).cwd ?? process.cwd();
8155
+ const result = retargetProviderFiles({
8156
+ profilesPath:
8157
+ scope === "project"
8158
+ ? projectProfilesPath(targetCwd)
8159
+ : userProfilesPath(),
8160
+ rolesPath:
8161
+ scope === "project"
8162
+ ? projectRolesPath(targetCwd)
8163
+ : userRolesPath(),
8164
+ oldProvider,
8165
+ newProvider,
8166
+ dryRun: !apply,
8167
+ });
8168
+ const summary = formatRetargetResult(result);
8169
+ if (
8170
+ result.profiles.status === "invalid" ||
8171
+ result.roles.status === "invalid"
8172
+ ) {
8173
+ ctx.ui.notify(summary, "error");
8174
+ return;
8175
+ }
8176
+ if (!apply) {
8177
+ ctx.ui.notify(
8178
+ `${summary}\n\nScope: ${scope}. A malformed file blocks both writes.`,
8179
+ "info",
8180
+ );
8181
+ return;
8182
+ }
8183
+ // The files changed underneath the cached configs. Force a reload so
8184
+ // the active profile/role resolves the new provider immediately.
8185
+ roleConfigLoadedFor = undefined;
8186
+ profilesLoadedFor = undefined;
8187
+ invalidateEffectiveRoles();
8188
+ ensureRoleConfig(targetCwd);
8189
+ ensureProfilesConfig(targetCwd);
8190
+ updateStatus(ctx as any);
8191
+ if (workflowMode) {
8192
+ try {
8193
+ await applyModeModel(ctx as any);
8194
+ } catch (_e) {
8195
+ void _e;
8196
+ }
8197
+ }
8198
+ ctx.ui.notify(`${summary}\n\nScope: ${scope}.`, "info");
8199
+ return;
8200
+ }
8201
+
6830
8202
  if (sub === "default") {
6831
8203
  // /profile default [name|none] [global|project]
6832
8204
  const target = rest.find(
@@ -8143,6 +9515,20 @@ export default function workflowExtension(
8143
9515
  // command reaches the reviewer now; the write-gate steer guard below stays
8144
9516
  // (a write instruction must never be steered into the reviewer).
8145
9517
  pi.on("input", async (event, _ctx) => {
9518
+ // Review owns the turn: only the explicit controls and model/profile
9519
+ // changes may become a prompt. This is the terminal-Pi half of the lock;
9520
+ // the Desktop store refuses prose before it reaches this hook.
9521
+ if (reviewTurnOwns(reviewTurn)) {
9522
+ const decision = reviewInputDecision(
9523
+ typeof event.text === "string" ? event.text : "",
9524
+ reviewTurn,
9525
+ );
9526
+ if (decision.action === "block") {
9527
+ notifyReview(decision.notice, "info");
9528
+ return { action: "handled" } as any;
9529
+ }
9530
+ return { action: "continue" } as any;
9531
+ }
8146
9532
  if (typeof event.text === "string" && event.text.startsWith("/btw ")) {
8147
9533
  const note = event.text.slice(5).trim();
8148
9534
  if (note) {
@@ -8238,12 +9624,27 @@ export default function workflowExtension(
8238
9624
  // A session boundary owns the session-scoped plan fallback; the restore
8239
9625
  // loop below re-seeds it from the persisted `planFile`.
8240
9626
  resetSessionPlanProvenance();
9627
+ // The branch is the source of truth for the panel's presentation. The
9628
+ // host has cleared every extension widget on a session switch/reload, so
9629
+ // the reconciler must register it again from the restored state.
9630
+ todoPanelOverride = null;
9631
+ todoPanelOverrideKey = null;
9632
+ todoPanelPromptAfterSettle = false;
9633
+ lastCompletedSignature = null;
9634
+ todoPanelAppliedState = "unregistered";
8241
9635
  // A reloaded/forked session must never inherit a stale write denial.
8242
9636
  writeGateRunDenials.clear();
8243
9637
  writeGateTurnAnswer = null;
8244
9638
  writeGateTurnSteered = false;
8245
9639
  writeGateBatchCalls = [];
8246
9640
  writeGateBatchCallIds = new Set();
9641
+ readGateRunDenials.clear();
9642
+ readGateTurnAnswer = null;
9643
+ readGateTurnSteered = false;
9644
+ readGateBatchCalls = [];
9645
+ readGateBatchCallIds = new Set();
9646
+ readGateTurnGrantedRoots.clear();
9647
+ readGateTurnUncertainAllowed.clear();
8247
9648
  // Roles are file-backed; load them up-front so every read path (status,
8248
9649
  // band label, reviewer runtime) sees the real config even in a fresh
8249
9650
  // session that has no persisted workflow entry yet. Profiles likewise.
@@ -8283,6 +9684,15 @@ export default function workflowExtension(
8283
9684
  }
8284
9685
  syncLegacyFlags();
8285
9686
  if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
9687
+ // Entries written before this feature have no `todoPanel`; they
9688
+ // must not erase a value restored from an earlier entry.
9689
+ if (d.todoPanel && typeof d.todoPanel === "object") {
9690
+ const panel = readPersistedTodoPanel(d);
9691
+ todoPanelOverride = panel.override;
9692
+ todoPanelOverrideKey = panel.overrideKey;
9693
+ todoPanelPromptAfterSettle = panel.promptAfterSettle;
9694
+ lastCompletedSignature = panel.completedSignature;
9695
+ }
8286
9696
  todoPlanPath = d.planFile;
8287
9697
  toolsBeforePlanMode = d.toolsBeforePlanMode;
8288
9698
  // An entry without a planFile must not erase a value restored
@@ -8351,31 +9761,39 @@ export default function workflowExtension(
8351
9761
  typeof d.awaitingDecisionAt === "number"
8352
9762
  ? d.awaitingDecisionAt
8353
9763
  : 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
- }
9764
+ // Restore the whole correlation, not just the boolean: recovery
9765
+ // and stale-browser correlation both need the review identity.
9766
+ lastReviewId =
9767
+ typeof (d as any).lastReviewId === "string"
9768
+ ? (d as any).lastReviewId
9769
+ : null;
9770
+ pendingHandoffPlanPath =
9771
+ typeof (d as any).pendingHandoffPlanPath === "string"
9772
+ ? (d as any).pendingHandoffPlanPath
9773
+ : null;
9774
+ pendingHandoffPlanHash =
9775
+ typeof (d as any).pendingHandoffPlanHash === "string"
9776
+ ? (d as any).pendingHandoffPlanHash
9777
+ : null;
9778
+ pendingHandoffRequestId =
9779
+ typeof (d as any).pendingHandoffRequestId === "string"
9780
+ ? (d as any).pendingHandoffRequestId
9781
+ : null;
9782
+ pendingHandoffSource =
9783
+ (d as any).pendingHandoffSource === "manual"
9784
+ ? "manual"
9785
+ : (d as any).pendingHandoffSource === "auto"
9786
+ ? "auto"
9787
+ : null;
9788
+ pendingHandoffCycleId =
9789
+ typeof (d as any).pendingHandoffCycleId === "number"
9790
+ ? (d as any).pendingHandoffCycleId
9791
+ : 0;
9792
+ pendingHandoffReviewUrl =
9793
+ typeof (d as any).pendingHandoffReviewUrl === "string"
9794
+ ? (d as any).pendingHandoffReviewUrl
9795
+ : null;
9796
+ manualReviewCycle = (d as any).manualReviewCycle === true;
8379
9797
  }
8380
9798
  }
8381
9799
  if (
@@ -8601,9 +10019,13 @@ export default function workflowExtension(
8601
10019
  resetPlanRunProvenance();
8602
10020
  lastPlanWritePath = currentPlanFile ?? null;
8603
10021
  lastPersistedPlanFile = currentPlanFile;
8604
- pendingHandoffPlanPath = null;
8605
- pendingHandoffPlanHash = null;
8606
- lastReviewId = null;
10022
+ // The pending correlation is restored above. Recovery (step 15) decides
10023
+ // whether it survives; do not null it here, or a restored gate can only
10024
+ // suppress the next handoff.
10025
+ if (!awaitingDecision) clearPendingHandoffDecision();
10026
+ // A reload cannot preserve a running/paused reviewer child. Only a
10027
+ // persisted pending decision survives, and it owns the turn as `handoff`.
10028
+ reviewTurn = awaitingDecision ? "handoff" : "idle";
8607
10029
  currentSessionCtx = ctx as any;
8608
10030
  // Give Tab back to autocomplete while a suggestion list is visible; the raw
8609
10031
  // `tab` shortcut keeps cycling modes otherwise.
@@ -8636,16 +10058,27 @@ export default function workflowExtension(
8636
10058
  } catch (_e) {
8637
10059
  void _e;
8638
10060
  }
10061
+ // Recovery is asynchronous (it may query Plannotator); session start must
10062
+ // not wait up to the status timeout. The gate is restored/cleared before
10063
+ // the next user turn.
10064
+ void recoverPendingHandoff(ctx as ExtensionContext, event.reason);
8639
10065
  updateStatus(ctx as any);
8640
10066
  });
8641
10067
 
8642
- pi.on("session_shutdown", async (_event, ctx) => {
10068
+ pi.on("session_shutdown", async (event, ctx) => {
8643
10069
  // A write denial is run-scoped; the session is going away, so drop it.
8644
10070
  writeGateRunDenials.clear();
8645
10071
  writeGateTurnAnswer = null;
8646
10072
  writeGateTurnSteered = false;
8647
10073
  writeGateBatchCalls = [];
8648
10074
  writeGateBatchCallIds = new Set();
10075
+ readGateRunDenials.clear();
10076
+ readGateTurnAnswer = null;
10077
+ readGateTurnSteered = false;
10078
+ readGateBatchCalls = [];
10079
+ readGateBatchCallIds = new Set();
10080
+ readGateTurnGrantedRoots.clear();
10081
+ readGateTurnUncertainAllowed.clear();
8649
10082
  // Kill any active explore subagents to prevent orphan leaks.
8650
10083
  // Without this, `pi --mode json` children spawned by runSingleAgent
8651
10084
  // (up to 4 concurrent) could survive if VS Code closes the terminal
@@ -8658,11 +10091,17 @@ export default function workflowExtension(
8658
10091
  }
8659
10092
  }
8660
10093
  activeSubagents.clear();
8661
- // Ensure a handoff decision/provenance does not persist stale across restart
8662
- const clearedAtShutdown = clearPendingHandoffDecision();
10094
+ // A reload/resume/fork can recover a browser review in the replacement
10095
+ // session; quitting or starting a new session cannot own the old server.
10096
+ const keepPending =
10097
+ event.reason === "reload" ||
10098
+ event.reason === "resume" ||
10099
+ event.reason === "fork";
10100
+ const clearedAtShutdown =
10101
+ keepPending === true ? false : clearPendingHandoffDecision();
8663
10102
  resetPlanRunProvenance();
8664
10103
  resetSessionPlanProvenance();
8665
- if (clearedAtShutdown) {
10104
+ if (clearedAtShutdown || keepPending === true) {
8666
10105
  try {
8667
10106
  persistState();
8668
10107
  } catch (_e) {
@@ -8674,13 +10113,15 @@ export default function workflowExtension(
8674
10113
  ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
8675
10114
  ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
8676
10115
  ctx.ui.setWidget("workflow-todos", undefined);
10116
+ // The host dropped the widget; a later session_start re-registers it.
10117
+ todoPanelAppliedState = "unregistered";
8677
10118
  // Drop any /commit authorization or flow: a reload/quit must never leave
8678
10119
  // an exemption armed for whatever runs next.
8679
10120
  commitAuthorization = null;
8680
10121
  pendingCommitFlow = null;
8681
10122
  // Release the reviewer session (and its custom tools) on shutdown so a
8682
10123
  // reload or quit never leaves an orphaned in-process session behind.
8683
- await teardownReview();
10124
+ await teardownReview({ keepHandoff: keepPending });
8684
10125
  currentSessionCtx = null;
8685
10126
  });
8686
10127
 
@@ -8689,6 +10130,12 @@ export default function workflowExtension(
8689
10130
  todoItems = [];
8690
10131
  todoPlanPath = undefined;
8691
10132
  btwNotes = [];
10133
+ // A rewind is a branch boundary: the panel choice belongs to the branch.
10134
+ todoPanelOverride = null;
10135
+ todoPanelOverrideKey = null;
10136
+ todoPanelPromptAfterSettle = false;
10137
+ lastCompletedSignature = null;
10138
+ todoPanelAppliedState = "unregistered";
8692
10139
  // A rewind is a branch boundary: no per-run write from the old branch
8693
10140
  // may authorize a handoff or a manual review on the new one.
8694
10141
  resetPlanRunProvenance();
@@ -8699,13 +10146,18 @@ export default function workflowExtension(
8699
10146
  try {
8700
10147
  const branch = ctx.sessionManager.getBranch();
8701
10148
  for (const e of branch as any[]) {
8702
- if (
8703
- e.type === "custom" &&
8704
- e.customType === "workflow" &&
8705
- Array.isArray(e.data?.todos)
8706
- ) {
8707
- todoItems = ensureTodoLabels(e.data.todos);
8708
- if (e.data.planFile) todoPlanPath = e.data.planFile;
10149
+ if (e.type === "custom" && e.customType === "workflow") {
10150
+ if (Array.isArray(e.data?.todos)) {
10151
+ todoItems = ensureTodoLabels(e.data.todos);
10152
+ if (e.data.planFile) todoPlanPath = e.data.planFile;
10153
+ }
10154
+ if (e.data?.todoPanel && typeof e.data.todoPanel === "object") {
10155
+ const panel = readPersistedTodoPanel(e.data);
10156
+ todoPanelOverride = panel.override;
10157
+ todoPanelOverrideKey = panel.overrideKey;
10158
+ todoPanelPromptAfterSettle = panel.promptAfterSettle;
10159
+ lastCompletedSignature = panel.completedSignature;
10160
+ }
8709
10161
  }
8710
10162
  if (
8711
10163
  e.type === "message" &&
@@ -8745,6 +10197,126 @@ export default function workflowExtension(
8745
10197
  * The decision itself is the pure `decideCommitGate`; this wrapper supplies
8746
10198
  * the runtime inputs and renders the block reasons / ask overlay.
8747
10199
  */
10200
+ function runGitZ(
10201
+ repoRoot: string,
10202
+ args: string[],
10203
+ ): { ok: boolean; paths: string[]; error?: string } {
10204
+ // The Pi host's PATH is not the shell's PATH (Windows especially). Honour
10205
+ // an explicit override so a desktop install without git on PATH can still
10206
+ // run the commit gate's synchronous checks.
10207
+ const gitBinary = process.env.PI_GIT_BIN?.trim() || "git";
10208
+ try {
10209
+ const res = spawnSync(gitBinary, ["-C", repoRoot, ...args], {
10210
+ encoding: "utf8",
10211
+ timeout: 5000,
10212
+ windowsHide: true,
10213
+ maxBuffer: 8 * 1024 * 1024,
10214
+ });
10215
+ if (res.error) {
10216
+ const code = (res.error as NodeJS.ErrnoException).code;
10217
+ const missing =
10218
+ code === "ENOENT" || /\bENOENT\b/.test(res.error.message);
10219
+ return {
10220
+ ok: false,
10221
+ paths: [],
10222
+ error: missing
10223
+ ? `git executable not found (${gitBinary}); set PI_GIT_BIN to the git binary`
10224
+ : res.error.message,
10225
+ };
10226
+ }
10227
+ if (res.status !== 0)
10228
+ return {
10229
+ ok: false,
10230
+ paths: [],
10231
+ error: String(res.stderr ?? "").trim() || `git exited ${res.status}`,
10232
+ };
10233
+ const paths = String(res.stdout ?? "")
10234
+ .split("\0")
10235
+ .map((entry) => entry.replace(/\r?\n$/, ""))
10236
+ .map((entry) => entry.replace(/^..\s+/, ""))
10237
+ .map((entry) => entry.split(" -> ").pop() ?? entry)
10238
+ .filter(Boolean);
10239
+ return { ok: true, paths };
10240
+ } catch (error: unknown) {
10241
+ return {
10242
+ ok: false,
10243
+ paths: [],
10244
+ error: error instanceof Error ? error.message : String(error),
10245
+ };
10246
+ }
10247
+ }
10248
+
10249
+ function resolveCommitRoot(
10250
+ candidate: string,
10251
+ cwd: string,
10252
+ ): { root: string; uncertain: boolean } {
10253
+ const res = runGitZ(candidate, ["rev-parse", "--show-toplevel"]);
10254
+ if (res.ok && res.paths[0]) {
10255
+ return {
10256
+ root: normalizeWritePath(res.paths[0], candidate),
10257
+ uncertain: false,
10258
+ };
10259
+ }
10260
+ const detected = detectRepoRoot(candidate) ?? detectRepoRoot(cwd);
10261
+ return detected
10262
+ ? { root: detected, uncertain: false }
10263
+ : { root: candidate, uncertain: true };
10264
+ }
10265
+
10266
+ function commitSensitiveHit(
10267
+ scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
10268
+ folderConfig: FolderPolicyConfig,
10269
+ ): { path: string; pattern: string } | { failed: string } | undefined {
10270
+ const check = (paths: readonly string[]) => {
10271
+ for (const p of paths) {
10272
+ const hit = matchSensitivePath(p, folderConfig, scope.repoRoot);
10273
+ if (hit.sensitive)
10274
+ return { path: p, pattern: hit.pattern ?? "sensitive" };
10275
+ }
10276
+ return undefined;
10277
+ };
10278
+ const explicit = check(scope.explicitPaths);
10279
+ if (explicit) return explicit;
10280
+
10281
+ if (scope.hit === "add" && scope.allPaths) {
10282
+ const status = runGitZ(scope.repoRoot, [
10283
+ "status",
10284
+ "--porcelain=v1",
10285
+ "-z",
10286
+ "--untracked-files=all",
10287
+ ]);
10288
+ if (!status.ok)
10289
+ return { failed: status.error ?? "git status failed" };
10290
+ const hit = check(status.paths);
10291
+ if (hit) return hit;
10292
+ }
10293
+ if (scope.hit === "commit") {
10294
+ const staged = runGitZ(scope.repoRoot, [
10295
+ "diff",
10296
+ "--cached",
10297
+ "--name-only",
10298
+ "-z",
10299
+ ]);
10300
+ if (!staged.ok)
10301
+ return { failed: staged.error ?? "git diff --cached failed" };
10302
+ const hit = check(staged.paths);
10303
+ if (hit) return hit;
10304
+ if (scope.allPaths) {
10305
+ const status = runGitZ(scope.repoRoot, [
10306
+ "status",
10307
+ "--porcelain=v1",
10308
+ "-z",
10309
+ "--untracked-files=all",
10310
+ ]);
10311
+ if (!status.ok)
10312
+ return { failed: status.error ?? "git status failed" };
10313
+ const modified = check(status.paths);
10314
+ if (modified) return modified;
10315
+ }
10316
+ }
10317
+ return undefined;
10318
+ }
10319
+
8748
10320
  async function commitPolicyGate(
8749
10321
  event: any,
8750
10322
  ctx: any,
@@ -8753,7 +10325,54 @@ export default function workflowExtension(
8753
10325
  const cmd = String(event?.input?.command ?? "");
8754
10326
  const hit = gitCommitPolicyHit(cmd);
8755
10327
  if (!hit) return undefined;
8756
- const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
10328
+ const trusted = ctx?.isProjectTrusted?.() === true;
10329
+ const policyConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
10330
+ trusted,
10331
+ }).config;
10332
+ const policy = policyConfig.mode;
10333
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
10334
+ trusted,
10335
+ }).config;
10336
+ const git = analyzeShellPaths(cmd, cwd).git;
10337
+ const gitDir = /\bGIT_DIR=([^\s;&|]+)/.exec(cmd)?.[1];
10338
+ const candidateRoot = gitDir
10339
+ ? normalizeWritePath(gitDir.replace(/^['"]|['"]$/g, ""), cwd)
10340
+ : (git?.repoRoot ?? normalizeWritePath(cwd, cwd));
10341
+ const resolvedRoot = resolveCommitRoot(candidateRoot, cwd);
10342
+ const repoRoot = resolvedRoot.root;
10343
+ const gitScope = git ? { ...git, repoRoot } : undefined;
10344
+ const folderEvaluation = evaluateFolderTargets(
10345
+ [{ path: repoRoot, kind: "dir" }],
10346
+ {
10347
+ config: folderConfig,
10348
+ grants: policyConfig.allowedPaths,
10349
+ cwd,
10350
+ action: "commit",
10351
+ },
10352
+ );
10353
+ if (resolvedRoot.uncertain)
10354
+ folderEvaluation.uncertain.push(repoRoot);
10355
+ const folderOutside = folderEvaluation.outside.length > 0;
10356
+ const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
10357
+ const folderDecision = decideFolderGate({
10358
+ sensitive: false,
10359
+ outside: folderEvaluation.outside.length > 0,
10360
+ outsideAction: folderConfig.outside,
10361
+ uncertain: folderEvaluation.uncertain.length > 0,
10362
+ });
10363
+ // decideFolderGate already turns uncertainty into ask (outside: ask) or
10364
+ // block (outside: block); there is no "allow" case left to escalate.
10365
+ const sensitiveHit = gitScope
10366
+ ? commitSensitiveHit(gitScope, folderConfig)
10367
+ : undefined;
10368
+ if (sensitiveHit && "failed" in sensitiveHit) {
10369
+ return {
10370
+ block: true,
10371
+ reason:
10372
+ `Commit policy: the sensitive-file check could not run (${sensitiveHit.failed}). ` +
10373
+ "Refusing to commit unverified. Do not retry; ask the user to commit manually or fix git access.",
10374
+ };
10375
+ }
8757
10376
  const now = Date.now();
8758
10377
  const decision = decideCommitGate({
8759
10378
  policy,
@@ -8761,6 +10380,8 @@ export default function workflowExtension(
8761
10380
  hasTodos: todoItems.length > 0,
8762
10381
  auth: commitAuthorization,
8763
10382
  now,
10383
+ folder: folderDecision,
10384
+ sensitive: Boolean(sensitiveHit && !("failed" in sensitiveHit)),
8764
10385
  });
8765
10386
  if (decision === "allow") {
8766
10387
  // Count a granted `git commit` against the authorization, but only when
@@ -8783,6 +10404,25 @@ export default function workflowExtension(
8783
10404
  }
8784
10405
 
8785
10406
  if (decision === "block") {
10407
+ if (sensitiveHit && !("failed" in sensitiveHit)) {
10408
+ return {
10409
+ block: true,
10410
+ reason:
10411
+ `Commit policy: \`${sensitiveHit.path}\` is a sensitive file (${sensitiveHit.pattern}). ` +
10412
+ "Committing it is blocked even when the commit mode is 'allow'. Do not retry; remove it from the index, or ask the user to add a manual sensitiveExemptions entry.",
10413
+ };
10414
+ }
10415
+ if (folderDecision === "block" && policy !== "never") {
10416
+ return {
10417
+ block: true,
10418
+ reason:
10419
+ `Folder policy is 'block' (workflow.folderPolicy): \`git ${hit}\` targets ${repoRoot}, ` +
10420
+ (folderOutside
10421
+ ? `which is outside the permitted folders (${baseRoot}). `
10422
+ : "which could not be verified against the permitted folders. ") +
10423
+ "Do not retry. Ask the user to grant that repository folder or change /folder-policy.",
10424
+ };
10425
+ }
8786
10426
  return {
8787
10427
  block: true,
8788
10428
  reason:
@@ -8803,20 +10443,60 @@ export default function workflowExtension(
8803
10443
  return {
8804
10444
  block: true,
8805
10445
  reason:
8806
- `Commit policy is 'ask' (workflow.commitPolicy): \`git ${hit}\` needs user approval, ` +
10446
+ `${folderOutside ? "Folder policy is 'ask' (workflow.folderPolicy)" : "Commit policy is 'ask' (workflow.commitPolicy)"}: \`git ${hit}\` needs user approval, ` +
8807
10447
  "but no interactive UI is available. Leave the change uncommitted and let the user decide.",
8808
10448
  };
8809
10449
  }
10450
+ const commitGrant = folderOutside
10451
+ ? (outsideProjectRootForTarget({ path: repoRoot, kind: "dir" }, baseRoot) ??
10452
+ repoRoot)
10453
+ : repoRoot;
8810
10454
  let sel: unknown;
8811
10455
  try {
8812
10456
  sel = await ui.select(
8813
- `Commit policy 'ask': allow \`git ${hit}\`?`,
8814
- ["Allow once", "Deny"],
10457
+ `${folderOutside ? "Folder" : "Commit"} policy 'ask': allow \`git ${hit}\`?` +
10458
+ `\nRepository: ${repoRoot}` +
10459
+ (folderOutside ? `\nOutside: ${baseRoot}` : ""),
10460
+ folderOutside
10461
+ ? ["Allow once", "Always allow this repository folder", "Deny"]
10462
+ : ["Allow once", "Deny"],
8815
10463
  );
8816
10464
  } catch {
8817
10465
  sel = undefined;
8818
10466
  }
8819
- if (String(sel ?? "").startsWith("Allow")) return undefined;
10467
+ const answer = String(sel ?? "");
10468
+ if (answer.startsWith("Always allow")) {
10469
+ if (folderConfig.grantScope === "project" && !trusted) {
10470
+ return {
10471
+ block: true,
10472
+ reason:
10473
+ "Folder policy 'ask': this project is not trusted, so a project-scoped grant would be ignored. " +
10474
+ "Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
10475
+ };
10476
+ }
10477
+ const written = writeCommitPolicyConfig(
10478
+ folderConfig.grantScope,
10479
+ cwd,
10480
+ getAgentDir(),
10481
+ { allowedPaths: [commitGrant] },
10482
+ );
10483
+ if (!written.ok) {
10484
+ return {
10485
+ block: true,
10486
+ reason: `Could not save the commit folder grant (${written.error ?? "unknown error"}); the commit was not allowed.`,
10487
+ };
10488
+ }
10489
+ return undefined;
10490
+ }
10491
+ if (answer.startsWith("Allow")) return undefined;
10492
+ if (folderOutside) {
10493
+ return {
10494
+ block: true,
10495
+ reason:
10496
+ `Folder policy 'ask': the user declined \`git ${hit}\` for ${repoRoot}, which is outside ${baseRoot}. ` +
10497
+ "Do not retry — leave the changes uncommitted for the user.",
10498
+ };
10499
+ }
8820
10500
  return {
8821
10501
  block: true,
8822
10502
  reason:
@@ -8833,7 +10513,9 @@ export default function workflowExtension(
8833
10513
  function commitDirectiveLine(ctx: any): string {
8834
10514
  try {
8835
10515
  const cwd = ctx?.cwd ?? process.cwd();
8836
- const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
10516
+ const mode = readCommitPolicyConfig(cwd, getAgentDir(), {
10517
+ trusted: ctx?.isProjectTrusted?.() === true,
10518
+ }).config.mode;
8837
10519
  switch (mode) {
8838
10520
  case "never":
8839
10521
  return (
@@ -8869,16 +10551,90 @@ export default function workflowExtension(
8869
10551
  * part of the list (its arguments may have been rewritten by another
8870
10552
  * extension's tool_call handler after the assistant message was sealed).
8871
10553
  */
10554
+ function writeFolderTargetsFor(
10555
+ toolName: string,
10556
+ input: any,
10557
+ classification: WriteCallClassification,
10558
+ cwd: string,
10559
+ ): FolderTarget[] {
10560
+ if (classification.paths.length > 0) {
10561
+ return classification.paths.map((p) => ({ path: p, kind: "file" as const }));
10562
+ }
10563
+ if (toolName === "bash" || toolName === "powershell") {
10564
+ const command = String(input?.command ?? "");
10565
+ return analyzeShellPaths(command, cwd).writes;
10566
+ }
10567
+ return [];
10568
+ }
10569
+
10570
+ function withFolderGrant(
10571
+ item: WriteGateItem,
10572
+ targets: readonly FolderTarget[],
10573
+ cwd: string,
10574
+ baseRoot: string,
10575
+ ): WriteGateItem {
10576
+ const grantEntries = targets
10577
+ .map((target) => {
10578
+ const exact = normalizeWritePath(target.path, cwd);
10579
+ const outsideRoot = outsideProjectRootForTarget(target, baseRoot);
10580
+ const path = outsideRoot ?? exact;
10581
+ // A dir/repo target (or a glob's static prefix) is granted as a
10582
+ // folder; so is a containing folder computed for an outside file. A
10583
+ // file directly in the common parent is granted as that file alone.
10584
+ const folder =
10585
+ target.kind !== "file" ||
10586
+ (outsideRoot !== undefined && outsideRoot !== exact);
10587
+ return { path, folder };
10588
+ })
10589
+ .filter((entry) => entry.path.length > 0);
10590
+ if (grantEntries.length === 0) return item;
10591
+ const unique = [...new Set(grantEntries.map((entry) => entry.path))].sort();
10592
+ return {
10593
+ ...item,
10594
+ target: unique.join(", "),
10595
+ grantPaths: [...new Set([...(item.grantPaths ?? []), ...unique])],
10596
+ grantKind: grantEntries.every((entry) => entry.folder)
10597
+ ? "folder"
10598
+ : "file",
10599
+ grantCommand: undefined,
10600
+ key: `path:${unique.join("|")}`,
10601
+ };
10602
+ }
10603
+
8872
10604
  function collectWriteGateItems(
8873
10605
  currentEvent: any,
8874
10606
  currentClassification: WriteCallClassification,
8875
10607
  config: WritePolicyConfig,
10608
+ folderConfig: FolderPolicyConfig,
8876
10609
  cwd: string,
10610
+ baseRoot: string,
8877
10611
  ): WriteGateItem[] {
8878
10612
  const items: WriteGateItem[] = [];
8879
10613
  const seen = new Set<string>();
8880
- const add = (name: string, classification: WriteCallClassification) => {
8881
- const item = writeGateItemFromClassification(name, classification, cwd);
10614
+ const add = (
10615
+ name: string,
10616
+ classification: WriteCallClassification,
10617
+ input: any,
10618
+ ) => {
10619
+ const targets = writeFolderTargetsFor(
10620
+ name,
10621
+ input,
10622
+ classification,
10623
+ cwd,
10624
+ );
10625
+ const evaluation = evaluateFolderTargets(targets, {
10626
+ config: folderConfig,
10627
+ grants: config.allowedPaths,
10628
+ cwd,
10629
+ action: "write",
10630
+ });
10631
+ const needsFolder = evaluation.outside.length > 0;
10632
+ if (!needsFolder && isWriteCallAllowed(classification, config, cwd))
10633
+ return;
10634
+ const base = writeGateItemFromClassification(name, classification, cwd);
10635
+ const item = needsFolder
10636
+ ? withFolderGrant(base, targets, cwd, baseRoot)
10637
+ : base;
8882
10638
  if (!item.key || seen.has(item.key)) return;
8883
10639
  seen.add(item.key);
8884
10640
  items.push(item);
@@ -8888,8 +10644,6 @@ export default function workflowExtension(
8888
10644
  if (!name) continue;
8889
10645
  const classification = classifyWriteCall(name, call.args, config);
8890
10646
  if (!classification.mutating) continue;
8891
- // A granted target is not gated — it never reaches a dialog.
8892
- if (isWriteCallAllowed(classification, config, cwd)) continue;
8893
10647
  // The plan directory stays writable in every mode.
8894
10648
  if (
8895
10649
  classification.paths.length > 0 &&
@@ -8897,9 +10651,9 @@ export default function workflowExtension(
8897
10651
  ) {
8898
10652
  continue;
8899
10653
  }
8900
- add(name, classification);
10654
+ add(name, classification, call.args);
8901
10655
  }
8902
- add(String(currentEvent?.toolName ?? ""), currentClassification);
10656
+ add(String(currentEvent?.toolName ?? ""), currentClassification, currentEvent?.input);
8903
10657
  return items;
8904
10658
  }
8905
10659
 
@@ -8944,13 +10698,16 @@ export default function workflowExtension(
8944
10698
  }
8945
10699
 
8946
10700
  /**
8947
- * Write permission gate (workflow.writePolicy) — Default mode only.
10701
+ * Write permission gate (workflow.writePolicy + the shared folder floor).
8948
10702
  *
8949
10703
  * The Workflow extension's three modes are Plan → Build → Default. Plan
8950
- * mode is read-only and Build mode is unrestricted by design, but DEFAULT
8951
- * mode was ALSO unrestricted: `resolveSessionRole()` returns undefined
8952
- * there, so the tool_call gate has no branch to take and the agent edits
8953
- * files without asking. This gate is the missing permission knob.
10704
+ * mode is read-only and Build mode is unrestricted *inside the permitted
10705
+ * folders*, but DEFAULT mode was ALSO unrestricted: `resolveSessionRole()`
10706
+ * returns undefined there, so the tool_call gate has no branch to take and
10707
+ * the agent edits files without asking. This gate is the missing permission
10708
+ * knob. The write MODE is Default-only; the folder floor applies in every
10709
+ * mode (Build included) and asks only for proven outside targets, sensitive
10710
+ * files, or an explicit target path that cannot be resolved.
8954
10711
  *
8955
10712
  * It is deliberately INDEPENDENT of the role gate and of the commit
8956
10713
  * policy: those govern *what* a role may do, this governs *whether the
@@ -8970,24 +10727,24 @@ export default function workflowExtension(
8970
10727
  cwd: string,
8971
10728
  ): Promise<{ block: true; reason: string } | undefined> {
8972
10729
  try {
8973
- // Scope: the top-level Default-mode session only.
8974
- // - Plan mode has its own stricter rules (writes only to .pi/plans/).
8975
- // - Build mode is the deliberate "go ahead" execution escape hatch.
8976
- // - A spawned child carries PI_WORKFLOW_ROLE and is already read-only
8977
- // through the role gate (and has no UI to answer a prompt).
8978
- if (workflowMode !== null) return undefined;
8979
- if (
8980
- resolveSessionRole({
8981
- envRole: process.env[WORKFLOW_ROLE_ENV],
8982
- workflowMode,
8983
- }) !== undefined
8984
- ) {
8985
- return undefined;
8986
- }
10730
+ // Scope: the write MODE is a Default-mode knob; the shared FOLDER floor
10731
+ // applies to every mode. Plan mode already restricts writes to
10732
+ // .pi/plans/ (inside the folder root); Build mode is the deliberate
10733
+ // execution path but must still respect the user's folder floor.
10734
+ const gateRole = resolveSessionRole({
10735
+ envRole: process.env[WORKFLOW_ROLE_ENV],
10736
+ workflowMode,
10737
+ });
10738
+ if (gateRole && gateRole !== "builder") return undefined;
10739
+ const defaultMode = workflowMode === null && gateRole === undefined;
8987
10740
 
8988
- const resolved = readWritePolicyConfig(cwd, getAgentDir());
10741
+ const trusted = ctx?.isProjectTrusted?.() === true;
10742
+ const resolved = readWritePolicyConfig(cwd, getAgentDir(), { trusted });
8989
10743
  const config = resolved.config;
8990
- if (config.mode === "allow") return undefined;
10744
+ const effectiveWriteMode = defaultMode ? config.mode : "allow";
10745
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
10746
+ trusted,
10747
+ }).config;
8991
10748
 
8992
10749
  const classification = classifyWriteCall(
8993
10750
  event.toolName,
@@ -9005,23 +10762,94 @@ export default function workflowExtension(
9005
10762
  return undefined;
9006
10763
  }
9007
10764
 
10765
+ const folderTargets = writeFolderTargetsFor(
10766
+ String(event.toolName ?? ""),
10767
+ event.input,
10768
+ classification,
10769
+ cwd,
10770
+ );
10771
+ for (const folderTarget of folderTargets) {
10772
+ const hit = matchSensitivePath(folderTarget.path, folderConfig, cwd);
10773
+ if (hit.sensitive) {
10774
+ return {
10775
+ block: true,
10776
+ reason:
10777
+ `Write policy: \`${folderTarget.path}\` is a sensitive file` +
10778
+ (hit.pattern ? ` (${hit.pattern})` : "") +
10779
+ ". This cannot be overridden by a write grant or folder. " +
10780
+ "Do not retry; ask the user to edit the sensitive list in settings.json if this is intentional.",
10781
+ };
10782
+ }
10783
+ }
10784
+
10785
+ const folderEvaluation = evaluateFolderTargets(folderTargets, {
10786
+ config: folderConfig,
10787
+ grants: config.allowedPaths,
10788
+ cwd,
10789
+ action: "write",
10790
+ });
10791
+ const folderDecision = decideFolderGate({
10792
+ sensitive: false,
10793
+ outside: folderEvaluation.outside.length > 0,
10794
+ outsideAction: folderConfig.outside,
10795
+ // Only a concrete target path that cannot be canonicalised is folder
10796
+ // uncertainty. "Mutating but no discovered write target" is NOT: the
10797
+ // shell analyzer cannot see inside `npm run build`, `node script`,
10798
+ // `npx`, `sed`/`awk` programs or `powershell`, and treating that as
10799
+ // "possibly outside" made Build and writePolicy:allow prompt for
10800
+ // ordinary in-repo toolchain commands. The capability policy owns
10801
+ // that case — writePolicy:ask still asks for every mutating call —
10802
+ // and undiscoverable shell writes are the documented residual
10803
+ // (README "Not a sandbox").
10804
+ uncertain: folderEvaluation.uncertain.length > 0,
10805
+ });
9008
10806
  const allowed = isWriteCallAllowed(classification, config, cwd);
9009
10807
  const decision = decideWriteGate({
9010
- mode: config.mode,
10808
+ mode: effectiveWriteMode,
9011
10809
  mutating: true,
9012
10810
  allowed,
10811
+ folder: folderDecision,
9013
10812
  });
9014
10813
  if (decision === "allow") return undefined;
9015
10814
 
10815
+ // Which policy actually produced this ask. A folder-sourced prompt must
10816
+ // not be labelled "Write policy" — that misattribution is what made the
10817
+ // in-repo toolchain regression look like a write-policy bug.
10818
+ const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
10819
+ const folderAsk = folderDecision === "ask";
10820
+ const gateSource: WriteGateSource = folderAsk
10821
+ ? effectiveWriteMode === "ask"
10822
+ ? "mixed"
10823
+ : "folder"
10824
+ : "write";
10825
+ const gatePolicyLabel =
10826
+ gateSource === "folder"
10827
+ ? "Folder policy"
10828
+ : gateSource === "mixed"
10829
+ ? "Permission policy"
10830
+ : "Write policy";
10831
+ const gateOutside = gateSource === "folder" ? baseRoot : undefined;
10832
+
9016
10833
  const target =
9017
- classification.paths.length > 0
9018
- ? classification.paths.join(", ")
9019
- : (classification.key ?? String(event.toolName));
10834
+ folderTargets.length > 0
10835
+ ? folderTargets.map((item) => item.path).join(", ")
10836
+ : classification.paths.length > 0
10837
+ ? classification.paths.join(", ")
10838
+ : (classification.key ?? String(event.toolName));
9020
10839
  const why = classification.reason
9021
10840
  ? ` (${classification.reason})`
9022
10841
  : "";
9023
10842
 
9024
10843
  if (decision === "block") {
10844
+ if (folderDecision === "block" && effectiveWriteMode !== "never") {
10845
+ return {
10846
+ block: true,
10847
+ reason:
10848
+ `Folder policy is 'block' (workflow.folderPolicy): the target is outside the permitted folders. ` +
10849
+ "Do not retry. Ask the user to open the project at that folder, grant it, or change /folder-policy. " +
10850
+ `Target: ${target}`,
10851
+ };
10852
+ }
9025
10853
  return {
9026
10854
  block: true,
9027
10855
  reason:
@@ -9047,11 +10875,18 @@ export default function workflowExtension(
9047
10875
  // the answer applies to all of them.
9048
10876
  const ui = ctx?.ui;
9049
10877
  if (!ui || !ctx.hasUI) {
10878
+ const policyName =
10879
+ gateSource === "folder"
10880
+ ? "Folder policy is 'ask' (workflow.folderPolicy)"
10881
+ : gateSource === "mixed"
10882
+ ? "Folder and write policies are 'ask' (workflow.folderPolicy + workflow.writePolicy)"
10883
+ : "Write policy is 'ask' (workflow.writePolicy)";
9050
10884
  return {
9051
10885
  block: true,
9052
10886
  reason:
9053
- `Write policy is 'ask' (workflow.writePolicy): writing needs user approval${why}, but no interactive UI is available. ` +
10887
+ `${policyName}: writing needs user approval${why}, but no interactive UI is available. ` +
9054
10888
  "Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
10889
+ (gateSource === "folder" ? `Outside: ${baseRoot}. ` : "") +
9055
10890
  `Target: ${target}`,
9056
10891
  };
9057
10892
  }
@@ -9068,12 +10903,23 @@ export default function workflowExtension(
9068
10903
  writeGateBatchCalls = [];
9069
10904
  writeGateBatchCallIds = new Set();
9070
10905
  }
9071
- const items = collectWriteGateItems(event, classification, config, cwd);
9072
- const currentItem = writeGateItemFromClassification(
10906
+ const items = collectWriteGateItems(
10907
+ event,
10908
+ classification,
10909
+ config,
10910
+ folderConfig,
10911
+ cwd,
10912
+ baseRoot,
10913
+ );
10914
+ const currentBase = writeGateItemFromClassification(
9073
10915
  String(event.toolName ?? ""),
9074
10916
  classification,
9075
10917
  cwd,
9076
10918
  );
10919
+ const currentItem =
10920
+ folderEvaluation.outside.length > 0
10921
+ ? withFolderGrant(currentBase, folderTargets, cwd, baseRoot)
10922
+ : currentBase;
9077
10923
 
9078
10924
  // (a) The user denied this target earlier in THIS RUN → deny again,
9079
10925
  // silently. The model gets the same sentence; the user is not asked a
@@ -9087,6 +10933,8 @@ export default function workflowExtension(
9087
10933
  target,
9088
10934
  why: classification.reason,
9089
10935
  instruction: remembered.instruction,
10936
+ source: gateSource,
10937
+ outside: gateOutside,
9090
10938
  }),
9091
10939
  };
9092
10940
  }
@@ -9104,6 +10952,8 @@ export default function workflowExtension(
9104
10952
  target,
9105
10953
  why: classification.reason,
9106
10954
  instruction: answer.instruction,
10955
+ source: gateSource,
10956
+ outside: gateOutside,
9107
10957
  }),
9108
10958
  };
9109
10959
  }
@@ -9112,7 +10962,7 @@ export default function workflowExtension(
9112
10962
  const options = writeGateOptions(writeGateItemsKind(items));
9113
10963
  let sel: unknown;
9114
10964
  try {
9115
- sel = await ui.select(writeGateTitle(items), options);
10965
+ sel = await ui.select(writeGateTitle(items, gateSource, baseRoot), options);
9116
10966
  } catch (_e) {
9117
10967
  void _e;
9118
10968
  sel = undefined;
@@ -9134,14 +10984,23 @@ export default function workflowExtension(
9134
10984
  for (const p of item.grantPaths ?? []) allowedPaths.push(p);
9135
10985
  if (item.grantCommand) allowedCommands.push(item.grantCommand);
9136
10986
  }
9137
- const written = writeWritePolicyConfig("global", cwd, getAgentDir(), {
10987
+ const grantScope = folderConfig.grantScope;
10988
+ if (grantScope === "project" && !trusted) {
10989
+ return {
10990
+ block: true,
10991
+ reason:
10992
+ `${gatePolicyLabel} 'ask': this project is not trusted, so a project-scoped grant would be ignored. ` +
10993
+ "Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
10994
+ };
10995
+ }
10996
+ const written = writeWritePolicyConfig(grantScope, cwd, getAgentDir(), {
9138
10997
  allowedPaths,
9139
10998
  allowedCommands,
9140
10999
  });
9141
11000
  if (written.ok) {
9142
11001
  try {
9143
11002
  (ctx as any).ui?.notify?.(
9144
- `Write policy: always allowed ${writeGateTargetsLabel(items)}`,
11003
+ `${gatePolicyLabel}: always allowed ${writeGateTargetsLabel(items)}`,
9145
11004
  "info",
9146
11005
  );
9147
11006
  } catch (_e) {
@@ -9155,99 +11014,444 @@ export default function workflowExtension(
9155
11014
  return {
9156
11015
  block: true,
9157
11016
  reason:
9158
- `Write policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
11017
+ `${gatePolicyLabel} 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
9159
11018
  `Target: ${target}`,
9160
11019
  };
9161
11020
  }
9162
11021
 
9163
- // ── Denial ──
9164
- // "Deny with instructions…" lets the user say WHY in one sentence
9165
- // ("only explore and propose a plan, do not edit files yet"), so the
9166
- // model can change course instead of stopping cold. The sentence
9167
- // travels through TWO channels:
9168
- // 1. the block reason (the tool result the model reads right now), and
9169
- // 2. a USER message (a steer turn the model must obey).
9170
- // Anthropic shipped the comment-only version and then shipped the bug
9171
- // where the model ignored it (anthropics/claude-code#89231, #52824):
9172
- // a tool result is a fact the model may reinterpret, a user turn is an
9173
- // instruction it must follow.
11022
+ // ── Denial ──
11023
+ // "Deny with instructions…" lets the user say WHY in one sentence
11024
+ // ("only explore and propose a plan, do not edit files yet"), so the
11025
+ // model can change course instead of stopping cold. The sentence
11026
+ // travels through TWO channels:
11027
+ // 1. the block reason (the tool result the model reads right now), and
11028
+ // 2. a USER message (a steer turn the model must obey).
11029
+ // Anthropic shipped the comment-only version and then shipped the bug
11030
+ // where the model ignored it (anthropics/claude-code#89231, #52824):
11031
+ // a tool result is a fact the model may reinterpret, a user turn is an
11032
+ // instruction it must follow.
11033
+ let instruction = "";
11034
+ if (choice === "deny-with-instructions") {
11035
+ const inputFn = (ui as any).input;
11036
+ if (typeof inputFn === "function") {
11037
+ let typed: unknown;
11038
+ try {
11039
+ typed = await inputFn.call(
11040
+ ui,
11041
+ WRITE_INSTRUCTION_PROMPT_TITLE,
11042
+ WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
11043
+ );
11044
+ } catch (_e) {
11045
+ void _e;
11046
+ typed = undefined;
11047
+ }
11048
+ instruction = sanitizeWriteInstruction(typed);
11049
+ }
11050
+ // No input dialog, a cancelled dialog, or empty text → plain denial
11051
+ // below. A cancel is never an implicit allow.
11052
+ }
11053
+
11054
+ // The answer covers every gated write of this turn.
11055
+ writeGateTurnAnswer = { choice, instruction };
11056
+ // …and the denial is remembered for the REST OF THE RUN, under every
11057
+ // target of this turn: a later retry is auto-denied with the same
11058
+ // sentence instead of asking the user again.
11059
+ for (const item of items) {
11060
+ writeGateRunDenials.set(item.key, { instruction });
11061
+ }
11062
+
11063
+ const reason = buildWriteDenyReason({
11064
+ target: items.length > 1 ? writeGateTargetsLabel(items) : target,
11065
+ why: classification.reason,
11066
+ instruction,
11067
+ source: gateSource,
11068
+ outside: gateOutside,
11069
+ });
11070
+
11071
+ if (instruction) {
11072
+ // Channel 2: a user-role message. `steer` queues it while the agent
11073
+ // is running; it lands after the current tool batch, before the next
11074
+ // model call, as a genuine user turn. ONE per turn, not one per
11075
+ // blocked call.
11076
+ deliverWriteInstruction(items, instruction);
11077
+ // Audit trail — transcript only (appendEntry is never sent to the
11078
+ // LLM). One entry per turn: it records the user's decision, not each
11079
+ // call the decision was applied to.
11080
+ try {
11081
+ pi.appendEntry("workflow-write-denial", {
11082
+ tool: String(event.toolName ?? ""),
11083
+ target,
11084
+ instruction,
11085
+ at: Date.now(),
11086
+ targets: items.map((item) => item.target),
11087
+ });
11088
+ } catch (_e) {
11089
+ void _e;
11090
+ }
11091
+ }
11092
+
11093
+ return {
11094
+ block: true,
11095
+ reason,
11096
+ // NOTE: no `terminate` here. pi consults `terminate` only AFTER the
11097
+ // whole tool batch has run (`executeToolCallsParallel` prepares and
11098
+ // runs every call before `shouldTerminateToolBatch()`), so it cannot
11099
+ // suppress a sibling write in the same batch — it only decides
11100
+ // whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
11101
+ // would end the run with no model acknowledgement at all: a
11102
+ // behaviour change nobody asked for. The guided path gets its next
11103
+ // turn from the queued steer message instead.
11104
+ };
11105
+ } catch (e: unknown) {
11106
+ // Fail closed: a broken policy read must not become an open door.
11107
+ return {
11108
+ block: true,
11109
+ reason:
11110
+ `Write policy (workflow.writePolicy) could not be evaluated (${e instanceof Error ? e.message : String(e)}), so the write was blocked. ` +
11111
+ "Do not retry. Ask the user to switch to Build mode (/build) or to fix the policy with /write-policy.",
11112
+ };
11113
+ }
11114
+ }
11115
+
11116
+ function collectReadGateItems(
11117
+ currentEvent: any,
11118
+ currentClassification: ReturnType<typeof classifyReadCall>,
11119
+ config: ReadPolicyConfig,
11120
+ folderConfig: FolderPolicyConfig,
11121
+ cwd: string,
11122
+ lockedRoots?: readonly string[],
11123
+ baseRoot?: string,
11124
+ ): ReadGateItem[] {
11125
+ const items: ReadGateItem[] = [];
11126
+ const seen = new Set<string>();
11127
+ const add = (
11128
+ name: string,
11129
+ classification: ReturnType<typeof classifyReadCall>,
11130
+ ) => {
11131
+ if (classification.exempt) return;
11132
+ const targets = classification.targets;
11133
+ const evaluation = evaluateFolderTargets(targets, {
11134
+ config: folderConfig,
11135
+ grants: [...config.allowedPaths, ...readGateTurnGrantedRoots],
11136
+ cwd,
11137
+ lockedRoots,
11138
+ action: "read",
11139
+ });
11140
+ for (const target of evaluation.outside) {
11141
+ const item = readGateItemFromTarget(
11142
+ name,
11143
+ target,
11144
+ "outside the folder",
11145
+ baseRoot,
11146
+ );
11147
+ if (seen.has(item.key)) continue;
11148
+ seen.add(item.key);
11149
+ items.push(item);
11150
+ }
11151
+ if (evaluation.outside.length === 0) {
11152
+ for (const reason of classification.uncertain) {
11153
+ const key = `cmd:${reason}`;
11154
+ if (seen.has(key)) continue;
11155
+ seen.add(key);
11156
+ items.push({
11157
+ tool: name,
11158
+ target: { path: reason, kind: "file" },
11159
+ grantRoot: "",
11160
+ why: reason,
11161
+ key,
11162
+ });
11163
+ }
11164
+ }
11165
+ };
11166
+ for (const call of readGateBatchCalls) {
11167
+ const name = String(call.name ?? "");
11168
+ if (!name) continue;
11169
+ add(name, classifyReadCall(name, call.args, cwd, config));
11170
+ }
11171
+ add(
11172
+ String(currentEvent?.toolName ?? ""),
11173
+ currentClassification,
11174
+ );
11175
+ return items;
11176
+ }
11177
+
11178
+ function deliverReadInstruction(instruction: string, target: string): void {
11179
+ if (!instruction || readGateTurnSteered) return;
11180
+ const rt = reviewRuntime;
11181
+ const wouldRouteToReviewer = shouldRouteInputToReviewer({
11182
+ reviewEnabled: reviewEnabled(),
11183
+ reviewActive: rt?.isRoundActive() === true,
11184
+ hasReviewerSession: rt?.isAlive() === true,
11185
+ manualSessionActive: reviewSessionLive(),
11186
+ });
11187
+ if (!wouldRouteToReviewer) {
11188
+ try {
11189
+ (pi as any).sendUserMessage?.(
11190
+ buildReadGuidanceMessage({ target, instruction }),
11191
+ { deliverAs: "steer" },
11192
+ );
11193
+ } catch (_e) {
11194
+ void _e;
11195
+ }
11196
+ }
11197
+ readGateTurnSteered = true;
11198
+ }
11199
+
11200
+ /**
11201
+ * Read permission gate. Applies in every workflow mode and to every role,
11202
+ * including Build/explorer/reviewer-adjacent calls. The shared folder floor
11203
+ * decides scope; the sensitive list always wins; the dialog is batched once
11204
+ * per assistant turn.
11205
+ */
11206
+ async function readPolicyGate(
11207
+ event: any,
11208
+ ctx: any,
11209
+ cwd: string,
11210
+ ): Promise<{ block: true; reason: string } | undefined> {
11211
+ try {
11212
+ const trusted = ctx?.isProjectTrusted?.() === true;
11213
+ const readResolved = readReadPolicyConfig(cwd, getAgentDir(), {
11214
+ trusted,
11215
+ });
11216
+ const folderResolved = readFolderPolicyConfig(cwd, getAgentDir(), {
11217
+ trusted,
11218
+ });
11219
+ const config = readResolved.config;
11220
+ const folderConfig = folderResolved.config;
11221
+ const classification = classifyReadCall(
11222
+ String(event.toolName ?? ""),
11223
+ event.input,
11224
+ cwd,
11225
+ config,
11226
+ );
11227
+ if (classification.exempt) return undefined;
11228
+ if (
11229
+ classification.targets.length === 0 &&
11230
+ classification.uncertain.length === 0
11231
+ )
11232
+ return undefined;
11233
+
11234
+ const grants = [...config.allowedPaths, ...readGateTurnGrantedRoots];
11235
+ const evaluation = evaluateFolderTargets(classification.targets, {
11236
+ config: folderConfig,
11237
+ grants,
11238
+ cwd,
11239
+ lockedRoots: folderResolved.locked?.roots,
11240
+ action: "read",
11241
+ });
11242
+ if (evaluation.sensitive.length > 0) {
11243
+ const hit = evaluation.sensitive[0];
11244
+ return {
11245
+ block: true,
11246
+ reason: buildReadDenyReason({
11247
+ kind: "sensitive",
11248
+ target: hit.path,
11249
+ pattern: hit.pattern,
11250
+ }),
11251
+ };
11252
+ }
11253
+
11254
+ const outside = evaluation.outside;
11255
+ const uncertain = classification.uncertain;
11256
+ let decision = decideReadGate({
11257
+ sensitive: false,
11258
+ outside: outside.length > 0,
11259
+ outsideAction: folderConfig.outside,
11260
+ granted: false,
11261
+ uncertain:
11262
+ uncertain.length > 0 || evaluation.uncertain.length > 0,
11263
+ shell: config.shell,
11264
+ });
11265
+
11266
+ if (uncertain.length > 0 && config.shell === "strict") {
11267
+ const allowed = uncertain.every((reason) =>
11268
+ readGateTurnUncertainAllowed.has(reason),
11269
+ );
11270
+ if (allowed) decision = "allow";
11271
+ }
11272
+ if (decision === "allow") return undefined;
11273
+ if (decision === "block") {
11274
+ return {
11275
+ block: true,
11276
+ reason: buildReadDenyReason({
11277
+ kind: "blocked",
11278
+ target:
11279
+ outside.length > 0
11280
+ ? outside.map((target) => target.path).join(", ")
11281
+ : uncertain[0] ?? String(event.toolName),
11282
+ outsideAction: folderConfig.outside,
11283
+ }),
11284
+ };
11285
+ }
11286
+
11287
+ const ui = ctx?.ui;
11288
+ if (!ui || !ctx.hasUI) {
11289
+ return {
11290
+ block: true,
11291
+ reason:
11292
+ "Read policy is 'ask' (workflow.folderPolicy): this read needs approval, but no interactive UI is available. " +
11293
+ "Do not retry; report what you need and let the user approve it.",
11294
+ };
11295
+ }
11296
+
11297
+ const callId = String(event.toolCallId ?? "");
11298
+ if (!readGateBatchCallIds.has(callId)) {
11299
+ readGateTurnAnswer = null;
11300
+ readGateTurnSteered = false;
11301
+ readGateBatchCalls = [];
11302
+ readGateBatchCallIds = new Set();
11303
+ readGateTurnGrantedRoots.clear();
11304
+ readGateTurnUncertainAllowed.clear();
11305
+ }
11306
+ const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
11307
+ const items = collectReadGateItems(
11308
+ event,
11309
+ classification,
11310
+ config,
11311
+ folderConfig,
11312
+ cwd,
11313
+ folderResolved.locked?.roots,
11314
+ baseRoot,
11315
+ );
11316
+ const currentItems = items.filter((item) => {
11317
+ if (outside.length > 0)
11318
+ return outside.some((target) => item.key === readGateTargetKey(target));
11319
+ return uncertain.some((reason) => item.key === `cmd:${reason}`);
11320
+ });
11321
+ const currentItem = currentItems[0];
11322
+ const remembered = currentItem
11323
+ ? readGateRunDenials.get(currentItem.key)
11324
+ : undefined;
11325
+ if (remembered) {
11326
+ return {
11327
+ block: true,
11328
+ reason: buildReadDenyReason({
11329
+ kind: "outside",
11330
+ target: readGateTargetsLabel(currentItems) || "the requested read",
11331
+ instruction: remembered.instruction,
11332
+ }),
11333
+ };
11334
+ }
11335
+ if (readGateTurnAnswer) {
11336
+ if (readGateTurnAnswer.choice === "deny") {
11337
+ return {
11338
+ block: true,
11339
+ reason: buildReadDenyReason({
11340
+ kind: "outside",
11341
+ target: readGateTargetsLabel(currentItems) || "the requested read",
11342
+ instruction: readGateTurnAnswer.instruction,
11343
+ }),
11344
+ };
11345
+ }
11346
+ return undefined;
11347
+ }
11348
+
11349
+ const options = readGateOptions(readGateItemsKind(items));
11350
+ let sel: unknown;
11351
+ try {
11352
+ sel = await ui.select(readGateTitle(items, baseRoot), options);
11353
+ } catch {
11354
+ sel = undefined;
11355
+ }
11356
+ const choice = parseReadGateChoice(sel);
11357
+ if (choice === "allow-once") {
11358
+ for (const item of items) if (item.grantRoot) readGateTurnGrantedRoots.add(item.grantRoot);
11359
+ for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
11360
+ readGateTurnAnswer = { choice: "allow-once", instruction: "" };
11361
+ return undefined;
11362
+ }
11363
+ if (choice === "always") {
11364
+ const roots = [
11365
+ ...new Set(items.map((item) => item.grantRoot).filter(Boolean)),
11366
+ ];
11367
+ if (roots.length === 0) {
11368
+ for (const reason of uncertain)
11369
+ readGateTurnUncertainAllowed.add(reason);
11370
+ readGateTurnAnswer = { choice: "always", instruction: "" };
11371
+ return undefined;
11372
+ }
11373
+ const broad = roots.find((root) => isBroadGrantRoot(root));
11374
+ if (broad) {
11375
+ return {
11376
+ block: true,
11377
+ reason:
11378
+ `Read policy: \`${broad}\` is too broad to persist from a dialog. Choose Allow once, or use /folder-policy any deliberately.`,
11379
+ };
11380
+ }
11381
+ if (folderConfig.grantScope === "project" && !trusted) {
11382
+ return {
11383
+ block: true,
11384
+ reason:
11385
+ "Read policy 'ask': this project is not trusted, so a project-scoped grant would be ignored. " +
11386
+ "Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
11387
+ };
11388
+ }
11389
+ const written = writeReadPolicyConfig(
11390
+ folderConfig.grantScope,
11391
+ cwd,
11392
+ getAgentDir(),
11393
+ { allowedPaths: roots },
11394
+ );
11395
+ if (!written.ok) {
11396
+ return {
11397
+ block: true,
11398
+ reason: `Read policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}).`,
11399
+ };
11400
+ }
11401
+ for (const root of roots) readGateTurnGrantedRoots.add(root);
11402
+ for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
11403
+ readGateTurnAnswer = { choice: "always", instruction: "" };
11404
+ return undefined;
11405
+ }
11406
+
9174
11407
  let instruction = "";
9175
11408
  if (choice === "deny-with-instructions") {
9176
11409
  const inputFn = (ui as any).input;
9177
11410
  if (typeof inputFn === "function") {
9178
- let typed: unknown;
9179
11411
  try {
9180
- typed = await inputFn.call(
9181
- ui,
9182
- WRITE_INSTRUCTION_PROMPT_TITLE,
9183
- WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
11412
+ instruction = sanitizeReadInstruction(
11413
+ await inputFn.call(
11414
+ ui,
11415
+ READ_INSTRUCTION_PROMPT_TITLE,
11416
+ READ_INSTRUCTION_PROMPT_PLACEHOLDER,
11417
+ ),
9184
11418
  );
9185
- } catch (_e) {
9186
- void _e;
9187
- typed = undefined;
11419
+ } catch {
11420
+ instruction = "";
9188
11421
  }
9189
- instruction = sanitizeWriteInstruction(typed);
9190
11422
  }
9191
- // No input dialog, a cancelled dialog, or empty text → plain denial
9192
- // below. A cancel is never an implicit allow.
9193
- }
9194
-
9195
- // The answer covers every gated write of this turn.
9196
- writeGateTurnAnswer = { choice, instruction };
9197
- // …and the denial is remembered for the REST OF THE RUN, under every
9198
- // target of this turn: a later retry is auto-denied with the same
9199
- // sentence instead of asking the user again.
9200
- for (const item of items) {
9201
- writeGateRunDenials.set(item.key, { instruction });
9202
11423
  }
9203
-
9204
- const reason = buildWriteDenyReason({
9205
- target: items.length > 1 ? writeGateTargetsLabel(items) : target,
9206
- why: classification.reason,
9207
- instruction,
9208
- });
9209
-
9210
- if (instruction) {
9211
- // Channel 2: a user-role message. `steer` queues it while the agent
9212
- // is running; it lands after the current tool batch, before the next
9213
- // model call, as a genuine user turn. ONE per turn, not one per
9214
- // blocked call.
9215
- deliverWriteInstruction(items, instruction);
9216
- // Audit trail — transcript only (appendEntry is never sent to the
9217
- // LLM). One entry per turn: it records the user's decision, not each
9218
- // call the decision was applied to.
9219
- try {
9220
- pi.appendEntry("workflow-write-denial", {
9221
- tool: String(event.toolName ?? ""),
9222
- target,
9223
- instruction,
9224
- at: Date.now(),
9225
- targets: items.map((item) => item.target),
9226
- });
9227
- } catch (_e) {
9228
- void _e;
9229
- }
11424
+ readGateTurnAnswer = { choice: "deny", instruction };
11425
+ for (const item of items) readGateRunDenials.set(item.key, { instruction });
11426
+ if (instruction)
11427
+ deliverReadInstruction(
11428
+ instruction,
11429
+ readGateTargetsLabel(items) || "the requested read",
11430
+ );
11431
+ try {
11432
+ pi.appendEntry("workflow-read-denial", {
11433
+ tool: String(event.toolName ?? ""),
11434
+ target: readGateTargetsLabel(items),
11435
+ instruction,
11436
+ at: Date.now(),
11437
+ });
11438
+ } catch (_e) {
11439
+ void _e;
9230
11440
  }
9231
-
9232
11441
  return {
9233
11442
  block: true,
9234
- reason,
9235
- // NOTE: no `terminate` here. pi consults `terminate` only AFTER the
9236
- // whole tool batch has run (`executeToolCallsParallel` prepares and
9237
- // runs every call before `shouldTerminateToolBatch()`), so it cannot
9238
- // suppress a sibling write in the same batch — it only decides
9239
- // whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
9240
- // would end the run with no model acknowledgement at all: a
9241
- // behaviour change nobody asked for. The guided path gets its next
9242
- // turn from the queued steer message instead.
11443
+ reason: buildReadDenyReason({
11444
+ kind: "outside",
11445
+ target: readGateTargetsLabel(items) || "the requested read",
11446
+ instruction,
11447
+ }),
9243
11448
  };
9244
- } catch (e: unknown) {
9245
- // Fail closed: a broken policy read must not become an open door.
11449
+ } catch (error: unknown) {
9246
11450
  return {
9247
11451
  block: true,
9248
11452
  reason:
9249
- `Write policy (workflow.writePolicy) could not be evaluated (${e instanceof Error ? e.message : String(e)}), so the write was blocked. ` +
9250
- "Do not retry. Ask the user to switch to Build mode (/build) or to fix the policy with /write-policy.",
11453
+ `Read policy could not be evaluated (${error instanceof Error ? error.message : String(error)}), so the read was blocked. ` +
11454
+ "Do not retry; ask the user to fix /read-policy or /folder-policy.",
9251
11455
  };
9252
11456
  }
9253
11457
  }
@@ -9385,15 +11589,19 @@ export default function workflowExtension(
9385
11589
  reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
9386
11590
  } as any;
9387
11591
  }
9388
- // ── Write permission gate (workflow.writePolicy) ── Independent of the
9389
- // role gate and of the commit policy: it governs whether the USER
9390
- // permits a write at all. It only applies in Default mode — Plan has its
9391
- // own stricter rules and Build is the approved-execution escape hatch —
9392
- // and it runs last so it only sees calls nothing else claimed.
9393
- if (workflowMode === null) {
11592
+ // ── Write permission gate (workflow.writePolicy + shared folder floor) ──
11593
+ // The write MODE is a Default-mode knob; the folder floor applies in every
11594
+ // mode. It runs after the role/commit rules and before the build-todo
11595
+ // guard so a folder/sensitive refusal wins.
11596
+ {
9394
11597
  const writeBlock = await writePolicyGate(event, ctx, cwd);
9395
11598
  if (writeBlock) return writeBlock as any;
9396
11599
  }
11600
+ // ── Read permission gate (workflow.readPolicy + shared folder floor) ──
11601
+ {
11602
+ const readBlock = await readPolicyGate(event, ctx, cwd);
11603
+ if (readBlock) return readBlock as any;
11604
+ }
9397
11605
  // In build mode, ensure todos exist before first real edit
9398
11606
  if (
9399
11607
  (event.toolName === "edit" || event.toolName === "write") &&
@@ -9455,7 +11663,73 @@ export default function workflowExtension(
9455
11663
  seedCommittedStepRefs();
9456
11664
  const completed = uncommittedDoneSteps();
9457
11665
  const dirty = await isDirty(pi as any, cwd);
9458
- const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
11666
+ const trusted = ctx?.isProjectTrusted?.() === true;
11667
+ const commitConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
11668
+ trusted,
11669
+ }).config;
11670
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
11671
+ trusted,
11672
+ }).config;
11673
+ const repoRoot = detectRepoRoot(cwd) ?? cwd;
11674
+ const folder = evaluateFolderTargets(
11675
+ [{ path: repoRoot, kind: "dir" }],
11676
+ {
11677
+ config: folderConfig,
11678
+ grants: commitConfig.allowedPaths,
11679
+ cwd,
11680
+ action: "commit",
11681
+ },
11682
+ );
11683
+ if (folder.outside.length > 0) {
11684
+ ctx?.ui?.notify?.(
11685
+ `Per-step commit skipped: ${repoRoot} is outside the permitted folders (use /folder-policy or grant it first).`,
11686
+ "warning",
11687
+ );
11688
+ return;
11689
+ }
11690
+ const status = runGitZ(repoRoot, [
11691
+ "status",
11692
+ "--porcelain=v1",
11693
+ "-z",
11694
+ "--untracked-files=all",
11695
+ ]);
11696
+ if (!status.ok) {
11697
+ ctx?.ui?.notify?.(
11698
+ `Per-step commit skipped: sensitive-file check could not run (${status.error ?? "git status failed"}).`,
11699
+ "warning",
11700
+ );
11701
+ return;
11702
+ }
11703
+ const sensitive = status.paths
11704
+ .map((p) => matchSensitivePath(p, folderConfig, repoRoot))
11705
+ .find((hit) => hit.sensitive);
11706
+ if (sensitive) {
11707
+ ctx?.ui?.notify?.(
11708
+ `Per-step commit skipped: ${sensitive.path} is sensitive. Remove it from the working tree/index or add a manual sensitiveExemptions entry.`,
11709
+ "warning",
11710
+ );
11711
+ return;
11712
+ }
11713
+ const statusFolder = evaluateFolderTargets(
11714
+ status.paths.map((p) => ({
11715
+ path: path.join(repoRoot, p),
11716
+ kind: "file" as const,
11717
+ })),
11718
+ {
11719
+ config: folderConfig,
11720
+ grants: commitConfig.allowedPaths,
11721
+ cwd,
11722
+ action: "commit",
11723
+ },
11724
+ );
11725
+ if (statusFolder.outside.length > 0 || statusFolder.uncertain.length > 0) {
11726
+ ctx?.ui?.notify?.(
11727
+ `Per-step commit skipped: staged paths leave the permitted folders or could not be verified. Review the working tree and commit manually.`,
11728
+ "warning",
11729
+ );
11730
+ return;
11731
+ }
11732
+ const mode = commitConfig.mode;
9459
11733
  const decision = stepCommitDecision({
9460
11734
  mode,
9461
11735
  hasTodos: todoItems.length > 0,
@@ -9512,6 +11786,44 @@ export default function workflowExtension(
9512
11786
  }
9513
11787
  }
9514
11788
 
11789
+ // Output redaction is defence in depth for channels the tool gate cannot
11790
+ // fully see (grep --hidden, git diff/show, truncated bash output).
11791
+ pi.on("tool_result", async (event, ctx) => {
11792
+ try {
11793
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
11794
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
11795
+ trusted: (ctx as any)?.isProjectTrusted?.() === true,
11796
+ }).config;
11797
+ return redactToolResult(event as any, folderConfig, cwd) as any;
11798
+ } catch (error: unknown) {
11799
+ const tool = String((event as any)?.toolName ?? "");
11800
+ if (
11801
+ [
11802
+ "read",
11803
+ "read_symbol",
11804
+ "read_enclosing",
11805
+ "module_report",
11806
+ "grep",
11807
+ "find",
11808
+ "ls",
11809
+ "bash",
11810
+ "powershell",
11811
+ ].includes(tool)
11812
+ ) {
11813
+ return {
11814
+ content: [
11815
+ {
11816
+ type: "text",
11817
+ text: `[read policy redaction failed — output withheld: ${error instanceof Error ? error.message : String(error)}]`,
11818
+ },
11819
+ ],
11820
+ structuredContent: undefined,
11821
+ } as any;
11822
+ }
11823
+ return undefined;
11824
+ }
11825
+ });
11826
+
9515
11827
  pi.on("tool_result", async (_event, ctx) => {
9516
11828
  try {
9517
11829
  const leaf =
@@ -9564,6 +11876,10 @@ export default function workflowExtension(
9564
11876
  writeGateBatchCallIds = callIds;
9565
11877
  writeGateTurnAnswer = null;
9566
11878
  writeGateTurnSteered = false;
11879
+ readGateBatchCalls = calls;
11880
+ readGateBatchCallIds = callIds;
11881
+ readGateTurnAnswer = null;
11882
+ readGateTurnSteered = false;
9567
11883
  } catch (_e) {
9568
11884
  void _e;
9569
11885
  }
@@ -9577,6 +11893,12 @@ export default function workflowExtension(
9577
11893
  writeGateTurnSteered = false;
9578
11894
  writeGateBatchCalls = [];
9579
11895
  writeGateBatchCallIds = new Set();
11896
+ readGateTurnAnswer = null;
11897
+ readGateTurnSteered = false;
11898
+ readGateBatchCalls = [];
11899
+ readGateBatchCallIds = new Set();
11900
+ readGateTurnGrantedRoots.clear();
11901
+ readGateTurnUncertainAllowed.clear();
9580
11902
  // Seed the per-step commit ledger once per session: steps already done
9581
11903
  // (restored sessions, re-executed plans) never trigger a spurious commit.
9582
11904
  try {
@@ -9627,6 +11949,13 @@ export default function workflowExtension(
9627
11949
  writeGateTurnSteered = false;
9628
11950
  writeGateBatchCalls = [];
9629
11951
  writeGateBatchCallIds = new Set();
11952
+ readGateRunDenials.clear();
11953
+ readGateTurnAnswer = null;
11954
+ readGateTurnSteered = false;
11955
+ readGateBatchCalls = [];
11956
+ readGateBatchCallIds = new Set();
11957
+ readGateTurnGrantedRoots.clear();
11958
+ readGateTurnUncertainAllowed.clear();
9630
11959
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
9631
11960
  });
9632
11961
 
@@ -9675,12 +12004,47 @@ export default function workflowExtension(
9675
12004
  pi.on("before_agent_start", async (event, ctx) => {
9676
12005
  currentSessionCtx = ctx as any;
9677
12006
  ensurePlannotatorListeners();
12007
+ // Safety valve for a prompt that bypassed `input` (host queue, another
12008
+ // extension): never let a Review turn run concurrently with a plan/build
12009
+ // turn. Stop the reviewer first, then let the queued prompt proceed.
12010
+ if (reviewTurnOwns(reviewTurn) && reviewTurn !== "transition") {
12011
+ // Invalidate the parked handoff BEFORE settling its gate: otherwise the
12012
+ // runHandoff continuation can observe the old cycle token and render the
12013
+ // abandoned plan alongside the new prompt.
12014
+ reviewCycleId += 1;
12015
+ try {
12016
+ await suspendReview();
12017
+ } catch (_e) {
12018
+ void _e;
12019
+ }
12020
+ endReviewTurn();
12021
+ notifyReview(
12022
+ "Review stopped — a prompt was already queued; the plan was not reviewed.",
12023
+ "info",
12024
+ );
12025
+ }
9678
12026
  // Fresh user prompt → reset the passive reminder cadence. Continuation runs
9679
12027
  // (triggerTurn/steer/followUp) do not fire this event, so state survives them.
9680
12028
  turnsSinceLastTodoWrite = 0;
9681
12029
  turnsSinceLastReminder = 0;
9682
12030
  mutatedSinceLastTodoWrite = false;
9683
12031
  footerInjectedThisTurn = false;
12032
+ // A real user prompt means the user has moved on from a finished plan:
12033
+ // hide the one-line todo panel. `custom` runs (triggerTurn) must not do
12034
+ // this — they can be self-initiated while the user is still reading.
12035
+ if (
12036
+ event.messageType === "user" &&
12037
+ isTodoListSettled(todoItems) &&
12038
+ !todoPanelPromptAfterSettle
12039
+ ) {
12040
+ todoPanelPromptAfterSettle = true;
12041
+ updateStatus(ctx);
12042
+ try {
12043
+ persistState();
12044
+ } catch (_e) {
12045
+ void _e;
12046
+ }
12047
+ }
9684
12048
  // A fresh user prompt starts a new handoff/provenance run. It also
9685
12049
  // supersedes any pending browser/inline decision immediately: the user
9686
12050
  // moved on, so a stale tab must not approve the previous plan. Follow-up
@@ -9705,10 +12069,14 @@ export default function workflowExtension(
9705
12069
  }
9706
12070
  // Every fresh prompt is a new review cycle: reset the round budget, cancel
9707
12071
  // an abandoned pause/gate, and tag the cycle so an old handoff cannot
9708
- // render after this prompt.
12072
+ // render after this prompt. A feedback/refine continuation keeps the
12073
+ // manual cycle alive; a genuine user prompt ends it.
12074
+ const feedbackTurn = reviewFeedbackTurn;
12075
+ reviewFeedbackTurn = false;
9709
12076
  try {
12077
+ if (!feedbackTurn) endReviewTurn();
9710
12078
  await beginReviewCycle({
9711
- reason: "prompt",
12079
+ reason: feedbackTurn ? "refine" : "prompt",
9712
12080
  keepFindings: true,
9713
12081
  planPath: currentPlanFile,
9714
12082
  });
@@ -9895,6 +12263,25 @@ export default function workflowExtension(
9895
12263
  /** Plan path/hash the currently rendered handoff prompt belongs to. */
9896
12264
  let pendingHandoffPlanPath: string | null = null;
9897
12265
  let pendingHandoffPlanHash: string | null = null;
12266
+ let pendingHandoffRequestId: string | null = null;
12267
+ let pendingHandoffSource: ReviewHandoffSource | null = null;
12268
+ let pendingHandoffCycleId = 0;
12269
+ let pendingHandoffReviewUrl: string | null = null;
12270
+ let pendingHandoffPlanTextLen = 0;
12271
+ /** The one in-flight `plannotator:request` waiter; settles exactly once. */
12272
+ let pendingPlanReviewRequest:
12273
+ | {
12274
+ requestId: string;
12275
+ settled: boolean;
12276
+ finish: (outcome: PlanReviewRequestOutcome) => void;
12277
+ }
12278
+ | undefined;
12279
+ /** The review id already recorded in the transcript (one entry per review). */
12280
+ let recordedHandoffReviewId: string | null = null;
12281
+ /** A manual `/review` owns the following feedback cycle (even when auto is off). */
12282
+ let manualReviewCycle = false;
12283
+ /** One reload recovery at a time (session_start may re-enter). */
12284
+ let recoveryInFlight: Promise<void> | null = null;
9898
12285
  /** Last path announced by the suppressed-handoff notice (per run). */
9899
12286
  let lastPlanReadyNoticeKey: string | null = null;
9900
12287
 
@@ -9919,18 +12306,88 @@ export default function workflowExtension(
9919
12306
  * switch supersedes it; stale browser events must not approve the old plan.
9920
12307
  * Returns true when persisted state changed so the caller can persist once.
9921
12308
  */
12309
+ /** One `/review-status` row for a browser handoff awaiting a decision. */
12310
+ function pendingHandoffStatusLine(): string {
12311
+ if (!awaitingDecision) return "";
12312
+ const age = lastHandoffAt
12313
+ ? Math.max(0, Math.round((Date.now() - lastHandoffAt) / 1000))
12314
+ : 0;
12315
+ return ` handoff : browser pending — ${lastReviewId ?? "review id unknown"}${
12316
+ pendingHandoffReviewUrl ? ` @ ${pendingHandoffReviewUrl}` : ""
12317
+ } (${pendingHandoffSource ?? "auto"}, ${age}s)`;
12318
+ }
12319
+
9922
12320
  function clearPendingHandoffDecision(): boolean {
9923
12321
  const changed =
9924
12322
  awaitingDecision === true ||
9925
12323
  lastHandoffAt !== undefined ||
9926
12324
  lastReviewId !== null ||
9927
12325
  pendingHandoffPlanPath !== null ||
9928
- pendingHandoffPlanHash !== null;
12326
+ pendingHandoffPlanHash !== null ||
12327
+ pendingHandoffRequestId !== null ||
12328
+ pendingHandoffSource !== null ||
12329
+ pendingHandoffCycleId !== 0 ||
12330
+ pendingHandoffReviewUrl !== null;
9929
12331
  awaitingDecision = false;
9930
12332
  lastHandoffAt = undefined;
9931
12333
  lastReviewId = null;
9932
12334
  pendingHandoffPlanPath = null;
9933
12335
  pendingHandoffPlanHash = null;
12336
+ pendingHandoffRequestId = null;
12337
+ pendingHandoffSource = null;
12338
+ pendingHandoffCycleId = 0;
12339
+ pendingHandoffReviewUrl = null;
12340
+ pendingHandoffPlanTextLen = 0;
12341
+ recordedHandoffReviewId = null;
12342
+ return changed;
12343
+ }
12344
+
12345
+ /**
12346
+ * Drop a gate that cannot belong to the round/plan about to start.
12347
+ *
12348
+ * A new automatic round, a manual `/review`, or a browser decision arriving
12349
+ * while another round runs all supersede the old gate. A duplicate event for
12350
+ * the same plan outside a new round must not clear a legitimate pending
12351
+ * prompt, so the pure `pendingHandoffIsStale` predicate decides.
12352
+ */
12353
+ function supersedePendingHandoff(
12354
+ reason: string,
12355
+ opts: {
12356
+ force?: boolean;
12357
+ nextCycleId?: number;
12358
+ nextPlanHash?: string;
12359
+ } = {},
12360
+ ): boolean {
12361
+ if (
12362
+ !pendingHandoffIsStale({
12363
+ awaiting: awaitingDecision,
12364
+ force: opts.force,
12365
+ pendingCycleId:
12366
+ pendingHandoffCycleId > 0 ? pendingHandoffCycleId : undefined,
12367
+ currentCycleId: opts.nextCycleId ?? reviewCycleId,
12368
+ pendingPlanHash: pendingHandoffPlanHash ?? undefined,
12369
+ nextPlanHash: opts.nextPlanHash,
12370
+ })
12371
+ ) {
12372
+ return false;
12373
+ }
12374
+ const changed = clearPendingHandoffDecision();
12375
+ if (changed) {
12376
+ notifyReview(`Plan handoff superseded — ${reason}.`, "info");
12377
+ try {
12378
+ pi.appendEntry("workflow-handoff-superseded", {
12379
+ reason,
12380
+ at: Date.now(),
12381
+ });
12382
+ } catch (_e) {
12383
+ void _e;
12384
+ }
12385
+ try {
12386
+ persistState();
12387
+ } catch (_e) {
12388
+ void _e;
12389
+ }
12390
+ }
9934
12391
  return changed;
9935
12392
  }
9936
12393
 
@@ -10232,26 +12689,36 @@ export default function workflowExtension(
10232
12689
  }
10233
12690
  // Build completion
10234
12691
  if (workflowMode === "build" && todoItems.length > 0) {
10235
- const allDone = todoItems.every(
10236
- (t) => isDone(t) || itemStatus(t) === "cancelled",
10237
- );
12692
+ const allDone = isTodoListSettled(todoItems);
10238
12693
  if (allDone) {
10239
- try {
10240
- (pi as any).sendMessage?.(
10241
- {
10242
- customType: "workflow-complete",
10243
- content: `**Build Complete! ✓**\n\n${todoItems.map((t) => `~~${todoRef(t)}. ${t.text}~~`).join("\n")}`,
10244
- display: true,
10245
- },
10246
- { triggerTurn: false },
10247
- );
10248
- } catch (_e) {
10249
- void _e;
12694
+ // One card per list revision: a settled list stays settled across
12695
+ // turns, and re-sending the same card on every agent_end buried the
12696
+ // transcript in duplicates. A new/renamed list or changed statuses
12697
+ // produce a new signature and therefore a new card.
12698
+ const signature = todoCompletionSignature(todoItems);
12699
+ if (signature !== lastCompletedSignature) {
12700
+ lastCompletedSignature = signature;
12701
+ try {
12702
+ (pi as any).sendMessage?.(
12703
+ {
12704
+ customType: "workflow-complete",
12705
+ content: `**Build Complete! ✓**\n\n${todoItems.map((t) => `~~${todoRef(t)}. ${t.text}~~`).join("\n")}`,
12706
+ display: true,
12707
+ },
12708
+ { triggerTurn: false },
12709
+ );
12710
+ } catch (_e) {
12711
+ void _e;
12712
+ }
10250
12713
  }
10251
12714
  // keep todos for history but stay in build
10252
12715
  todoStale = false;
10253
12716
  updateStatus(ctx as any);
10254
12717
  persistState();
12718
+ } else if (lastCompletedSignature !== null) {
12719
+ // The list reopened (new step or a reverted status): allow the next
12720
+ // completion to emit its own card. Persisted on the next mutation.
12721
+ lastCompletedSignature = null;
10255
12722
  }
10256
12723
  return;
10257
12724
  }
@@ -10462,17 +12929,21 @@ export default function workflowExtension(
10462
12929
  void _e;
10463
12930
  }
10464
12931
  };
10465
- if (planTextForRender && reviewEnabled()) {
12932
+ const reviewThisCycle = reviewEnabled() || manualReviewCycle;
12933
+ if (planTextForRender && reviewThisCycle) {
10466
12934
  const authorPlan: string = planTextForRender;
10467
12935
  reviewHandoffGate.reset();
10468
12936
  reviewGatePhase = "auditing";
10469
12937
  reviewGateCycleId = handoffCycle;
12938
+ // Review owns the turn before the reviewer boots. A plan handoff with
12939
+ // Review Mode off never reaches this block (no Review turn).
12940
+ beginReviewTurn();
10470
12941
  try {
10471
12942
  const reviewed = await runReviewStep(
10472
12943
  authorPlan,
10473
12944
  ctx as ExtensionContext,
10474
12945
  (ctx as any).signal as AbortSignal | undefined,
10475
- "auto",
12946
+ manualReviewCycle ? "manual" : "auto",
10476
12947
  );
10477
12948
  if (handoffCycle !== reviewCycleId) return;
10478
12949
  const reviewedText = reviewed.planText;
@@ -10587,114 +13058,15 @@ export default function workflowExtension(
10587
13058
  if (planTextForRender && hasUI && !awaitingDecision) {
10588
13059
  // ── Plannotator webview branch (browser review) ──────────
10589
13060
  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
13061
+ const handoff = await requestPlanReview({
13062
+ planText: planTextForRender,
13063
+ planPath,
13064
+ source: "auto",
13065
+ ctx: ctx as ExtensionContext,
13066
+ cycleId: handoffCycle,
13067
+ });
13068
+ if (handoff.ok) return;
13069
+ // The helper already cleared the gate; fall through to inline.
10698
13070
  }
10699
13071
  awaitingDecision = true;
10700
13072
  lastHandoffAt = Date.now();