@narumitw/pi-sync 0.49.6 → 0.49.9

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
@@ -54,22 +54,57 @@ Remote status: Not checked
54
54
  ```
55
55
 
56
56
  Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
57
- **More…**. The standard manager also owns setup and connection lists/details plus history and
58
- recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes the flow.
57
+ **More…**. When pi-sync has already detected a synced-content-list mismatch, the manager instead
58
+ shows **Sync status: Review needed**, puts **Review synced content (recommended)** first, and keeps
59
+ **Sync now** visibly unavailable until the choice is reviewed. Opening the manager still performs no
60
+ new remote request. The standard manager also owns setup and connection lists/details plus history
61
+ and recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes the flow.
59
62
  Destructive, credential-bearing, and externally visible operations retain exact specialized previews
60
63
  and confirmations.
61
64
 
65
+ ### Restore sync access
66
+
67
+ When an operation is currently running, the manager shows its command and process ID, keeps sync and
68
+ settings changes unavailable, and puts **Refresh operation status** first.
69
+ When lock metadata is still protected by an active guard, the manager explains that another pi-sync
70
+ process may be starting or finishing and asks the user to wait before refreshing.
71
+ It never offers lock removal while an owner or guard may still be active.
72
+
73
+ When a previous operation appears to have stopped without releasing its local lock, the manager shows
74
+ **Sync paused** and puts **Restore sync access… (recommended)** first instead of hiding recovery
75
+ inside **History & recovery…**.
76
+ Unreadable metadata receives a stronger warning because pi-sync cannot verify who owns it.
77
+ Close every other Pi session that may still be syncing, review the local-lock-only confirmation, then
78
+ choose **Remove local lock and continue**.
79
+ The guarded recovery path rechecks metadata and ownership before removal, changes no settings, local
80
+ files, sync state, or remote data, and returns directly to the normal manager when access is restored.
81
+ Cancellation leaves the lock unchanged.
82
+ If ownership changes or a guard is still expiring, pi-sync refuses removal and keeps actionable status
83
+ available for refresh or retry.
84
+ The deterministic fallback remains `/sync unlock --stale` after the same no-other-sync verification.
85
+
62
86
  ### Resolve conflicts in the manager
63
87
 
64
- When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
65
- direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
66
- error. The flow names the current sync setup and explains whether local content, remote content, or
67
- the included-content policy changed. If the active remote snapshot explicitly selects different
68
- content, automatic sync and pulls pause until you adopt the remote policy in Settings or publish the
69
- local policy with a reviewed force push.
88
+ When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only error.
89
+ The flow names the current sync setup and explains whether local content, remote content, or the included-content policy changed.
90
+
91
+ An explicit remote content-list mismatch now opens **Synced content differs** inside the same manager flow.
92
+ Nothing changes until you choose an action.
93
+ Choose **Review all paths (recommended)** first to compare exact remote-only paths, device-only paths, and both ordered lists.
94
+ If membership matches but order differs, the review says that only ordering differs.
95
+ Then choose one action:
96
+
97
+ - **Use remote content list** revalidates the remote snapshot and reviewed local setup before saving only `sync.include`.
98
+ **Remote content list saved** confirms that no files were pulled and offers a separate **Continue Sync/Pull/Push now…** action plus **Done**.
99
+ Continue starts a fresh operation and exact preview for the captured setup rather than resuming stale work.
100
+ - **Keep this device's content list and update remote…** opens the existing `push --force` preparation and exact confirmation without `--yes`.
101
+ Cancelling preparation or confirmation returns to the content-list choice without changing remote data.
102
+ - **Cancel** returns to the manager without changing settings, files, remote data, or sync state.
103
+ - **Later** appears when automatic startup sync detected the mismatch and returns immediately to the
104
+ Pi editor without changing anything.
70
105
 
71
- Choose **Review differences (recommended)** to inspect the exact affected paths without changing
72
- local files, remote data, or sync state. Then choose one reviewed direction:
106
+ Choose **Review differences (recommended)** in an ordinary file-direction conflict to inspect the exact affected paths without changing local files, remote data, or sync state.
107
+ Then choose one reviewed direction:
73
108
 
74
109
  - **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
75
110
  local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
@@ -79,10 +114,22 @@ local files, remote data, or sync state. Then choose one reviewed direction:
79
114
  - First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
80
115
  empty remote offers **Push local content…** only.
81
116
 
82
- Cancelling a preparation or confirmation returns to conflict resolution with no side effects. Back
83
- returns to the sync manager; Ctrl+C closes the complete flow. Automatic startup/shutdown sync never
84
- opens this interactive flow and continues to report an actionable warning. Deterministic direct
85
- routes remain available and keep their command-oriented error guidance.
117
+ Cancelling a preparation or confirmation returns to conflict resolution with no side effects.
118
+ Back returns to the sync manager, and Ctrl+C closes the complete flow.
119
+
120
+ When automatic startup sync detects an explicit content-list mismatch in TUI mode, it opens
121
+ **Synced content differs** once for that session instead of ending at a warning. Choosing **Later** or
122
+ closing the flow leaves a compact **Pi Sync needs review** widget above the editor and a
123
+ **review needed** footer status. The attention state is in memory only, contains no credentials or
124
+ file contents, and is revalidated before any settings or remote mutation. It clears after verified
125
+ resolution, invalidation, session replacement, or shutdown.
126
+
127
+ Interactive TUI `/sync sync`, `/sync pull`, and `/sync push` routes without `--yes` open the same
128
+ review flow when they detect the mismatch. Explicit `--yes` routes remain non-interactive and report
129
+ exact remote-only, device-only, or order-only guidance while leaving visible attention for later
130
+ review. Shutdown automatic sync never opens a dialog because Pi is exiting.
131
+ RPC startup and direct routes remain read-only and notification-based, while print and JSON modes
132
+ remain unsupported for `/sync` because their UI output is not observable.
86
133
 
87
134
  ## ⚙️ Settings
88
135
 
@@ -203,17 +250,16 @@ remote snapshot. Leaving the editor opens an exact Include/Exclude review with *
203
250
  the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
204
251
  the manual `sync.include` path.
205
252
 
206
- Every new snapshot stores the normalized included-content selection separately from the files that
207
- happened to exist. This preserves selected-but-missing paths without syncing `pi-sync.json`, storage
208
- credentials, automatic-sync preferences, or setup names. **Settings → Remote included content** reads
209
- the active snapshot and offers **Adopt remote included content**, **Keep local included content**,
210
- **Review paths**, and **Cancel** when local and remote selections differ. Adoption revalidates the
211
- remote head, saves local settings atomically, and does not pull files or write sync state; review
212
- **Sync now** separately. A reviewed force push keeps local selection and explicitly replaces the
213
- remote policy while preserving eligible unmanaged remote files.
214
-
215
- Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather
216
- than silently expanding local scope. Status reports matches and exact local-only/remote-only paths.
253
+ Every new snapshot stores the normalized included-content selection separately from the files that happened to exist.
254
+ This preserves selected-but-missing paths without syncing `pi-sync.json`, storage credentials, automatic-sync preferences, or setup names.
255
+ **Settings → Compare synced content** opens the same review-first flow as a manager operation when local and remote lists differ.
256
+ Adoption revalidates the remote head, immutable snapshot, reviewed storage coordinates, and local include list before one atomic settings update.
257
+ The saved state changes only `sync.include`, preserves unknown settings fields, and never pulls files or writes sync state.
258
+ Its explicit Continue action starts a fresh **Sync now** route, while **Done** leaves the reviewed settings change saved without implying a file operation.
259
+ Keeping this device's list opens the reviewed force-push path and explicitly replaces the remote policy while preserving eligible unmanaged remote files.
260
+
261
+ Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather than silently expanding local scope.
262
+ Status reports matches and exact local-only/remote-only paths.
217
263
  Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
218
264
  clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
219
265
  preserved-unmanaged intent cannot be reconstructed. Use **Add custom path…** for any needed path.
@@ -297,7 +343,7 @@ The operational state root is `<agent-dir>/pi-sync/`. An existing installation c
297
343
 
298
344
  ## ♿ Conditional terminal accessibility smoke
299
345
 
300
- Pi exposes terminal components rather than a semantic/ARIA tree. Release validation checks textual state, keyboard operation, Escape/Back, control escaping, and narrow rendering. Critical meaning appears in text such as `(current)`, `Warning`, `Invalid`, `Saved`, `Cancelled`, and `Applied`; color is supplementary.
346
+ Pi exposes terminal components rather than a semantic/ARIA tree. Release validation checks textual state, keyboard operation, Escape/Back, control escaping, and narrow rendering. Critical meaning appears in text such as `(current)`, `Review needed`, `Later`, `Warning`, `Invalid`, `Saved`, `Cancelled`, and `Applied`; color is supplementary. The attention widget is informational and does not pretend to be a clickable control.
301
347
 
302
348
  ## 🗂️ Package layout
303
349
 
@@ -313,6 +359,11 @@ packages/pi-sync/
313
359
  │ ├── state-directory.ts
314
360
  │ ├── settings-management.ts
315
361
  │ ├── manager-ui.ts
362
+ │ ├── manager-state.ts
363
+ │ ├── manager-recovery.ts
364
+ │ ├── operation-availability.ts
365
+ │ ├── manager-attention.ts
366
+ │ ├── sync-attention.ts
316
367
  │ ├── storage-connections-ui.ts
317
368
  │ ├── sync-setups-ui.ts
318
369
  │ ├── file-selection.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.49.6",
3
+ "version": "0.49.9",
4
4
  "description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "typecheck": "tsc --noEmit"
40
40
  },
41
41
  "dependencies": {
42
- "@narumitw/pi-tui-kit": "^0.49.1",
42
+ "@narumitw/pi-tui-kit": "^0.52.0",
43
43
  "fast-xml-parser": "^5.10.1",
44
44
  "proper-lockfile": "^4.1.2"
45
45
  },
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  BorderedLoader,
3
- type ExtensionCommandContext,
3
+ type ExtensionContext,
4
4
  type KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth } from "@earendil-works/pi-tui";
@@ -8,10 +8,12 @@ import { runCustomInteraction } from "@narumitw/pi-tui-kit";
8
8
  import { safeTerminalText } from "./manager-helpers.js";
9
9
  import type { SetupPullOutcome } from "./setup-switch.js";
10
10
  import type { SyncDecision } from "./sync-decision.js";
11
+ import type { RemoteSelectionDecision } from "./sync-policy.js";
11
12
 
12
13
  export type RunRouteResult =
13
14
  | { kind: "completed"; outcome?: SetupPullOutcome }
14
15
  | { kind: "decision-required"; decision: SyncDecision }
16
+ | { kind: "remote-selection-required"; decision: RemoteSelectionDecision }
15
17
  | { kind: "failed" };
16
18
 
17
19
  export type RunRoute = (
@@ -34,7 +36,7 @@ interface CancellableOperationOptions {
34
36
  }
35
37
 
36
38
  export async function runCancellableOperation(
37
- ctx: ExtensionCommandContext,
39
+ ctx: ExtensionContext,
38
40
  message: string,
39
41
  route: string,
40
42
  runRoute: RunRoute,
@@ -51,7 +53,10 @@ export async function runCancellableOperation(
51
53
  }
52
54
  let commitStarted = false;
53
55
  let routeResult: RunRouteResult | undefined;
54
- const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }>(ctx, {
56
+ const interaction = await runCustomInteraction<
57
+ { cancelled?: boolean; error?: unknown },
58
+ ExtensionContext
59
+ >(ctx, {
55
60
  signal,
56
61
  isCurrent: () => !signal?.aborted,
57
62
  create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
package/src/config.ts CHANGED
@@ -142,6 +142,10 @@ export function syncConfigReviewIdentity(config: AnySyncConfig) {
142
142
  ]);
143
143
  }
144
144
 
145
+ export function syncConfigReviewFingerprint(config: AnySyncConfig) {
146
+ return createHash("sha256").update(syncConfigReviewIdentity(config)).digest("hex");
147
+ }
148
+
145
149
  function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
146
150
  return {
147
151
  connectionName: config.connectionName,
package/src/lock.ts CHANGED
@@ -152,7 +152,9 @@ export function isStaleLock(lock: LockFile) {
152
152
  }
153
153
 
154
154
  export async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
155
+ throwIfAborted(options.signal);
155
156
  await ensureStateDir();
157
+ throwIfAborted(options.signal);
156
158
  let guard: Guard;
157
159
  try {
158
160
  guard = await acquireGuard();
@@ -179,7 +181,9 @@ export async function unlock(ctx: ExtensionCommandContext, options: CommandOptio
179
181
  }
180
182
 
181
183
  async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptions) {
184
+ throwIfAborted(options.signal);
182
185
  let inspection = await inspectLock();
186
+ throwIfAborted(options.signal);
183
187
  if (inspection.status === "missing") {
184
188
  ctx.ui.notify("No pi-sync lock is present.", "info");
185
189
  return;
@@ -193,12 +197,19 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
193
197
  return;
194
198
  }
195
199
  inspection = await inspectLock();
200
+ throwIfAborted(options.signal);
196
201
  if (inspection.status === "unreadable") {
197
202
  // Legacy writers expose an empty file before writing owner metadata, so no
198
203
  // automatic test can prove this file is abandoned. The explicit --stale
199
204
  // flag is the user's confirmation that no legacy sync is still running.
205
+ throwIfAborted(options.signal);
200
206
  await fs.rm(lockPath(), { force: true });
201
- ctx.ui.notify("Removed unreadable pi-sync lock.", "info");
207
+ if (!options.signal?.aborted) {
208
+ ctx.ui.notify(
209
+ "Removed unreadable pi-sync lock. No settings, files, sync state, or remote data were changed.",
210
+ "info",
211
+ );
212
+ }
202
213
  return;
203
214
  }
204
215
  if (inspection.status === "missing") {
@@ -210,8 +221,21 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
210
221
  ctx.ui.notify("Lock owner is still live; refusing to remove it.", "warning");
211
222
  return;
212
223
  }
224
+ throwIfAborted(options.signal);
213
225
  await fs.rm(lockPath(), { force: true });
214
- ctx.ui.notify("Removed stale pi-sync lock.", "info");
226
+ if (!options.signal?.aborted) {
227
+ ctx.ui.notify(
228
+ "Removed stale pi-sync lock. No settings, files, sync state, or remote data were changed.",
229
+ "info",
230
+ );
231
+ }
232
+ }
233
+
234
+ function throwIfAborted(signal?: AbortSignal) {
235
+ if (!signal?.aborted) return;
236
+ throw signal.reason instanceof Error
237
+ ? signal.reason
238
+ : new DOMException("The operation was aborted", "AbortError");
215
239
  }
216
240
 
217
241
  async function releaseGuard(guard: Guard) {
@@ -0,0 +1,81 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ActionMenuItem } from "@narumitw/pi-tui-kit";
3
+ import type { RunRoute } from "./cancellable-operation.js";
4
+ import { dispatchManagerResult } from "./manager-result-dispatcher.js";
5
+ import type { ManagerDescription } from "./manager-state.js";
6
+ import type { SyncAttentionState } from "./sync-attention.js";
7
+
8
+ export interface SyncManagerAttentionOptions {
9
+ getAttention?: () => SyncAttentionState | undefined;
10
+ onSelectionResolved?: (expected: SyncAttentionState) => void;
11
+ }
12
+
13
+ export function attentionMainMenuItems(
14
+ manager: ManagerDescription,
15
+ ): ActionMenuItem<"main" | "more" | "recovery", "review-attention">[] {
16
+ if (!manager.attention) return [];
17
+ const disabled = manager.attentionReviewDisabled === true;
18
+ return [
19
+ {
20
+ id: "review-attention",
21
+ label: "Review synced content (recommended)",
22
+ action: "review-attention",
23
+ ...(disabled
24
+ ? {
25
+ disabled: true,
26
+ disabledReason: "Finish or recover the active operation first.",
27
+ }
28
+ : {}),
29
+ },
30
+ ];
31
+ }
32
+
33
+ export function blockedSyncMenuItem(
34
+ label: string,
35
+ manager: ManagerDescription,
36
+ ): ActionMenuItem<"main" | "more" | "recovery", "sync"> | undefined {
37
+ if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return undefined;
38
+ return {
39
+ id: "sync",
40
+ label,
41
+ description: "Review first.",
42
+ action: "sync",
43
+ disabled: true,
44
+ disabledReason: "Review synced content first.",
45
+ };
46
+ }
47
+
48
+ export async function showManagerAttention(
49
+ ctx: ExtensionCommandContext,
50
+ attention: SyncAttentionState,
51
+ runRoute: RunRoute,
52
+ signal: AbortSignal | undefined,
53
+ onSelectionResolved: (() => void) | undefined,
54
+ ): Promise<"close" | "stay"> {
55
+ const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
56
+ if (signal?.aborted) return "close";
57
+ const review = await showRemoteSelectionReview(
58
+ ctx,
59
+ attention.decision.setupName,
60
+ signal,
61
+ undefined,
62
+ {
63
+ decision: attention.decision,
64
+ origin: attention.origin,
65
+ runRoute,
66
+ onSelectionResolved,
67
+ },
68
+ );
69
+ if (review.kind === "route-result") {
70
+ const disposition = await dispatchManagerResult(
71
+ ctx,
72
+ review.result,
73
+ review.route,
74
+ runRoute,
75
+ signal,
76
+ { onSelectionResolved },
77
+ );
78
+ return disposition.kind;
79
+ }
80
+ return review.kind === "closed" || review.kind === "stale" ? "close" : "stay";
81
+ }
@@ -0,0 +1,65 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { runConfirmation } from "@narumitw/pi-tui-kit";
3
+ import type { RunRoute } from "./cancellable-operation.js";
4
+ import { errorMessage, safeTerminalText } from "./manager-helpers.js";
5
+ import type { ManagerDescription } from "./manager-state.js";
6
+ import { inspectOperationAvailability, operationCanRecover } from "./operation-availability.js";
7
+
8
+ export type RecoveryDisposition = "restored" | "stay" | "close";
9
+
10
+ export async function recoverSyncAccess(
11
+ ctx: ExtensionCommandContext,
12
+ manager: ManagerDescription,
13
+ runRoute: RunRoute,
14
+ sessionSignal: AbortSignal | undefined,
15
+ actionSignal: AbortSignal,
16
+ ): Promise<RecoveryDisposition> {
17
+ const operation = manager.operation;
18
+ if (!operation || !operationCanRecover(operation)) {
19
+ ctx.ui.notify(
20
+ "Operation status changed. Refresh the manager before retrying recovery.",
21
+ "warning",
22
+ );
23
+ return "stay";
24
+ }
25
+ const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
26
+ if (signal.aborted) return "close";
27
+ const unreadable = operation.kind === "recoverable-unreadable";
28
+ const details = unreadable
29
+ ? "Pi-sync cannot verify who owns the unreadable lock. Close other Pi sessions that may be syncing before continuing."
30
+ : `The recorded ${safeTerminalText(operation.lock.command)} operation (pid ${operation.lock.pid}) appears to have stopped. Close other Pi sessions that may still be syncing before continuing.`;
31
+ const confirmation = await runConfirmation(ctx, {
32
+ title: "Restore sync access?",
33
+ message: [
34
+ details,
35
+ "",
36
+ "This removes only the local operation lock.",
37
+ "It does not change settings, local files, sync state, or remote data.",
38
+ ].join("\n"),
39
+ confirmLabel: "Remove local lock and continue",
40
+ cancelLabel: "Cancel",
41
+ signal,
42
+ isCurrent: () => !signal.aborted,
43
+ onError: (_currentCtx, error) => {
44
+ ctx.ui.notify(
45
+ `Recovery confirmation failed: ${safeTerminalText(errorMessage(error))}`,
46
+ "error",
47
+ );
48
+ },
49
+ });
50
+ if (confirmation.kind === "stale") return "close";
51
+ if (confirmation.kind === "closed") {
52
+ if (confirmation.reason === "close") return "close";
53
+ if (!signal.aborted) {
54
+ ctx.ui.notify("Recovery cancelled; the local operation lock was not changed.", "info");
55
+ }
56
+ return "stay";
57
+ }
58
+ if (confirmation.kind !== "confirmed") return "stay";
59
+ if (signal.aborted) return "close";
60
+ await runRoute(unreadable ? "unlock --stale" : "unlock", signal);
61
+ if (signal.aborted) return "close";
62
+ const latest = await inspectOperationAvailability();
63
+ if (signal.aborted) return "close";
64
+ return latest.kind === "free" ? "restored" : "stay";
65
+ }
@@ -0,0 +1,93 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ CancellableOperationResult,
4
+ RunRoute,
5
+ RunRouteResult,
6
+ } from "./cancellable-operation.js";
7
+ import { type RemoteSelectionOrigin, showRemoteSelectionReview } from "./remote-selection-ui.js";
8
+ import { showSyncResolution } from "./sync-resolution-ui.js";
9
+
10
+ const MAX_DECISION_TRANSITIONS = 32;
11
+
12
+ export interface ManagerResultDisposition {
13
+ kind: "close" | "stay";
14
+ appliedRoute?: "sync" | "pull" | "push";
15
+ }
16
+
17
+ export interface ManagerResultOptions {
18
+ cancelLabel?: string;
19
+ onSelectionResolved?: () => void;
20
+ withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
21
+ }
22
+
23
+ export async function dispatchManagerResult(
24
+ ctx: ExtensionContext,
25
+ initialResult: CancellableOperationResult,
26
+ origin: Exclude<RemoteSelectionOrigin, "settings">,
27
+ runRoute: RunRoute,
28
+ signal?: AbortSignal,
29
+ options: ManagerResultOptions = {},
30
+ ): Promise<ManagerResultDisposition> {
31
+ let result: CancellableOperationResult | RunRouteResult = initialResult;
32
+ let currentRoute: "sync" | "pull" | "push" = origin;
33
+ let resolving = false;
34
+
35
+ for (let transition = 0; transition < MAX_DECISION_TRANSITIONS; transition += 1) {
36
+ if (signal?.aborted) return { kind: "close" };
37
+ if (result.kind === "remote-selection-required") {
38
+ resolving = true;
39
+ const resolution = await showRemoteSelectionReview(
40
+ ctx,
41
+ result.decision.setupName,
42
+ signal,
43
+ undefined,
44
+ {
45
+ decision: result.decision,
46
+ origin: currentRoute,
47
+ runRoute,
48
+ cancelLabel: options.cancelLabel,
49
+ onSelectionResolved: options.onSelectionResolved,
50
+ withStateAccess: options.withStateAccess,
51
+ },
52
+ );
53
+ if (resolution.kind === "route-result") {
54
+ result = resolution.result;
55
+ currentRoute = resolution.route;
56
+ continue;
57
+ }
58
+ return resolution.kind === "closed" || resolution.kind === "stale"
59
+ ? { kind: "close" }
60
+ : { kind: "stay" };
61
+ }
62
+ if (result.kind === "decision-required") {
63
+ resolving = true;
64
+ const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
65
+ if (resolution.kind === "route-result") {
66
+ result = resolution.result;
67
+ currentRoute = resolution.route;
68
+ continue;
69
+ }
70
+ if (resolution.kind === "resolved") {
71
+ return { kind: "close", appliedRoute: resolution.direction };
72
+ }
73
+ return resolution.kind === "closed" || resolution.kind === "stale"
74
+ ? { kind: "close" }
75
+ : { kind: "stay" };
76
+ }
77
+ if (result.kind === "closed") return { kind: "close" };
78
+ if (result.kind === "completed") {
79
+ const appliedRoute = result.outcome === "applied" ? currentRoute : undefined;
80
+ if (resolving || (origin === "pull" && result.outcome === "applied")) {
81
+ return { kind: "close", ...(appliedRoute ? { appliedRoute } : {}) };
82
+ }
83
+ return { kind: "stay", ...(appliedRoute ? { appliedRoute } : {}) };
84
+ }
85
+ return { kind: "stay" };
86
+ }
87
+
88
+ ctx.ui.notify(
89
+ "Sync resolution stopped after too many state changes. Start /sync again to review fresh state.",
90
+ "warning",
91
+ );
92
+ return { kind: "stay" };
93
+ }