pi-goal-x 0.20.1 → 0.21.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/README.md CHANGED
@@ -84,6 +84,7 @@ If the objective is already final and should start immediately, use:
84
84
  /goal-status Show focused goal state
85
85
  /goal-list List all open goals in .pi/goals/
86
86
  /goal-focus Choose this session's focused goal
87
+ /goal-unfocus Stop this session's goal work without modifying the shared goal
87
88
  /goal-tweak <change> Draft a revision to the focused active/paused goal
88
89
  /goal-pause Pause the focused active goal
89
90
  /goal-resume Resume a paused goal
@@ -104,7 +105,7 @@ Pressing `Esc` or aborting an active run pauses the goal so it does not remain f
104
105
  - **Branch-local focus**: because focus is reconstructed from the current session branch, `/tree` navigation can restore a different focus for a different branch.
105
106
  - **One continuation chain**: auto-continue only schedules work for the focused goal in the current session.
106
107
 
107
- Creating a goal with `/goals`, `/sisyphus`, `/goals-set`, or `/sisyphus-set` no longer clears other open goals. It creates a new active goal file and focuses it. Use `/goal-list` to inspect open goals and `/goal-focus` to switch the session focus. If the latest focus entry explicitly clears focus, or points at a missing/stale goal, a remaining single open goal is not auto-focused. By default (`autoSelectSingleGoal: false`) sessions start unfocused so goals stay session-scoped — useful when multiple sessions share the same `.pi/goals/` directory. Set `autoSelectSingleGoal: true` to restore the old behavior where a single open goal is auto-focused when no focus entry exists at all. If multiple open goals exist and the session has no valid focus, `/goal-resume`, `/goal-clear`, `/goal-abort`, `/goal-pause`, and `/goal-tweak` ask the user to choose a goal instead of acting on all of them.
108
+ Creating a goal with `/goals`, `/sisyphus`, `/goals-set`, or `/sisyphus-set` no longer clears other open goals. It creates a new active goal file and focuses it. Use `/goal-list` to inspect open goals, `/goal-focus` to switch the session focus, and `/goal-unfocus` to detach the current session without pausing, modifying, archiving, or recording a project-ledger focus change for the shared goal. Unfocus also aborts in-flight work and audits owned by that session; asynchronous lifecycle results are discarded if focus changed while they were pending. If the latest focus entry explicitly clears focus, or points at a missing/stale goal, a remaining single open goal is not auto-focused and resume does not prompt to replace that explicit choice. By default (`autoSelectSingleGoal: false`) sessions start unfocused so focus stays session-scoped — useful when multiple sessions share the same `.pi/goals/` directory. Set `autoSelectSingleGoal: true` to restore the old behavior where a single open goal is auto-focused when no focus entry exists at all. If multiple open goals exist and the session has no valid focus, `/goal-resume`, `/goal-clear`, `/goal-abort`, `/goal-pause`, and `/goal-tweak` ask the user to choose a goal instead of acting on all of them.
108
109
 
109
110
  ## Agent tools
110
111
 
@@ -77,6 +77,8 @@ let focusedGoalId: string | null = null;
77
77
  | `goalWorkToolCalledThisTurn` | empty-turn guard:只有本 turn 做了有意义工作才继续 auto-continue。 |
78
78
  | `turnStoppedFor` | pause/abort/complete/tweak 后阻止同一 turn 继续乱调用工具。 |
79
79
  | `postCompactReminderPending` | session compact 后给下一轮 agent 注入 deterministic resync prompt。 |
80
+ | `focusRevision` | session focus 改变时递增;异步 completion/tweak/task proposal 在写入前必须验证 revision,避免 unfocus 后迟到结果修改共享 goal。 |
81
+ | `hasExplicitSessionFocus` | 区分“从未选择 focus”和显式 null focus,保证 resume/tree 不覆盖 `/goal-unfocus`。 |
80
82
  | `activeGetGoalTurnsByGoalId` | 统计重复 `get_goal`,用于 soft nudge,不是 hard block。 |
81
83
 
82
84
  ## 4. 持久化:Goal 文件与 Ledger
@@ -140,6 +142,7 @@ ledger append 是 best-effort:写入失败不应该让用户的生命周期动
140
142
  | `/sisyphus-set <objective>` | 直接创建并启动 Sisyphus goal,不进入 draft discussion。 |
141
143
  | `/goal-list` | 列出 `.pi/goals/` 下所有 open goals。 |
142
144
  | `/goal-focus` | 让用户选择当前 session focus。 |
145
+ | `/goal-unfocus` | 清除当前 session focus,终止该 session 的 continuation/in-flight work/audit,并用 revision gate 丢弃迟到异步结果;不暂停、修改、归档共享 goal,也不写 project-global focus event。 |
143
146
  | `/goal-status` / `/goal` | 显示 focused goal 状态和其他 open goals 提示。 |
144
147
  | `/goal-tweak <change>` | 修改 focused goal,但也要先走 tweak drafting。 |
145
148
  | `/goal-pause` | 用户暂停 focused active goal。 |
@@ -68,6 +68,7 @@ Reusable logic is split into smaller modules:
68
68
  ├─ multiple open goals
69
69
  │ ├─ /goal-list shows the project goal pool
70
70
  │ ├─ /goal-focus chooses the session focus
71
+ │ ├─ /goal-unfocus clears only the session focus and leaves the shared goal open
71
72
  │ └─ unfocused sessions guide the user to choose instead of letting the agent decide
72
73
  │
73
74
  └─ /goal-clear or /goal-abort archives the focused goal or cancels drafting
@@ -89,7 +90,7 @@ Session focus is separate. Focus changes append a custom session entry:
89
90
  {
90
91
  version: 1,
91
92
  focusedGoalId: string | null,
92
- reason: "created" | "selected" | "resumed" | "completed" | "cleared" | "aborted" | "migrated"
93
+ reason: "created" | "selected" | "unfocused" | "resumed" | "completed" | "cleared" | "aborted" | "migrated"
93
94
  }
94
95
  ```
95
96
 
@@ -98,8 +99,8 @@ Because this is stored with `pi.appendEntry("pi-goal-focus", ...)`, it is sessio
98
99
  1. Use a valid focused id from the latest focus entry.
99
100
  2. If the latest focus entry explicitly has `focusedGoalId: null`, or points at a missing/stale goal, remain unfocused.
100
101
  3. If no focus entry exists, merge a compatible legacy `pi-goal-state { version: 3, goal }` goal and focus it. If disk already has the same id, the disk record wins and the legacy session record only supplies focus.
101
- 4. If no focus entry exists and exactly one open goal exists, auto-focus it for compatibility.
102
- 5. If multiple open goals exist and no valid focus exists, remain unfocused until `/goal-focus`, `/goal-resume`, `/goal-clear`, `/goal-abort`, `/goal-pause`, or `/goal-tweak` asks the user to choose.
102
+ 4. If no focus entry exists and `autoSelectSingleGoal` is enabled, auto-focus the sole open goal for compatibility. The default is disabled.
103
+ 5. Otherwise remain unfocused until the user explicitly selects a goal. `/goal-unfocus` appends a null focus entry so the current session stays detached without modifying the shared goal or appending a project-global focus event. Resume and tree reconstruction preserve that explicit null focus.
103
104
 
104
105
  Focus is human-owned. No agent tool can switch focus. Lifecycle tools operate only on the focused goal.
105
106
 
@@ -137,6 +138,7 @@ A deprecated optional `draftId` parameter is accepted for compatibility but igno
137
138
  - `/goals-set` and `/sisyphus-set` directly create and focus a new open goal from the supplied objective.
138
139
  - `/goal-list` prints all open goals with id, status, mode, usage, objective title, path, and a focus marker.
139
140
  - `/goal-focus` uses `ctx.ui.select` when multiple goals are open and updates only session focus.
141
+ - `/goal-unfocus` writes a null session focus entry, clears continuation/runtime state, aborts in-flight work and audits for that session, and leaves the shared active goal file and project-global focus ledger unchanged. Focus revision tokens prevent pending completion, tweak, and task-list confirmation results from mutating a goal after detachment.
140
142
  - `/goal-status` and `/goal` show the focused goal plus an `other open goals` hint.
141
143
  - `/goal-resume` resumes the focused paused goal; when unfocused with multiple open goals, it asks the user to choose. Choosing an already active goal only focuses it.
142
144
  - `/goal-clear` and `/goal-abort` archive only the focused/selected goal and never clear the whole pool at once.
@@ -2,7 +2,7 @@ export type GoalStatus = "active" | "paused" | "complete";
2
2
  export type StopReason = "user" | "agent";
3
3
  export type GoalEventKind = "checkpoint" | "stale" | "drafting";
4
4
  export type DraftingFocus = "goal" | "sisyphus";
5
- export type GoalFocusReason = "created" | "selected" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
5
+ export type GoalFocusReason = "created" | "selected" | "unfocused" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
6
6
 
7
7
  export type TaskStatus = "pending" | "complete" | "skipped";
8
8
 
@@ -146,7 +146,7 @@ export function normalizeGoalFocusEntry(value: unknown): GoalFocusEntry | null {
146
146
  ? safeIdPart(raw.focusedGoalId)
147
147
  : null;
148
148
  const reason: GoalFocusReason =
149
- raw.reason === "created" || raw.reason === "selected" || raw.reason === "resumed" || raw.reason === "completed" || raw.reason === "cleared" || raw.reason === "aborted" || raw.reason === "migrated"
149
+ raw.reason === "created" || raw.reason === "selected" || raw.reason === "unfocused" || raw.reason === "resumed" || raw.reason === "completed" || raw.reason === "cleared" || raw.reason === "aborted" || raw.reason === "migrated"
150
150
  ? raw.reason
151
151
  : "selected";
152
152
  return { version: 1, focusedGoalId, reason };
@@ -388,9 +388,39 @@ function isMeaningfulProgressToolCall(toolName: string, args: unknown): boolean
388
388
 
389
389
  // ---------- extension entry point ----------
390
390
 
391
- export default function goalExtension(pi: ExtensionAPI): void {
391
+ export default function goalExtension(
392
+ pi: ExtensionAPI,
393
+ dependencies: { runCompletionAuditor?: typeof runGoalCompletionAuditor } = {},
394
+ ): void {
392
395
  let goalsById = new Map<string, GoalRecord>();
393
396
  let focusedGoalId: string | null = null;
397
+ let focusRevision = 0;
398
+ let hasExplicitSessionFocus = false;
399
+
400
+ function assignFocusedGoalId(next: string | null): void {
401
+ if (focusedGoalId !== next) focusRevision += 1;
402
+ focusedGoalId = next;
403
+ }
404
+
405
+ function focusedOperationToken(goalId: string): { goalId: string; revision: number } {
406
+ return { goalId, revision: focusRevision };
407
+ }
408
+
409
+ function isFocusedOperationCurrent(token: { goalId: string; revision: number }): boolean {
410
+ return focusedGoalId === token.goalId && focusRevision === token.revision;
411
+ }
412
+
413
+ function focusedOperationCancelledResult(action: string, token: { goalId: string; revision: number }) {
414
+ return {
415
+ content: [{
416
+ type: "text" as const,
417
+ text: `${action} cancelled because goal ${token.goalId} is no longer focused in this session. The shared goal was not modified.`,
418
+ }],
419
+ details: goalDetails(state.goal),
420
+ terminate: true,
421
+ };
422
+ }
423
+
394
424
  const state = {
395
425
  get goal(): GoalRecord | null {
396
426
  return focusedGoalFromPool(goalsById, focusedGoalId);
@@ -398,11 +428,11 @@ export default function goalExtension(pi: ExtensionAPI): void {
398
428
  set goal(next: GoalRecord | null) {
399
429
  if (next) {
400
430
  goalsById.set(next.id, next);
401
- focusedGoalId = next.id;
431
+ assignFocusedGoalId(next.id);
402
432
  return;
403
433
  }
404
434
  if (focusedGoalId) goalsById.delete(focusedGoalId);
405
- focusedGoalId = null;
435
+ assignFocusedGoalId(null);
406
436
  },
407
437
  };
408
438
  let continuationQueuedFor: string | null = null;
@@ -602,11 +632,11 @@ export default function goalExtension(pi: ExtensionAPI): void {
602
632
  if (current && !current.activePath) {
603
633
  goalsById = fresh;
604
634
  goalsById.set(current.id, current);
605
- focusedGoalId = current.id;
635
+ assignFocusedGoalId(current.id);
606
636
  return true;
607
637
  }
608
638
  goalsById = fresh;
609
- focusedGoalId = null;
639
+ assignFocusedGoalId(null);
610
640
  clearStoppedRuntimeState();
611
641
  if (current) resetGetGoalNudgeState(current.id);
612
642
  if (tweakDraftingFor !== null) tweakDraftingFor = null;
@@ -619,19 +649,25 @@ export default function goalExtension(pi: ExtensionAPI): void {
619
649
  : diskGoal;
620
650
  goalsById = fresh;
621
651
  goalsById.set(reconciled.id, reconciled);
622
- focusedGoalId = reconciled.id;
652
+ assignFocusedGoalId(reconciled.id);
623
653
  if (reconciled.status !== "active" || !reconciled.autoContinue) clearContinuationState();
624
654
  if (reconciled.status !== "active") clearActiveAccounting();
625
655
  return true;
626
656
  }
627
657
 
628
658
  function appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void {
659
+ hasExplicitSessionFocus = true;
629
660
  pi.appendEntry(FOCUS_ENTRY, goalFocusDetails(goalId, reason));
630
661
  }
631
662
 
632
- function setFocusedGoalId(goalId: string | null, ctx: ExtensionContext, reason: GoalFocusReason): void {
663
+ function setFocusedGoalId(
664
+ goalId: string | null,
665
+ ctx: ExtensionContext,
666
+ reason: GoalFocusReason,
667
+ opts: { recordLedger?: boolean } = {},
668
+ ): void {
633
669
  const previousGoalId = focusedGoalId;
634
- focusedGoalId = goalId && goalsById.has(goalId) ? goalId : null;
670
+ assignFocusedGoalId(goalId && goalsById.has(goalId) ? goalId : null);
635
671
  if (previousGoalId !== focusedGoalId) {
636
672
  clearContinuationState();
637
673
  clearActiveAccounting();
@@ -642,9 +678,9 @@ export default function goalExtension(pi: ExtensionAPI): void {
642
678
  appendFocusEntry(focusedGoalId, reason);
643
679
  // Append ledger event for focus changes
644
680
  try {
645
- if (focusedGoalId) {
681
+ if (opts.recordLedger !== false && focusedGoalId) {
646
682
  appendGoalEvent(ctx, { type: "goal_focused", goalId: focusedGoalId, reason, at: nowIso() });
647
- } else if (previousGoalId) {
683
+ } else if (opts.recordLedger !== false && previousGoalId) {
648
684
  appendGoalEvent(ctx, { type: "goal_unfocused", reason, at: nowIso() });
649
685
  }
650
686
  } catch {
@@ -657,7 +693,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
657
693
  function updateFocusedGoal(next: GoalRecord, ctx: ExtensionContext, shouldPersist = true): void {
658
694
  const previousGoalId = focusedGoalId;
659
695
  goalsById.set(next.id, next);
660
- focusedGoalId = next.id;
696
+ assignFocusedGoalId(next.id);
661
697
  if (previousGoalId !== focusedGoalId) {
662
698
  resetGetGoalNudgeState(previousGoalId);
663
699
  resetGetGoalNudgeState(focusedGoalId);
@@ -675,7 +711,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
675
711
  function removeFocusedGoal(ctx: ExtensionContext, reason: GoalFocusReason): void {
676
712
  const previousGoalId = focusedGoalId;
677
713
  if (focusedGoalId) goalsById.delete(focusedGoalId);
678
- focusedGoalId = null;
714
+ assignFocusedGoalId(null);
679
715
  clearStoppedRuntimeState();
680
716
  resetGetGoalNudgeState(previousGoalId);
681
717
  appendFocusEntry(null, reason);
@@ -859,7 +895,9 @@ export default function goalExtension(pi: ExtensionAPI): void {
859
895
 
860
896
  function loadState(ctx: ExtensionContext): void {
861
897
  goalsById = readActiveGoalPool(ctx);
862
- focusedGoalId = null;
898
+ focusRevision += 1; // Session reload/tree navigation invalidates pending async focus operations.
899
+ assignFocusedGoalId(null);
900
+ hasExplicitSessionFocus = false;
863
901
  let focusEntry: GoalFocusEntry | null = null;
864
902
  let legacyGoal: GoalRecord | null = null;
865
903
  let legacyStateSeen = false;
@@ -880,7 +918,8 @@ export default function goalExtension(pi: ExtensionAPI): void {
880
918
  legacyGoal = sanitizeGoalPaths(ctx, mergeGoalPromptFromDisk(ctx, legacyGoal));
881
919
  }
882
920
  const settings = loadGoalSettings(ctx.cwd);
883
- focusedGoalId = resolveSessionFocus({ pool: goalsById, focusEntry, legacyGoal, autoSelectSingleGoal: settings.autoSelectSingleGoal });
921
+ hasExplicitSessionFocus = focusEntry !== null;
922
+ assignFocusedGoalId(resolveSessionFocus({ pool: goalsById, focusEntry, legacyGoal, autoSelectSingleGoal: settings.autoSelectSingleGoal }));
884
923
  if (!focusEntry && focusedGoalId) {
885
924
  try {
886
925
  appendFocusEntry(focusedGoalId, legacyGoal?.id === focusedGoalId ? "migrated" : "selected");
@@ -1038,7 +1077,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
1038
1077
  state.goal = null;
1039
1078
  if (focusedGoalId === prevId) {
1040
1079
  goalsById.delete(prevId);
1041
- focusedGoalId = null;
1080
+ assignFocusedGoalId(null);
1042
1081
  }
1043
1082
  clearStoppedRuntimeState();
1044
1083
  syncGoalTools();
@@ -1503,6 +1542,34 @@ Verification contract:
1503
1542
  ctx.ui.notify(`Focused goal: ${oneLineSummary(state.goal)}`, "info");
1504
1543
  }
1505
1544
 
1545
+ function unfocusGoalCommand(ctx: ExtensionContext): void {
1546
+ const runtimeGoalId = state.goal?.id ?? runningGoalId ?? checkpointGoalId;
1547
+ reconcileFocusedGoalFromDisk(ctx);
1548
+ const current = state.goal;
1549
+ const detachedGoalId = current?.id ?? runtimeGoalId;
1550
+ let wasBusy = false;
1551
+ try {
1552
+ wasBusy = !ctx.isIdle();
1553
+ } catch {}
1554
+ if (detachedGoalId && wasBusy) turnStoppedFor = { goalId: detachedGoalId, turnSeq };
1555
+ setFocusedGoalId(null, ctx, "unfocused", { recordLedger: false });
1556
+ runningGoalId = null;
1557
+ checkpointGoalId = null;
1558
+ postCompactReminderPending = false;
1559
+ if (auditAbortController) auditAbortController.abort();
1560
+ if (detachedGoalId && wasBusy) {
1561
+ try {
1562
+ ctx.abort?.();
1563
+ } catch {}
1564
+ }
1565
+ if (!current) {
1566
+ const openCount = openGoals().length;
1567
+ ctx.ui.notify(openCount > 0 ? buildUnfocusedOpenGoalsSummary(openCount) : detailedSummary(null), "info");
1568
+ return;
1569
+ }
1570
+ ctx.ui.notify(`Goal unfocused for this session. It remains open in .pi/goals: ${current.id}`, "info");
1571
+ }
1572
+
1506
1573
  async function handleGoalCommandTopic(rawTopic: string, ctx: ExtensionContext, focus: DraftingFocus, opts: { replace: boolean }): Promise<void> {
1507
1574
  const topic = rawTopic.trim();
1508
1575
  if (opts.replace) {
@@ -1761,7 +1828,7 @@ Verification contract:
1761
1828
  },
1762
1829
  };
1763
1830
  pi.registerCommand("goal", {
1764
- description: "Show focused goal status. Discuss with /goals or /sisyphus; direct-start with /goals-set or /sisyphus-set; manage with /goal-list, /goal-focus, /goal-settings, /goal-tweak, /goal-clear, /goal-abort, /goal-pause, /goal-resume.",
1831
+ description: "Show focused goal status. Discuss with /goals or /sisyphus; direct-start with /goals-set or /sisyphus-set; manage with /goal-list, /goal-focus, /goal-unfocus, /goal-settings, /goal-tweak, /goal-clear, /goal-abort, /goal-pause, /goal-resume.",
1765
1832
  handler: statusCommand.handler,
1766
1833
  });
1767
1834
  pi.registerCommand("goal-status", statusCommand);
@@ -1779,6 +1846,12 @@ Verification contract:
1779
1846
  await focusGoalCommand(ctx);
1780
1847
  },
1781
1848
  });
1849
+ pi.registerCommand("goal-unfocus", {
1850
+ description: "Stop focusing the current goal in this session without modifying or archiving the shared goal.",
1851
+ handler: async (_rawArgs, ctx) => {
1852
+ unfocusGoalCommand(ctx);
1853
+ },
1854
+ });
1782
1855
  pi.registerCommand("goal-settings", {
1783
1856
  description: "Open pi-goal settings, including auditor provider/model/thinking_level.",
1784
1857
  handler: async (_rawArgs, ctx) => {
@@ -2240,6 +2313,7 @@ ${objective}` : objective,
2240
2313
  });
2241
2314
 
2242
2315
  const headless = shouldAutoConfirmProposal({ hasUI: ctx.hasUI, autoConfirmEnv: process.env.PI_GOAL_AUTO_CONFIRM });
2316
+ const tweakFocus = focusedOperationToken(state.goal.id);
2243
2317
 
2244
2318
  let decision: { decision: "confirm" | "continue"; auditorEnabled: boolean };
2245
2319
  if (headless) {
@@ -2256,6 +2330,9 @@ ${objective}` : objective,
2256
2330
  };
2257
2331
  }
2258
2332
  }
2333
+ if (!isFocusedOperationCurrent(tweakFocus)) {
2334
+ return focusedOperationCancelledResult("Goal tweak", tweakFocus);
2335
+ }
2259
2336
 
2260
2337
  if (decision.decision === "confirm") {
2261
2338
  // Persist any auditor toggle change
@@ -2418,6 +2495,7 @@ ${objective}` : objective,
2418
2495
  }
2419
2496
 
2420
2497
  const auditTarget = mergeGoalPromptFromDisk(ctx, state.goal);
2498
+ const completionFocus = focusedOperationToken(auditTarget.id);
2421
2499
  // Append ledger: completion requested
2422
2500
  try {
2423
2501
  appendGoalEvent(ctx, {
@@ -2562,6 +2640,9 @@ ${objective}` : objective,
2562
2640
  display: true,
2563
2641
  details: { phase: "started", goalId: auditTarget.id, auditor: auditorLabel },
2564
2642
  }, { triggerTurn: true });
2643
+ if (!isFocusedOperationCurrent(completionFocus)) {
2644
+ return focusedOperationCancelledResult("Goal completion", completionFocus);
2645
+ }
2565
2646
  // Append ledger: audit started
2566
2647
  try {
2567
2648
  appendGoalEvent(ctx, {
@@ -2596,16 +2677,17 @@ ${objective}` : objective,
2596
2677
 
2597
2678
  // Create a dedicated AbortController for the audit so it can be interrupted via Escape
2598
2679
  auditAbortController?.abort(); // Clean up any stale controller
2599
- auditAbortController = new AbortController();
2680
+ const completionAuditController = new AbortController();
2681
+ auditAbortController = completionAuditController;
2600
2682
 
2601
- const auditor = await runGoalCompletionAuditor({
2683
+ const auditor = await (dependencies.runCompletionAuditor ?? runGoalCompletionAuditor)({
2602
2684
  ctx,
2603
2685
  goal: auditTarget,
2604
2686
  completionSummary: params.completionSummary,
2605
2687
  detailedSummary: detailedSummary(auditTarget),
2606
2688
  verificationSummary: params.verificationSummary,
2607
2689
  settings: loadGoalSettings(ctx.cwd),
2608
- signal: auditAbortController.signal,
2690
+ signal: completionAuditController.signal,
2609
2691
  onProgress: (progress) => {
2610
2692
  auditProgress = {
2611
2693
  ...progress,
@@ -2615,9 +2697,14 @@ ${objective}` : objective,
2615
2697
  },
2616
2698
  });
2617
2699
  // Clear abort controller — audit finished on its own
2618
- auditAbortController = null;
2700
+ if (auditAbortController === completionAuditController) auditAbortController = null;
2619
2701
  // Clear auditor progress display
2620
2702
  stopAuditAnimation();
2703
+ if (!isFocusedOperationCurrent(completionFocus)) {
2704
+ auditProgress = null;
2705
+ goalWidgetComponent?.invalidate();
2706
+ return focusedOperationCancelledResult("Goal completion", completionFocus);
2707
+ }
2621
2708
 
2622
2709
  // If the audit was aborted by the user (Esc), show a TUI dialog letting
2623
2710
  // the user choose: mark complete without audit, or continue working.
@@ -2629,6 +2716,9 @@ ${objective}` : objective,
2629
2716
  showingEscapeDialog = true;
2630
2717
  const userChoice: EscapeDialogResult = await showEscapeDialog(ctx, auditTarget.objective);
2631
2718
  showingEscapeDialog = false;
2719
+ if (!isFocusedOperationCurrent(completionFocus)) {
2720
+ return focusedOperationCancelledResult("Goal completion", completionFocus);
2721
+ }
2632
2722
 
2633
2723
  if (userChoice === "complete_without_audit") {
2634
2724
  // ── Mark complete without audit ────────────────────────────
@@ -3053,8 +3143,12 @@ ${objective}` : objective,
3053
3143
  const taskLines = renderTaskLines(taskList.tasks);
3054
3144
  const gateLabel = blockCompletion ? " (blockCompletion enabled)" : "";
3055
3145
  const proposalText = [`Proposed task list${gateLabel}:`, "", ...taskLines].join("\n");
3146
+ const taskListFocus = focusedOperationToken(state.goal.id);
3056
3147
 
3057
3148
  const dialogResult = await showProposalDialog(ctx, proposalText, "goal", !state.goal?.skipAuditor);
3149
+ if (!isFocusedOperationCurrent(taskListFocus)) {
3150
+ return focusedOperationCancelledResult("Task list proposal", taskListFocus);
3151
+ }
3058
3152
  if (dialogResult.decision !== "confirm") {
3059
3153
  return {
3060
3154
  content: [{ type: "text", text: "Task list proposal declined." }],
@@ -3427,7 +3521,7 @@ promptGuidelines: [
3427
3521
  const archived = archiveGoalFile(ctx, completedGoal);
3428
3522
  resetGetGoalNudgeState(completedGoal.id);
3429
3523
  goalsById.delete(completedGoal.id);
3430
- focusedGoalId = null;
3524
+ assignFocusedGoalId(null);
3431
3525
  appendFocusEntry(null, "completed");
3432
3526
  syncGoalTools();
3433
3527
  updateUI(ctx);
@@ -3469,7 +3563,7 @@ promptGuidelines: [
3469
3563
  loadState(ctx);
3470
3564
  syncGoalTools();
3471
3565
  syncTerminalInputPause(ctx);
3472
- if (event.reason === "resume" && !state.goal && openGoals().length > 1 && ctx.hasUI) {
3566
+ if (event.reason === "resume" && !state.goal && !hasExplicitSessionFocus && openGoals().length > 1 && ctx.hasUI) {
3473
3567
  await focusGoalCommand(ctx);
3474
3568
  }
3475
3569
  // Codex behavior: prompt before reactivating a paused goal on resume.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Goal mode extension for pi: persistent long-running objectives, /goal-set drafting, Sisyphus prompt style, autoContinue, and an above-editor status overlay. Fork of @capyup/pi-goal.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",