@narumitw/pi-sync 0.49.6 → 0.49.8

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.
@@ -1,9 +1,10 @@
1
+ import { syncConfigReviewFingerprint } from "./config.js";
1
2
  import type { RemoteHead, SyncBackend } from "./sync-backend.js";
2
3
  import { safeTerminalText } from "./sync-format.js";
3
4
  import {
4
5
  inspectRemoteSelection,
5
- RemoteSelectionMismatchError,
6
6
  type RemoteSelectionState,
7
+ remoteSelectionMismatch,
7
8
  sameSyncInclude,
8
9
  snapshotSelectionInclude,
9
10
  } from "./sync-policy.js";
@@ -37,7 +38,7 @@ export async function readSnapshotForHead(
37
38
  export function requireCompatibleRemoteSelection(config: AnySyncConfig, snapshot: Snapshot) {
38
39
  const state = inspectRemoteSelection(config.include, snapshot);
39
40
  if (state.kind === "different") {
40
- throw new RemoteSelectionMismatchError(config.include, state.include);
41
+ throw remoteSelectionMismatch(config, state.include, syncConfigReviewFingerprint(config));
41
42
  }
42
43
  }
43
44
 
@@ -9,6 +9,13 @@ import {
9
9
  } from "./config.js";
10
10
  import type { PiSyncSettingsV3, StorageConnectionSettings, SyncSetupSettings } from "./types.js";
11
11
 
12
+ export class SyncSetupReviewChangedError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "SyncSetupReviewChangedError";
16
+ }
17
+ }
18
+
12
19
  export async function saveNewV3Settings(
13
20
  input: {
14
21
  setupName: string;
@@ -119,7 +126,7 @@ export async function updateSyncSetup(
119
126
  options.expectedInclude &&
120
127
  !sameNames(normalizeSyncInclude(setup.sync.include), options.expectedInclude)
121
128
  ) {
122
- throw new Error(
129
+ throw new SyncSetupReviewChangedError(
123
130
  `Sync setup “${name}” included content changed while it was open; reopen it and review the current selection.`,
124
131
  );
125
132
  }
@@ -133,7 +140,7 @@ export async function updateSyncSetup(
133
140
  options.expectedStorage,
134
141
  )
135
142
  ) {
136
- throw new Error(
143
+ throw new SyncSetupReviewChangedError(
137
144
  `Sync setup “${name}” storage changed while it was open; reopen it and review the current storage location.`,
138
145
  );
139
146
  }
@@ -2,6 +2,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
  import type { RunRoute } from "./cancellable-operation.js";
4
4
  import { loadConfig, localConfigPath } from "./config.js";
5
+ import { dispatchManagerResult } from "./manager-result-dispatcher.js";
5
6
  import { updateSyncSetup } from "./settings-management.js";
6
7
  import {
7
8
  SETUP_SWITCH_ACTION_OPTIONS,
@@ -67,9 +68,8 @@ export async function showSyncSettings(
67
68
  },
68
69
  {
69
70
  id: "remoteInclude",
70
- label: "Remote included content",
71
- description:
72
- "Compare portable remote selection, then adopt it or keep local settings without pulling files.",
71
+ label: "Compare synced content",
72
+ description: "Review this device and remote content lists before choosing either one.",
73
73
  currentValue: "Review",
74
74
  action: "remote-include",
75
75
  },
@@ -126,8 +126,24 @@ export async function showSyncSettings(
126
126
  const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
127
127
  const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
128
128
  if (reviewSignal.aborted) return { kind: "rejected" };
129
- await showRemoteSelectionReview(ctx, setupName, reviewSignal);
130
- return reviewSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
129
+ const review = await showRemoteSelectionReview(ctx, setupName, reviewSignal, undefined, {
130
+ origin: "settings",
131
+ runRoute,
132
+ });
133
+ if (reviewSignal.aborted) return { kind: "rejected" };
134
+ if (review.kind === "route-result") {
135
+ const disposition = await dispatchManagerResult(
136
+ ctx,
137
+ review.result,
138
+ review.route,
139
+ runRoute,
140
+ reviewSignal,
141
+ );
142
+ return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
143
+ }
144
+ return review.kind === "closed" || review.kind === "stale"
145
+ ? { kind: "close" }
146
+ : { kind: "stay" };
131
147
  },
132
148
  },
133
149
  });
@@ -0,0 +1,100 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { syncConfigReviewFingerprint } from "./config.js";
4
+ import { safeTerminalText } from "./sync-format.js";
5
+ import {
6
+ compareSyncInclude,
7
+ type RemoteSelectionDecision,
8
+ sameSyncInclude,
9
+ } from "./sync-policy.js";
10
+ import type { AnySyncConfig } from "./types.js";
11
+
12
+ const STATUS_KEY = "sync";
13
+ const WIDGET_KEY = "sync:attention";
14
+
15
+ export type SyncAttentionOrigin = "sync" | "pull" | "push";
16
+
17
+ export interface SyncAttentionState {
18
+ decision: RemoteSelectionDecision;
19
+ origin: SyncAttentionOrigin;
20
+ offered: boolean;
21
+ }
22
+
23
+ export function syncAttentionMatchesConfig(attention: SyncAttentionState, config: AnySyncConfig) {
24
+ return (
25
+ attention.decision.setupName === config.setupName &&
26
+ attention.decision.configIdentity === syncConfigReviewFingerprint(config) &&
27
+ sameSyncInclude(attention.decision.localInclude, config.include)
28
+ );
29
+ }
30
+
31
+ export interface SyncAttentionController {
32
+ set(decision: RemoteSelectionDecision, origin: SyncAttentionOrigin): void;
33
+ current(): SyncAttentionState | undefined;
34
+ markOffered(): boolean;
35
+ clear(ctx: ExtensionContext): void;
36
+ reset(ctx: ExtensionContext): void;
37
+ publish(ctx: ExtensionContext): void;
38
+ }
39
+
40
+ export function createSyncAttentionController(): SyncAttentionController {
41
+ let state: SyncAttentionState | undefined;
42
+
43
+ return {
44
+ set(decision, origin) {
45
+ state = { decision, origin, offered: false };
46
+ },
47
+ current() {
48
+ return state;
49
+ },
50
+ markOffered() {
51
+ if (!state || state.offered) return false;
52
+ state = { ...state, offered: true };
53
+ return true;
54
+ },
55
+ clear(ctx) {
56
+ state = undefined;
57
+ clearAttentionPresentation(ctx);
58
+ },
59
+ reset(ctx) {
60
+ state = undefined;
61
+ clearAttentionPresentation(ctx);
62
+ },
63
+ publish(ctx) {
64
+ if (!state) {
65
+ clearAttentionPresentation(ctx);
66
+ return;
67
+ }
68
+ const presentation = attentionPresentation(state.decision);
69
+ ctx.ui.setStatus(STATUS_KEY, presentation.status);
70
+ if (ctx.mode !== "tui") return;
71
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
72
+ render(width: number) {
73
+ const safeWidth = Math.max(1, width);
74
+ return presentation.lines.map((line, index) =>
75
+ truncateToWidth(theme.fg(index === 0 ? "warning" : "muted", line), safeWidth, "…"),
76
+ );
77
+ },
78
+ invalidate() {},
79
+ }));
80
+ },
81
+ };
82
+ }
83
+
84
+ function attentionPresentation(decision: RemoteSelectionDecision) {
85
+ const setupName = safeTerminalText(decision.setupName);
86
+ const comparison = compareSyncInclude(decision.localInclude, decision.remoteInclude);
87
+ const difference =
88
+ comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0
89
+ ? "Only list order differs"
90
+ : `Remote ${comparison.remoteOnly.length} · Device ${comparison.localOnly.length}`;
91
+ return {
92
+ status: "review needed",
93
+ lines: [`Pi Sync needs review · ${setupName}`, difference, "No changes · Run /sync to review"],
94
+ };
95
+ }
96
+
97
+ function clearAttentionPresentation(ctx: ExtensionContext) {
98
+ ctx.ui.setStatus(STATUS_KEY, undefined);
99
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
100
+ }
@@ -36,11 +36,22 @@ import {
36
36
  stateDirectoryMigrationNotice,
37
37
  withStateDirectoryAccess,
38
38
  } from "./state-directory.js";
39
+ import {
40
+ createSyncAttentionController,
41
+ type SyncAttentionController,
42
+ type SyncAttentionOrigin,
43
+ syncAttentionMatchesConfig,
44
+ } from "./sync-attention.js";
39
45
  import {
40
46
  errorMessage,
41
47
  isSyncDecisionRequiredError,
42
48
  SetupPullRequiresUiError,
43
49
  } from "./sync-errors.js";
50
+ import {
51
+ formatRemoteSelectionMismatch,
52
+ type RemoteSelectionDecision,
53
+ RemoteSelectionMismatchError,
54
+ } from "./sync-policy.js";
44
55
  import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
45
56
 
46
57
  const STATUS_KEY = "sync";
@@ -84,6 +95,7 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
84
95
  dependencies.loadSyncOperations ?? (() => import("./sync-operations.js")),
85
96
  ),
86
97
  };
98
+ const attention = createSyncAttentionController();
87
99
  let sessionAbort = new AbortController();
88
100
  let shutdownAbort: AbortController | undefined;
89
101
 
@@ -96,7 +108,7 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
96
108
  "/sync requires TUI or RPC mode so results and safety prompts are observable.",
97
109
  );
98
110
  }
99
- const run = () => handleCommand(args, ctx, sessionAbort.signal, loaders);
111
+ const run = () => handleCommand(args, ctx, sessionAbort.signal, loaders, attention);
100
112
  if (splitArgs(args)[0] === "migrate-state") await run();
101
113
  else await withStateDirectoryAccess(run);
102
114
  },
@@ -108,17 +120,38 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
108
120
  sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
109
121
  sessionAbort = new AbortController();
110
122
  const signal = sessionAbort.signal;
111
- ctx.ui.setStatus(STATUS_KEY, undefined);
123
+ attention.reset(ctx);
124
+ let decision: RemoteSelectionDecision | undefined;
112
125
  try {
113
- await withStateDirectoryAccess(() => startSession(ctx, signal, loaders));
126
+ decision = await withStateDirectoryAccess(() => startSession(ctx, signal, loaders));
114
127
  } catch (error) {
115
128
  if (signal.aborted) return;
116
129
  ctx.ui.notify(`pi-sync state access failed: ${errorMessage(error)}`, "error");
130
+ return;
131
+ }
132
+ if (!decision || signal.aborted) return;
133
+ attention.set(decision, "sync");
134
+ if (ctx.mode !== "tui") {
135
+ ctx.ui.notify(
136
+ `pi-sync auto sync skipped: ${formatRemoteSelectionMismatch(
137
+ decision.setupName,
138
+ decision.localInclude,
139
+ decision.remoteInclude,
140
+ )}\nRPC review is read-only.`,
141
+ "warning",
142
+ );
143
+ } else if (attention.markOffered()) {
144
+ await resolveSelectionAttention(ctx, attention, signal, loaders, {
145
+ cancelLabel: "Later",
146
+ withStateAccess: withStateDirectoryAccess,
147
+ });
117
148
  }
149
+ if (!signal.aborted) attention.publish(ctx);
118
150
  });
119
151
 
120
152
  pi.on("session_shutdown", async (event, ctx) => {
121
153
  sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
154
+ attention.reset(ctx);
122
155
  shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
123
156
  const controller = new AbortController();
124
157
  shutdownAbort = controller;
@@ -171,7 +204,7 @@ async function startSession(ctx: ExtensionContext, signal: AbortSignal, loaders:
171
204
  const migrationNotice = consumeLocalConfigMigrationNotice();
172
205
  if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
173
206
  if (signal.aborted) return;
174
- await autoSync(ctx, signal, loaders);
207
+ return autoSync(ctx, signal, loaders);
175
208
  }
176
209
 
177
210
  async function handleCommand(
@@ -179,6 +212,7 @@ async function handleCommand(
179
212
  ctx: ExtensionCommandContext,
180
213
  sessionSignal: AbortSignal,
181
214
  loaders: SyncLoaders,
215
+ attention: SyncAttentionController,
182
216
  ) {
183
217
  if (!rawArgs.trim()) {
184
218
  try {
@@ -196,17 +230,173 @@ async function handleCommand(
196
230
  target,
197
231
  ),
198
232
  sessionSignal,
233
+ {
234
+ getAttention: () => attention.current(),
235
+ onSelectionResolved: (expected) => {
236
+ if (attention.current() === expected) attention.clear(ctx);
237
+ },
238
+ },
199
239
  );
200
240
  } catch (error) {
201
241
  if (sessionSignal.aborted) return;
202
242
  ctx.ui.setStatus(STATUS_KEY, undefined);
203
243
  ctx.ui.notify(errorMessage(error), "error");
204
244
  }
245
+ if (!sessionSignal.aborted) attention.publish(ctx);
205
246
  return;
206
247
  }
207
248
  const result = await executeCommand(rawArgs, ctx, sessionSignal, loaders);
208
249
  if (result.kind === "decision-required") {
209
250
  ctx.ui.notify(result.decision.directMessage, "error");
251
+ } else if (result.kind === "remote-selection-required") {
252
+ const origin = directSelectionOrigin(rawArgs);
253
+ if (origin) attention.set(result.decision, origin);
254
+ const deterministic = splitArgs(rawArgs).some((arg) => arg === "--yes" || arg === "-y");
255
+ if (origin && ctx.mode === "tui" && !deterministic) {
256
+ await resolveSelectionAttention(ctx, attention, sessionSignal, loaders);
257
+ } else {
258
+ ctx.ui.notify(
259
+ formatRemoteSelectionMismatch(
260
+ result.decision.setupName,
261
+ result.decision.localInclude,
262
+ result.decision.remoteInclude,
263
+ ),
264
+ "error",
265
+ );
266
+ }
267
+ }
268
+ await clearAttentionAfterCompletedOperation(rawArgs, result, ctx, attention, sessionSignal);
269
+ await reconcileSelectionAttention(ctx, attention, sessionSignal);
270
+ if (!sessionSignal.aborted) attention.publish(ctx);
271
+ }
272
+
273
+ async function clearAttentionAfterCompletedOperation(
274
+ rawArgs: string,
275
+ result: RunRouteResult,
276
+ ctx: ExtensionContext,
277
+ attention: SyncAttentionController,
278
+ signal: AbortSignal,
279
+ ) {
280
+ if (result.kind !== "completed" || result.outcome === "cancelled" || signal.aborted) return;
281
+ const [command, ...rest] = splitArgs(rawArgs);
282
+ if (command !== "sync" && command !== "pull" && command !== "push") return;
283
+ const current = attention.current();
284
+ if (!current) return;
285
+ try {
286
+ const options = parseOptions(rest);
287
+ const setupName = options.setup ?? (await loadConfig()).setupName;
288
+ if (signal.aborted || attention.current() !== current) return;
289
+ if (current.decision.setupName === setupName) attention.clear(ctx);
290
+ } catch {
291
+ // Attention reconciliation below owns malformed or concurrently changed settings.
292
+ }
293
+ }
294
+
295
+ async function reconcileSelectionAttention(
296
+ ctx: ExtensionContext,
297
+ attention: SyncAttentionController,
298
+ signal: AbortSignal,
299
+ ) {
300
+ const current = attention.current();
301
+ if (!current || signal.aborted) return;
302
+ try {
303
+ const config = await loadConfig(current.decision.setupName);
304
+ if (signal.aborted || attention.current() !== current) return;
305
+ if (!syncAttentionMatchesConfig(current, config)) attention.clear(ctx);
306
+ } catch {
307
+ if (!signal.aborted && attention.current() === current) attention.clear(ctx);
308
+ }
309
+ }
310
+
311
+ function directSelectionOrigin(rawArgs: string): SyncAttentionOrigin | undefined {
312
+ const command = splitArgs(rawArgs)[0];
313
+ return command === "sync" || command === "pull" || command === "push" ? command : undefined;
314
+ }
315
+
316
+ async function resolveSelectionAttention(
317
+ ctx: ExtensionContext,
318
+ attention: SyncAttentionController,
319
+ signal: AbortSignal,
320
+ loaders: SyncLoaders,
321
+ options: {
322
+ cancelLabel?: string;
323
+ withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
324
+ } = {},
325
+ ) {
326
+ const current = attention.current();
327
+ if (!current || signal.aborted) return;
328
+ const { dispatchManagerResult } = await import("./manager-result-dispatcher.js");
329
+ if (signal.aborted || attention.current() !== current) return;
330
+ await dispatchManagerResult(
331
+ ctx,
332
+ { kind: "remote-selection-required", decision: current.decision },
333
+ current.origin,
334
+ (route, actionSignal, onCommit, target) => {
335
+ const execute = () =>
336
+ executeRecoveryCommand(
337
+ route,
338
+ ctx,
339
+ combineSignals(signal, actionSignal),
340
+ loaders,
341
+ onCommit,
342
+ target,
343
+ );
344
+ return options.withStateAccess ? options.withStateAccess(execute) : execute();
345
+ },
346
+ signal,
347
+ {
348
+ cancelLabel: options.cancelLabel,
349
+ withStateAccess: options.withStateAccess,
350
+ onSelectionResolved: () => {
351
+ if (attention.current() === current) attention.clear(ctx);
352
+ },
353
+ },
354
+ );
355
+ }
356
+
357
+ async function executeRecoveryCommand(
358
+ rawArgs: string,
359
+ ctx: ExtensionContext,
360
+ signal: AbortSignal | undefined,
361
+ loaders: SyncLoaders,
362
+ onCommit?: () => void,
363
+ setup?: string,
364
+ ): Promise<RunRouteResult> {
365
+ try {
366
+ const [subcommand, ...rest] = splitArgs(rawArgs);
367
+ if (subcommand !== "sync" && subcommand !== "pull" && subcommand !== "push") {
368
+ throw new Error(`Unsupported sync recovery route: ${subcommand ?? "missing"}`);
369
+ }
370
+ const options = parseOptions(rest);
371
+ if (setup !== undefined) options.setup = setup;
372
+ if (signal) options.signal = signal;
373
+ if (onCommit) options.onCommit = onCommit;
374
+ options.reload = false;
375
+ options.auto = false;
376
+ validateCommandOptions(subcommand, options);
377
+ const operations = await loaders.operations();
378
+ throwIfAborted(options.signal);
379
+ if (subcommand === "push") {
380
+ const outcome = await withLock("push", () => operations.push(ctx, options));
381
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
382
+ }
383
+ if (subcommand === "pull") {
384
+ const outcome = await withLock("pull", () => operations.pull(ctx, options));
385
+ return { kind: "completed", ...(outcome ? { outcome } : {}) };
386
+ }
387
+ await withLock("sync", () => operations.syncBoth(ctx, options));
388
+ return { kind: "completed" };
389
+ } catch (error) {
390
+ if (signal?.aborted) return { kind: "failed" };
391
+ ctx.ui.setStatus(STATUS_KEY, undefined);
392
+ if (error instanceof RemoteSelectionMismatchError) {
393
+ return { kind: "remote-selection-required", decision: error.decision };
394
+ }
395
+ if (isSyncDecisionRequiredError(error)) {
396
+ return { kind: "decision-required", decision: error.decision };
397
+ }
398
+ ctx.ui.notify(errorMessage(error), "error");
399
+ return { kind: "failed" };
210
400
  }
211
401
  }
212
402
 
@@ -324,6 +514,9 @@ async function executeCommand(
324
514
  if (signal?.aborted) return { kind: "failed" };
325
515
  ctx.ui.setStatus(STATUS_KEY, undefined);
326
516
  if (error instanceof SetupPullRequiresUiError) throw error;
517
+ if (error instanceof RemoteSelectionMismatchError) {
518
+ return { kind: "remote-selection-required", decision: error.decision };
519
+ }
327
520
  if (isSyncDecisionRequiredError(error)) {
328
521
  return { kind: "decision-required", decision: error.decision };
329
522
  }
@@ -359,7 +552,11 @@ async function migrateStateDirectory(ctx: ExtensionCommandContext, options: Comm
359
552
  ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
360
553
  }
361
554
 
362
- async function autoSync(ctx: ExtensionContext, signal: AbortSignal, loaders: SyncLoaders) {
555
+ async function autoSync(
556
+ ctx: ExtensionContext,
557
+ signal: AbortSignal,
558
+ loaders: SyncLoaders,
559
+ ): Promise<RemoteSelectionDecision | undefined> {
363
560
  try {
364
561
  const partial = await loadPartialConfig();
365
562
  throwIfAborted(signal);
@@ -377,6 +574,7 @@ async function autoSync(ctx: ExtensionContext, signal: AbortSignal, loaders: Syn
377
574
  } catch (error) {
378
575
  if (signal.aborted || isMissingConfigError(error)) return;
379
576
  ctx.ui.setStatus(STATUS_KEY, undefined);
577
+ if (error instanceof RemoteSelectionMismatchError) return error.decision;
380
578
  ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
381
579
  }
382
580
  }
@@ -7,6 +7,7 @@ import {
7
7
  readStateForConfig,
8
8
  sessionDirForApply,
9
9
  stateDir,
10
+ syncConfigReviewFingerprint,
10
11
  syncSessionsWarnings,
11
12
  writeStateForConfig,
12
13
  } from "./config.js";
@@ -46,7 +47,7 @@ import {
46
47
  publicationCapabilityDescription,
47
48
  safeTerminalText,
48
49
  } from "./sync-format.js";
49
- import { inspectRemoteSelection, RemoteSelectionMismatchError } from "./sync-policy.js";
50
+ import { inspectRemoteSelection, remoteSelectionMismatch } from "./sync-policy.js";
50
51
  import {
51
52
  canPullRemoteSessionsOnFirstSync,
52
53
  canPullRemoteSettingsOnFirstSync,
@@ -923,7 +924,8 @@ async function readRemoteSnapshot(
923
924
  const snapshot = await readSnapshotForHead(backend, head, signal);
924
925
  const selectionState = inspectRemoteSelection(config.include, snapshot);
925
926
  if (!options.allowSelectionDifference && selectionState.kind === "different") {
926
- throw new RemoteSelectionMismatchError(config.include, selectionState.include);
927
+ const configIdentity = syncConfigReviewFingerprint(config);
928
+ throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
927
929
  }
928
930
  return {
929
931
  head,
@@ -206,22 +206,81 @@ export function inspectRemoteSelection(
206
206
  : { kind: "different", include: remoteInclude, ...comparison };
207
207
  }
208
208
 
209
+ export interface RemoteSelectionDecision {
210
+ setupName: string;
211
+ configIdentity: string;
212
+ localInclude: readonly string[];
213
+ remoteInclude: readonly string[];
214
+ }
215
+
209
216
  export class RemoteSelectionMismatchError extends Error {
217
+ readonly decision: RemoteSelectionDecision;
218
+ readonly setupName: string;
210
219
  readonly localInclude: string[];
211
220
  readonly remoteInclude: string[];
212
221
 
213
- constructor(localInclude: unknown, remoteInclude: unknown) {
222
+ constructor(
223
+ setupName: string,
224
+ localInclude: unknown,
225
+ remoteInclude: unknown,
226
+ configIdentity = JSON.stringify([setupName, normalizeSyncInclude(localInclude)]),
227
+ ) {
214
228
  const local = normalizeSyncInclude(localInclude);
215
229
  const remote = normalizeSyncInclude(remoteInclude);
216
- super(
217
- "Remote included content differs from this sync setup. Review Remote included content in /sync Settings, or use a reviewed force push to keep local selection.",
218
- );
230
+ super(formatRemoteSelectionMismatch(setupName, local, remote));
219
231
  this.name = "RemoteSelectionMismatchError";
232
+ this.setupName = setupName;
220
233
  this.localInclude = local;
221
234
  this.remoteInclude = remote;
235
+ this.decision = {
236
+ setupName,
237
+ configIdentity,
238
+ localInclude: [...local],
239
+ remoteInclude: [...remote],
240
+ };
222
241
  }
223
242
  }
224
243
 
244
+ export function remoteSelectionMismatch(
245
+ config: { setupName: string; include: unknown },
246
+ remoteInclude: unknown,
247
+ configIdentity?: string,
248
+ ) {
249
+ return new RemoteSelectionMismatchError(
250
+ config.setupName,
251
+ config.include,
252
+ remoteInclude,
253
+ configIdentity,
254
+ );
255
+ }
256
+
257
+ export function formatRemoteSelectionMismatch(
258
+ setupName: string,
259
+ localInclude: readonly string[],
260
+ remoteInclude: readonly string[],
261
+ ) {
262
+ const comparison = compareSyncInclude(localInclude, remoteInclude);
263
+ const lines = [
264
+ `Synced content differs for sync setup “${stripTerminalControls(setupName)}”.`,
265
+ `Remote-only: ${comparison.remoteOnly.join(", ") || "none"}`,
266
+ `This-device-only: ${comparison.localOnly.join(", ") || "none"}`,
267
+ ];
268
+ if (comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0) {
269
+ lines.push(
270
+ "Only ordering differs.",
271
+ `Remote order: ${remoteInclude.join(", ") || "none"}`,
272
+ `This device order: ${localInclude.join(", ") || "none"}`,
273
+ );
274
+ }
275
+ lines.push("Run /sync in TUI to review both content lists and choose what happens next.");
276
+ return lines.join("\n");
277
+ }
278
+
279
+ function stripTerminalControls(value: string) {
280
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
281
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
282
+ }
283
+
225
284
  export function discoverLegacySnapshotInclude(snapshot: Pick<Snapshot, "files">) {
226
285
  const builtIns = new Set<BuiltInSyncFile>();
227
286
  const custom = new Set<string>();
@@ -1,18 +1,27 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
3
+ import {
4
+ type RunRoute,
5
+ type RunRouteResult,
6
+ runCancellableOperation,
7
+ } from "./cancellable-operation.js";
4
8
  import { loadConfig, syncConfigReviewIdentity } from "./config.js";
5
9
  import type { SyncDecision, SyncResolutionDirection } from "./sync-decision.js";
6
10
  import { errorMessage, safeTerminalText } from "./sync-format.js";
7
11
 
8
12
  export type SyncResolutionResult =
9
13
  | { kind: "resolved"; direction: SyncResolutionDirection }
14
+ | {
15
+ kind: "route-result";
16
+ result: RunRouteResult;
17
+ route: SyncResolutionDirection;
18
+ }
10
19
  | { kind: "back" }
11
20
  | { kind: "closed" }
12
21
  | { kind: "stale" };
13
22
 
14
23
  export async function showSyncResolution(
15
- ctx: ExtensionCommandContext,
24
+ ctx: ExtensionContext,
16
25
  initialDecision: SyncDecision,
17
26
  runRoute: RunRoute,
18
27
  sessionSignal?: AbortSignal,
@@ -21,7 +30,8 @@ export async function showSyncResolution(
21
30
  type Action = "push" | "pull" | "back";
22
31
  let currentDecision = initialDecision;
23
32
  let resolvedDirection: SyncResolutionDirection | undefined;
24
- const menu = defineMenu<SyncDecision, Screen, Action, ExtensionCommandContext>({
33
+ let chainedResult: { result: RunRouteResult; route: SyncResolutionDirection } | undefined;
34
+ const menu = defineMenu<SyncDecision, Screen, Action, ExtensionContext>({
25
35
  start: "resolve",
26
36
  screens: {
27
37
  resolve: ({ state }) => ({
@@ -104,6 +114,10 @@ export async function showSyncResolution(
104
114
  currentDecision = result.decision;
105
115
  return { kind: "stay" as const };
106
116
  }
117
+ if (result.kind === "remote-selection-required") {
118
+ chainedResult = { result, route: direction };
119
+ return { kind: "close" as const };
120
+ }
107
121
  if (result.kind === "completed") {
108
122
  if (result.outcome === "cancelled") return { kind: "stay" as const };
109
123
  resolvedDirection = direction;
@@ -119,6 +133,7 @@ export async function showSyncResolution(
119
133
  onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
120
134
  });
121
135
  if (sessionSignal?.aborted || result.kind === "stale") return { kind: "stale" };
136
+ if (chainedResult) return { kind: "route-result", ...chainedResult };
122
137
  if (resolvedDirection) return { kind: "resolved", direction: resolvedDirection };
123
138
  if (result.kind === "closed") {
124
139
  return result.reason === "back" ? { kind: "back" } : { kind: "closed" };