@narumitw/pi-sync 0.46.0 → 0.48.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
@@ -58,6 +58,29 @@ recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes
58
58
  Destructive, credential-bearing, and externally visible operations retain exact specialized previews
59
59
  and confirmations.
60
60
 
61
+ ### Resolve conflicts in the manager
62
+
63
+ When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
64
+ direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
65
+ error. The flow names the current sync setup and explains whether local content, remote content, or
66
+ the included-content policy changed.
67
+
68
+ Choose **Review differences (recommended)** to inspect the exact affected paths without changing
69
+ local files, remote data, or sync state. Then choose one reviewed direction:
70
+
71
+ - **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
72
+ local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
73
+ and asks again if the reviewed plan changed.
74
+ - **Use remote content and replace local…** uses the existing forced-pull path. It shows exact local
75
+ writes and deletions, protects the live session, and creates a local backup before applying.
76
+ - First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
77
+ empty remote offers **Push local content…** only.
78
+
79
+ Cancelling a preparation or confirmation returns to conflict resolution with no side effects. Back
80
+ returns to the sync manager; Ctrl+C closes the complete flow. Automatic startup/shutdown sync never
81
+ opens this interactive flow and continues to report an actionable warning. Deterministic direct
82
+ routes remain available and keep their command-oriented error guidance.
83
+
61
84
  ## ⚙️ Settings
62
85
 
63
86
  The canonical private user file is:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
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",
@@ -7,15 +7,24 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
7
7
  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
+ import type { SyncDecision } from "./sync-decision.js";
11
+
12
+ export type RunRouteResult =
13
+ | { kind: "completed"; outcome?: SetupPullOutcome }
14
+ | { kind: "decision-required"; decision: SyncDecision }
15
+ | { kind: "failed" };
10
16
 
11
17
  export type RunRoute = (
12
18
  route: string,
13
19
  signal?: AbortSignal,
14
20
  onCommit?: () => void,
15
21
  target?: string,
16
- ) => Promise<SetupPullOutcome | undefined>;
22
+ ) => Promise<RunRouteResult | undefined>;
17
23
 
18
- export type CancellableOperationResult = SetupPullOutcome | "closed" | undefined;
24
+ export type CancellableOperationResult =
25
+ | RunRouteResult
26
+ | { kind: "closed" }
27
+ | { kind: "cancelled" };
19
28
 
20
29
  interface CancellableOperationOptions {
21
30
  commitAware?: boolean;
@@ -38,10 +47,10 @@ export async function runCancellableOperation(
38
47
  signal,
39
48
  } = options;
40
49
  if (ctx.mode !== "tui") {
41
- return await runRoute(route, undefined, undefined, target);
50
+ return (await runRoute(route, signal, undefined, target)) ?? { kind: "failed" };
42
51
  }
43
52
  let commitStarted = false;
44
- let routeResult: SetupPullOutcome | undefined;
53
+ let routeResult: RunRouteResult | undefined;
45
54
  const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }>(ctx, {
46
55
  signal,
47
56
  isCurrent: () => !signal?.aborted,
@@ -89,13 +98,13 @@ export async function runCancellableOperation(
89
98
  },
90
99
  });
91
100
  if (interaction.kind === "error") throw interaction.error;
92
- if (interaction.kind !== "completed") return "closed";
101
+ if (interaction.kind !== "completed") return { kind: "closed" };
93
102
  if (interaction.value.cancelled) {
94
103
  if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
95
- return "cancelled";
104
+ return { kind: "cancelled" };
96
105
  }
97
106
  if (interaction.value.error) throw interaction.value.error;
98
- return routeResult;
107
+ return routeResult ?? { kind: "failed" };
99
108
  }
100
109
 
101
110
  function keybindingText(
package/src/manager-ui.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
3
+ import {
4
+ type CancellableOperationResult,
5
+ type RunRoute,
6
+ runCancellableOperation,
7
+ } from "./cancellable-operation.js";
4
8
  import { setSyncSetupCompletions } from "./command.js";
5
9
  import {
6
10
  configuredSyncSetupNames,
@@ -33,6 +37,7 @@ import { showSyncSettings } from "./settings-ui.js";
33
37
  import { useSyncSetup } from "./setup-switch.js";
34
38
  import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
35
39
  import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
40
+ import { showSyncResolution } from "./sync-resolution-ui.js";
36
41
  import { showSyncSetups } from "./sync-setups-ui.js";
37
42
  import type { AnySyncConfig } from "./types.js";
38
43
  import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
@@ -120,7 +125,10 @@ export async function showSyncManager(
120
125
  signal: sessionSignal,
121
126
  },
122
127
  );
123
- return result === "closed" ? { kind: "close" } : { kind: "stay" };
128
+ const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
129
+ return result.kind === "closed" || resolution === "close"
130
+ ? { kind: "close" }
131
+ : { kind: "stay" };
124
132
  },
125
133
  switch: async () => {
126
134
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
@@ -136,7 +144,7 @@ export async function showSyncManager(
136
144
  runRoute,
137
145
  { signal: sessionSignal },
138
146
  );
139
- return result === "closed" ? { kind: "close" } : { kind: "stay" };
147
+ return result.kind === "closed" ? { kind: "close" } : { kind: "stay" };
140
148
  },
141
149
  settings: async () => {
142
150
  await showSyncSettings(ctx, runRoute, sessionSignal);
@@ -154,7 +162,12 @@ export async function showSyncManager(
154
162
  signal: sessionSignal,
155
163
  },
156
164
  );
157
- return result === "applied" || result === "closed" ? { kind: "close" } : { kind: "stay" };
165
+ const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
166
+ return result.kind === "closed" ||
167
+ (result.kind === "completed" && result.outcome === "applied") ||
168
+ resolution === "close"
169
+ ? { kind: "close" }
170
+ : { kind: "stay" };
158
171
  },
159
172
  push: async () => {
160
173
  const result = await runCancellableOperation(
@@ -168,7 +181,10 @@ export async function showSyncManager(
168
181
  signal: sessionSignal,
169
182
  },
170
183
  );
171
- return result === "closed" ? { kind: "close" } : { kind: "stay" };
184
+ const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
185
+ return result.kind === "closed" || resolution === "close"
186
+ ? { kind: "close" }
187
+ : { kind: "stay" };
172
188
  },
173
189
  setups: async () => {
174
190
  const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
@@ -218,6 +234,21 @@ export async function showSyncManager(
218
234
  });
219
235
  }
220
236
 
237
+ async function resolveManagerDecision(
238
+ ctx: ExtensionCommandContext,
239
+ result: CancellableOperationResult,
240
+ runRoute: RunRoute,
241
+ signal?: AbortSignal,
242
+ ) {
243
+ if (result.kind !== "decision-required") return undefined;
244
+ const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
245
+ return resolution.kind === "resolved" ||
246
+ resolution.kind === "closed" ||
247
+ resolution.kind === "stale"
248
+ ? "close"
249
+ : "stay";
250
+ }
251
+
221
252
  function syncMainMenuItem(
222
253
  label: string,
223
254
  ): ActionMenuItem<
@@ -522,11 +553,25 @@ async function showSetupSwitcher(
522
553
  signal,
523
554
  },
524
555
  );
525
- if (pullResult === "closed") {
556
+ if (pullResult.kind === "closed") {
526
557
  pullClosed = true;
527
558
  return undefined;
528
559
  }
529
- return pullResult;
560
+ if (pullResult.kind === "decision-required") {
561
+ const resolution = await showSyncResolution(ctx, pullResult.decision, runRoute, signal);
562
+ if (
563
+ resolution.kind === "resolved" ||
564
+ resolution.kind === "closed" ||
565
+ resolution.kind === "stale"
566
+ ) {
567
+ pullClosed = true;
568
+ }
569
+ return resolution.kind === "resolved" && resolution.direction === "pull"
570
+ ? "applied"
571
+ : undefined;
572
+ }
573
+ if (pullResult.kind === "completed") return pullResult.outcome;
574
+ return pullResult.kind === "cancelled" ? "cancelled" : undefined;
530
575
  },
531
576
  onSwitch,
532
577
  signal,
@@ -1,22 +1,17 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import type { RunRoute } from "./cancellable-operation.js";
3
4
  import { loadConfig, localConfigPath } from "./config.js";
4
5
  import { updateSyncSetup } from "./settings-management.js";
5
6
  import {
6
7
  SETUP_SWITCH_ACTION_OPTIONS,
7
- type SetupPullOutcome,
8
8
  saveOnSwitch,
9
9
  setupSwitchActionFromLabel,
10
10
  setupSwitchActionLabel,
11
11
  } from "./setup-switch.js";
12
12
  import { safeTerminalText } from "./sync-format.js";
13
13
 
14
- export type SyncSettingsRoute = (
15
- route: string,
16
- signal?: AbortSignal,
17
- onCommit?: () => void,
18
- setup?: string,
19
- ) => Promise<SetupPullOutcome | undefined>;
14
+ export type SyncSettingsRoute = RunRoute;
20
15
 
21
16
  export async function showSyncSettings(
22
17
  ctx: ExtensionCommandContext,
@@ -0,0 +1,113 @@
1
+ import { syncConfigReviewIdentity } from "./config.js";
2
+ import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
3
+ import { includeFromSelectionConfig } from "./sync-policy.js";
4
+ import { syncPolicyChanged } from "./sync-state.js";
5
+ import type { AnySyncConfig, Snapshot, SyncState } from "./types.js";
6
+
7
+ export type SyncDecisionKind =
8
+ | "remote-or-policy-changed"
9
+ | "both-changed"
10
+ | "first-sync-settings-diverged"
11
+ | "first-sync-sessions-diverged"
12
+ | "remote-empty";
13
+
14
+ export type SyncResolutionDirection = "push" | "pull";
15
+
16
+ export interface SyncDecision {
17
+ kind: SyncDecisionKind;
18
+ setupName: string;
19
+ configIdentity: string;
20
+ causes: {
21
+ localChanged: boolean;
22
+ remoteChanged: boolean;
23
+ policyChanged: boolean;
24
+ };
25
+ previousInclude?: readonly string[];
26
+ currentInclude: readonly string[];
27
+ review: string;
28
+ directions: readonly SyncResolutionDirection[];
29
+ directMessage: string;
30
+ }
31
+
32
+ export class SyncDecisionRequiredError extends Error {
33
+ readonly decision: SyncDecision;
34
+
35
+ constructor(decision: SyncDecision) {
36
+ super(decision.directMessage);
37
+ this.name = "SyncDecisionRequiredError";
38
+ this.decision = decision;
39
+ }
40
+ }
41
+
42
+ interface CreateSyncDecisionOptions {
43
+ kind: SyncDecisionKind;
44
+ config: AnySyncConfig;
45
+ state: SyncState;
46
+ local: Snapshot;
47
+ remote?: Snapshot;
48
+ localChanged: boolean;
49
+ remoteChanged: boolean;
50
+ directMessage: string;
51
+ }
52
+
53
+ export function createSyncDecision(options: CreateSyncDecisionOptions) {
54
+ const { config, state, local, remote, kind } = options;
55
+ const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config);
56
+ const previousInclude = includeFromSelectionConfig(state);
57
+ const currentInclude = [...config.include];
58
+ const causes = {
59
+ localChanged: options.localChanged,
60
+ remoteChanged: options.remoteChanged,
61
+ policyChanged,
62
+ };
63
+ const causeLines =
64
+ kind === "first-sync-settings-diverged"
65
+ ? ["This machine and the remote have different Pi settings on first sync."]
66
+ : kind === "first-sync-sessions-diverged"
67
+ ? ["Pi settings match, but local and remote sessions differ on first sync."]
68
+ : kind === "remote-empty"
69
+ ? ["The remote storage location is empty."]
70
+ : [
71
+ ...(causes.localChanged ? ["Local content changed since the last sync."] : []),
72
+ ...(causes.remoteChanged ? ["Remote content changed since the last sync."] : []),
73
+ ...(causes.policyChanged ? ["Included content changed since the last sync."] : []),
74
+ ];
75
+ const comparison = remote
76
+ ? formatDiff(local, remote)
77
+ : formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local);
78
+ const review = [
79
+ `Sync setup: ${safeTerminalText(config.setupName)}`,
80
+ "",
81
+ "Why a decision is required:",
82
+ ...causeLines,
83
+ ...(policyChanged
84
+ ? [
85
+ "",
86
+ `Previously included: ${safeList(previousInclude)}`,
87
+ `Currently included: ${safeList(currentInclude)}`,
88
+ ]
89
+ : []),
90
+ "",
91
+ "Observed differences:",
92
+ ...comparison.split("\n").map(safeTerminalText),
93
+ ].join("\n");
94
+ return new SyncDecisionRequiredError({
95
+ kind,
96
+ setupName: config.setupName,
97
+ configIdentity: syncConfigReviewIdentity(config),
98
+ causes,
99
+ ...(policyChanged ? { previousInclude: [...previousInclude] } : {}),
100
+ currentInclude,
101
+ review,
102
+ directions: kind === "remote-empty" ? ["push"] : ["push", "pull"],
103
+ directMessage: options.directMessage,
104
+ });
105
+ }
106
+
107
+ export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
108
+ return error instanceof SyncDecisionRequiredError;
109
+ }
110
+
111
+ function safeList(values: readonly string[]) {
112
+ return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
113
+ }
@@ -29,6 +29,7 @@ import {
29
29
  type RemoteHead,
30
30
  type SyncBackend,
31
31
  } from "./sync-backend.js";
32
+ import { createSyncDecision } from "./sync-decision.js";
32
33
  import {
33
34
  countPreservedRemoteFiles,
34
35
  errorMessage,
@@ -308,9 +309,17 @@ export async function push(
308
309
  ? filterSnapshotForConfigPolicy(remoteForUpload, config)
309
310
  : undefined;
310
311
  if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
311
- throw new Error(
312
- "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
313
- );
312
+ throw createSyncDecision({
313
+ kind: head ? "remote-or-policy-changed" : "remote-empty",
314
+ config,
315
+ state,
316
+ local,
317
+ remote: remoteForConflict,
318
+ localChanged: hasLocalChanges(local, state, config),
319
+ remoteChanged: true,
320
+ directMessage:
321
+ "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
322
+ });
314
323
  }
315
324
  }
316
325
 
@@ -330,7 +339,7 @@ export async function push(
330
339
  }
331
340
 
332
341
  if (!(await confirmPush(ctx, options, config, backend, local, upload, head, remoteForUpload))) {
333
- return;
342
+ return "cancelled" as const;
334
343
  }
335
344
 
336
345
  if (options.force) {
@@ -361,7 +370,7 @@ export async function push(
361
370
  "Remote changed during review. Push the refreshed plan?",
362
371
  ))
363
372
  ) {
364
- return;
373
+ return "cancelled" as const;
365
374
  }
366
375
  }
367
376
  }
@@ -395,6 +404,7 @@ export async function push(
395
404
  result.warnings.length > 0 ? "warning" : "info",
396
405
  );
397
406
  }
407
+ return "applied" as const;
398
408
  }
399
409
 
400
410
  export async function pull(
@@ -415,14 +425,32 @@ export async function pull(
415
425
  throwIfAborted(options.signal);
416
426
  const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
417
427
  throwIfAborted(options.signal);
418
- if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
419
-
420
428
  const localChanged = hasLocalChanges(local, state, config);
429
+ if (!remote) {
430
+ throw createSyncDecision({
431
+ kind: "remote-empty",
432
+ config,
433
+ state,
434
+ local,
435
+ localChanged,
436
+ remoteChanged: false,
437
+ directMessage: "Remote is empty. Run /sync push from a configured machine first.",
438
+ });
439
+ }
440
+
421
441
  const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
422
442
  if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
423
- throw new Error(
424
- "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
425
- );
443
+ throw createSyncDecision({
444
+ kind: "both-changed",
445
+ config,
446
+ state,
447
+ local,
448
+ remote,
449
+ localChanged,
450
+ remoteChanged,
451
+ directMessage:
452
+ "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
453
+ });
426
454
  }
427
455
 
428
456
  if (
@@ -519,15 +547,31 @@ export async function syncBoth(
519
547
 
520
548
  if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
521
549
  if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
522
- throw new Error(
523
- "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
524
- );
550
+ throw createSyncDecision({
551
+ kind: "first-sync-settings-diverged",
552
+ config,
553
+ state,
554
+ local,
555
+ remote,
556
+ localChanged: true,
557
+ remoteChanged: true,
558
+ directMessage:
559
+ "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
560
+ });
525
561
  }
526
562
  if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
527
563
  if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
528
- throw new Error(
529
- "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
530
- );
564
+ throw createSyncDecision({
565
+ kind: "first-sync-sessions-diverged",
566
+ config,
567
+ state,
568
+ local,
569
+ remote,
570
+ localChanged: true,
571
+ remoteChanged: true,
572
+ directMessage:
573
+ "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
574
+ });
531
575
  }
532
576
  await pull(ctx, options, factory);
533
577
  return;
@@ -557,9 +601,17 @@ export async function syncBoth(
557
601
  return;
558
602
  }
559
603
  if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
560
- throw new Error(
561
- "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
562
- );
604
+ throw createSyncDecision({
605
+ kind: "both-changed",
606
+ config,
607
+ state,
608
+ local,
609
+ remote,
610
+ localChanged,
611
+ remoteChanged,
612
+ directMessage:
613
+ "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
614
+ });
563
615
  }
564
616
  if (remoteChanged) {
565
617
  await pull(ctx, options, factory);
@@ -0,0 +1,166 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
4
+ import { loadConfig, syncConfigReviewIdentity } from "./config.js";
5
+ import type { SyncDecision, SyncResolutionDirection } from "./sync-decision.js";
6
+ import { errorMessage, safeTerminalText } from "./sync-format.js";
7
+
8
+ export type SyncResolutionResult =
9
+ | { kind: "resolved"; direction: SyncResolutionDirection }
10
+ | { kind: "back" }
11
+ | { kind: "closed" }
12
+ | { kind: "stale" };
13
+
14
+ export async function showSyncResolution(
15
+ ctx: ExtensionCommandContext,
16
+ initialDecision: SyncDecision,
17
+ runRoute: RunRoute,
18
+ sessionSignal?: AbortSignal,
19
+ ): Promise<SyncResolutionResult> {
20
+ type Screen = "resolve" | "review";
21
+ type Action = "push" | "pull" | "back";
22
+ let currentDecision = initialDecision;
23
+ let resolvedDirection: SyncResolutionDirection | undefined;
24
+ const menu = defineMenu<SyncDecision, Screen, Action, ExtensionCommandContext>({
25
+ start: "resolve",
26
+ screens: {
27
+ resolve: ({ state }) => ({
28
+ kind: "actions",
29
+ title: resolutionTitle(state),
30
+ lines: resolutionLines(state),
31
+ items: [
32
+ { id: "review", label: "Review differences (recommended)", to: "review" },
33
+ ...(state.directions.includes("push")
34
+ ? [
35
+ {
36
+ id: "push",
37
+ label: pushLabel(state),
38
+ description: "Review an exact push before replacing the remote version.",
39
+ action: "push" as const,
40
+ },
41
+ ]
42
+ : []),
43
+ ...(state.directions.includes("pull")
44
+ ? [
45
+ {
46
+ id: "pull",
47
+ label: pullLabel(state),
48
+ description: "Review exact local changes and create a backup before applying.",
49
+ action: "pull" as const,
50
+ },
51
+ ]
52
+ : []),
53
+ { id: "back", label: "Back", action: "back" },
54
+ ],
55
+ hint: "back",
56
+ }),
57
+ review: ({ state }) => ({
58
+ kind: "review",
59
+ title: `Review differences · ${safeTerminalText(state.setupName)}`,
60
+ content: state.review,
61
+ format: { kind: "text" },
62
+ viewportSize: "adaptive",
63
+ hint: "back",
64
+ }),
65
+ },
66
+ actions: {
67
+ push: ({ state, signal }) => resolveDirection("push", state, signal),
68
+ pull: ({ state, signal }) => resolveDirection("pull", state, signal),
69
+ back: async () => ({ kind: "back" }),
70
+ },
71
+ });
72
+
73
+ async function resolveDirection(
74
+ direction: SyncResolutionDirection,
75
+ decision: SyncDecision,
76
+ actionSignal: AbortSignal,
77
+ ) {
78
+ const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
79
+ const config = await loadConfig(decision.setupName);
80
+ if (signal.aborted) return { kind: "close" as const };
81
+ if (syncConfigReviewIdentity(config) !== decision.configIdentity) {
82
+ throw new Error(
83
+ `Sync setup “${safeTerminalText(decision.setupName)}” changed while conflict resolution was open; return to the sync manager and retry.`,
84
+ );
85
+ }
86
+ const result = await runCancellableOperation(
87
+ ctx,
88
+ direction === "push"
89
+ ? "Preparing local-wins push preview…"
90
+ : "Preparing remote-wins pull preview…",
91
+ `${direction} --force`,
92
+ runRoute,
93
+ {
94
+ commitAware: true,
95
+ cancelledMessage:
96
+ direction === "push"
97
+ ? "Push preparation cancelled; no remote files were changed."
98
+ : "Pull check cancelled; no local files were changed.",
99
+ target: decision.setupName,
100
+ signal,
101
+ },
102
+ );
103
+ if (result.kind === "decision-required") {
104
+ currentDecision = result.decision;
105
+ return { kind: "stay" as const };
106
+ }
107
+ if (result.kind === "completed") {
108
+ if (result.outcome === "cancelled") return { kind: "stay" as const };
109
+ resolvedDirection = direction;
110
+ return { kind: "close" as const };
111
+ }
112
+ return result.kind === "closed" ? { kind: "close" as const } : { kind: "stay" as const };
113
+ }
114
+
115
+ const result = await runMenu(ctx, menu, {
116
+ getState: () => currentDecision,
117
+ signal: sessionSignal,
118
+ isCurrent: () => !sessionSignal?.aborted,
119
+ onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
120
+ });
121
+ if (sessionSignal?.aborted || result.kind === "stale") return { kind: "stale" };
122
+ if (resolvedDirection) return { kind: "resolved", direction: resolvedDirection };
123
+ if (result.kind === "closed") {
124
+ return result.reason === "back" ? { kind: "back" } : { kind: "closed" };
125
+ }
126
+ return { kind: "closed" };
127
+ }
128
+
129
+ function resolutionTitle(decision: SyncDecision) {
130
+ return decision.kind === "remote-empty" ? "Remote is empty" : "Resolve sync conflict";
131
+ }
132
+
133
+ function resolutionLines(decision: SyncDecision) {
134
+ return [
135
+ `Sync setup: ${safeTerminalText(decision.setupName)}`,
136
+ ...causeSummary(decision),
137
+ "No files have been changed by this failed operation.",
138
+ ];
139
+ }
140
+
141
+ function causeSummary(decision: SyncDecision) {
142
+ if (decision.kind === "first-sync-settings-diverged") {
143
+ return ["This machine and the remote have different Pi settings on first sync."];
144
+ }
145
+ if (decision.kind === "first-sync-sessions-diverged") {
146
+ return ["Pi settings match, but local and remote sessions differ on first sync."];
147
+ }
148
+ if (decision.kind === "remote-empty") return ["The remote storage location has no snapshot."];
149
+ return [
150
+ ...(decision.causes.localChanged ? ["Local content changed since the last sync."] : []),
151
+ ...(decision.causes.remoteChanged ? ["Remote content changed since the last sync."] : []),
152
+ ...(decision.causes.policyChanged ? ["Included content changed since the last sync."] : []),
153
+ ];
154
+ }
155
+
156
+ function pushLabel(decision: SyncDecision) {
157
+ if (decision.kind === "remote-empty") return "Push local content…";
158
+ if (decision.kind.startsWith("first-sync-")) return "Use local as initial source…";
159
+ return "Keep local content and replace remote…";
160
+ }
161
+
162
+ function pullLabel(decision: SyncDecision) {
163
+ return decision.kind.startsWith("first-sync-")
164
+ ? "Use remote as initial source…"
165
+ : "Use remote content and replace local…";
166
+ }
package/src/sync.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ExtensionCommandContext,
4
4
  ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import type { RunRouteResult } from "./cancellable-operation.js";
6
7
  import {
7
8
  completeSyncArguments,
8
9
  parseOptions,
@@ -33,6 +34,7 @@ import { showSetupWizard, showSyncManager } from "./manager-ui.js";
33
34
  import { SetupPullRequiresUiError, useSyncSetup } from "./setup-switch.js";
34
35
  import { createSnapshot } from "./snapshot.js";
35
36
  import { recoverSnapshotTransactionsOnStartup } from "./snapshot-transaction.js";
37
+ import { isSyncDecisionRequiredError } from "./sync-decision.js";
36
38
  import { errorMessage } from "./sync-format.js";
37
39
  import {
38
40
  diff,
@@ -141,7 +143,10 @@ async function handleCommand(
141
143
  }
142
144
  return;
143
145
  }
144
- await executeCommand(rawArgs, ctx, sessionSignal);
146
+ const result = await executeCommand(rawArgs, ctx, sessionSignal);
147
+ if (result.kind === "decision-required") {
148
+ ctx.ui.notify(result.decision.directMessage, "error");
149
+ }
145
150
  }
146
151
 
147
152
  async function executeCommand(
@@ -150,10 +155,10 @@ async function executeCommand(
150
155
  signal?: AbortSignal,
151
156
  onCommit?: () => void,
152
157
  setup?: string,
153
- ) {
158
+ ): Promise<RunRouteResult> {
154
159
  try {
155
160
  const command = await resolveSyncCommand(rawArgs, ctx);
156
- if (signal?.aborted || !command) return;
161
+ if (signal?.aborted || !command) return { kind: "completed" };
157
162
  const { subcommand, rest } = command;
158
163
  const options = parseOptions(rest);
159
164
  if (setup !== undefined) options.setup = setup;
@@ -164,7 +169,7 @@ async function executeCommand(
164
169
  switch (subcommand) {
165
170
  case "help":
166
171
  ctx.ui.notify(usage(), "info");
167
- return;
172
+ return { kind: "completed" };
168
173
  case "use":
169
174
  await useSyncSetup(
170
175
  ctx,
@@ -174,50 +179,58 @@ async function executeCommand(
174
179
  undefined,
175
180
  options.signal,
176
181
  );
177
- return;
182
+ return { kind: "completed" };
178
183
  case "init":
179
184
  await initConfig(ctx, signal);
180
- return;
185
+ return { kind: "completed" };
181
186
  case "config":
182
187
  await showConfig(ctx, options);
183
- return;
188
+ return { kind: "completed" };
184
189
  case "files":
185
190
  await showFileSelection(ctx, options.setup, options.signal);
186
- return;
191
+ return { kind: "completed" };
187
192
  case "status":
188
193
  await status(ctx, options);
189
- return;
194
+ return { kind: "completed" };
190
195
  case "diff":
191
196
  await diff(ctx, options);
192
- return;
197
+ return { kind: "completed" };
193
198
  case "doctor":
194
199
  await doctor(ctx, options);
195
- return;
196
- case "push":
197
- await withLock("push", () => push(ctx, options));
198
- return;
199
- case "pull":
200
- return await withLock("pull", () => pull(ctx, options));
200
+ return { kind: "completed" };
201
+ case "push": {
202
+ const outcome = await withLock("push", () => push(ctx, options));
203
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
204
+ }
205
+ case "pull": {
206
+ const outcome = await withLock("pull", () => pull(ctx, options));
207
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
208
+ }
201
209
  case "sync":
202
210
  await withLock("sync", () => syncBoth(ctx, options));
203
- return;
211
+ return { kind: "completed" };
204
212
  case "history":
205
213
  await history(ctx, options);
206
- return;
214
+ return { kind: "completed" };
207
215
  case "rollback":
208
216
  await withLock("rollback", () => rollback(ctx, options));
209
- return;
217
+ return { kind: "completed" };
210
218
  case "unlock":
211
219
  await unlock(ctx, options);
212
- return;
220
+ return { kind: "completed" };
213
221
  default:
214
222
  ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
223
+ return { kind: "failed" };
215
224
  }
216
225
  } catch (error) {
217
- if (signal?.aborted) return;
226
+ if (signal?.aborted) return { kind: "failed" };
218
227
  ctx.ui.setStatus(STATUS_KEY, undefined);
219
228
  if (error instanceof SetupPullRequiresUiError) throw error;
229
+ if (isSyncDecisionRequiredError(error)) {
230
+ return { kind: "decision-required", decision: error.decision };
231
+ }
220
232
  ctx.ui.notify(errorMessage(error), "error");
233
+ return { kind: "failed" };
221
234
  }
222
235
  }
223
236