@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.
package/README.md CHANGED
@@ -54,22 +54,36 @@ 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
 
62
65
  ### Resolve conflicts in the manager
63
66
 
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.
67
+ 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.
68
+ The flow names the current sync setup and explains whether local content, remote content, or the included-content policy changed.
70
69
 
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:
70
+ An explicit remote content-list mismatch now opens **Synced content differs** inside the same manager flow.
71
+ Nothing changes until you choose an action.
72
+ Choose **Review all paths (recommended)** first to compare exact remote-only paths, device-only paths, and both ordered lists.
73
+ If membership matches but order differs, the review says that only ordering differs.
74
+ Then choose one action:
75
+
76
+ - **Use remote content list** revalidates the remote snapshot and reviewed local setup before saving only `sync.include`.
77
+ **Remote content list saved** confirms that no files were pulled and offers a separate **Continue Sync/Pull/Push now…** action plus **Done**.
78
+ Continue starts a fresh operation and exact preview for the captured setup rather than resuming stale work.
79
+ - **Keep this device's content list and update remote…** opens the existing `push --force` preparation and exact confirmation without `--yes`.
80
+ Cancelling preparation or confirmation returns to the content-list choice without changing remote data.
81
+ - **Cancel** returns to the manager without changing settings, files, remote data, or sync state.
82
+ - **Later** appears when automatic startup sync detected the mismatch and returns immediately to the
83
+ Pi editor without changing anything.
84
+
85
+ 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.
86
+ Then choose one reviewed direction:
73
87
 
74
88
  - **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
75
89
  local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
@@ -79,10 +93,22 @@ local files, remote data, or sync state. Then choose one reviewed direction:
79
93
  - First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
80
94
  empty remote offers **Push local content…** only.
81
95
 
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.
96
+ Cancelling a preparation or confirmation returns to conflict resolution with no side effects.
97
+ Back returns to the sync manager, and Ctrl+C closes the complete flow.
98
+
99
+ When automatic startup sync detects an explicit content-list mismatch in TUI mode, it opens
100
+ **Synced content differs** once for that session instead of ending at a warning. Choosing **Later** or
101
+ closing the flow leaves a compact **Pi Sync needs review** widget above the editor and a
102
+ **review needed** footer status. The attention state is in memory only, contains no credentials or
103
+ file contents, and is revalidated before any settings or remote mutation. It clears after verified
104
+ resolution, invalidation, session replacement, or shutdown.
105
+
106
+ Interactive TUI `/sync sync`, `/sync pull`, and `/sync push` routes without `--yes` open the same
107
+ review flow when they detect the mismatch. Explicit `--yes` routes remain non-interactive and report
108
+ exact remote-only, device-only, or order-only guidance while leaving visible attention for later
109
+ review. Shutdown automatic sync never opens a dialog because Pi is exiting.
110
+ RPC startup and direct routes remain read-only and notification-based, while print and JSON modes
111
+ remain unsupported for `/sync` because their UI output is not observable.
86
112
 
87
113
  ## ⚙️ Settings
88
114
 
@@ -203,17 +229,16 @@ remote snapshot. Leaving the editor opens an exact Include/Exclude review with *
203
229
  the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
204
230
  the manual `sync.include` path.
205
231
 
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.
232
+ Every new snapshot stores the normalized included-content selection separately from the files that happened to exist.
233
+ This preserves selected-but-missing paths without syncing `pi-sync.json`, storage credentials, automatic-sync preferences, or setup names.
234
+ **Settings → Compare synced content** opens the same review-first flow as a manager operation when local and remote lists differ.
235
+ Adoption revalidates the remote head, immutable snapshot, reviewed storage coordinates, and local include list before one atomic settings update.
236
+ The saved state changes only `sync.include`, preserves unknown settings fields, and never pulls files or writes sync state.
237
+ Its explicit Continue action starts a fresh **Sync now** route, while **Done** leaves the reviewed settings change saved without implying a file operation.
238
+ Keeping this device's list opens the reviewed force-push path and explicitly replaces the remote policy while preserving eligible unmanaged remote files.
239
+
240
+ Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather than silently expanding local scope.
241
+ Status reports matches and exact local-only/remote-only paths.
217
242
  Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
218
243
  clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
219
244
  preserved-unmanaged intent cannot be reconstructed. Use **Add custom path…** for any needed path.
@@ -297,7 +322,7 @@ The operational state root is `<agent-dir>/pi-sync/`. An existing installation c
297
322
 
298
323
  ## ♿ Conditional terminal accessibility smoke
299
324
 
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.
325
+ 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
326
 
302
327
  ## 🗂️ Package layout
303
328
 
@@ -313,6 +338,8 @@ packages/pi-sync/
313
338
  │ ├── state-directory.ts
314
339
  │ ├── settings-management.ts
315
340
  │ ├── manager-ui.ts
341
+ │ ├── manager-attention.ts
342
+ │ ├── sync-attention.ts
316
343
  │ ├── storage-connections-ui.ts
317
344
  │ ├── sync-setups-ui.ts
318
345
  │ ├── 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.8",
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,
@@ -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,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
+ }
@@ -7,7 +7,8 @@ import {
7
7
  } from "./config.js";
8
8
  import { inspectLock, isStaleLock } from "./lock.js";
9
9
  import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
10
- import { syncIncludeSelection } from "./sync-policy.js";
10
+ import { type SyncAttentionState, syncAttentionMatchesConfig } from "./sync-attention.js";
11
+ import { compareSyncInclude, syncIncludeSelection } from "./sync-policy.js";
11
12
  import { countValidSyncSetups } from "./sync-setups-ui.js";
12
13
  import type { AnySyncConfig } from "./types.js";
13
14
 
@@ -19,9 +20,18 @@ export const MAIN_MENU_ACTIONS = [
19
20
  "More…",
20
21
  ] as const;
21
22
 
23
+ export interface ManagerDescription {
24
+ title: string;
25
+ actions: string[];
26
+ attention?: SyncAttentionState;
27
+ attentionBlocksSync?: boolean;
28
+ attentionReviewDisabled?: boolean;
29
+ }
30
+
22
31
  export async function describeManagerState(
23
32
  signal?: AbortSignal,
24
- ): Promise<{ title: string; actions: string[] }> {
33
+ attention?: SyncAttentionState,
34
+ ): Promise<ManagerDescription> {
25
35
  let raw: Record<string, unknown> | undefined;
26
36
  try {
27
37
  raw = await readLocalConfigObject();
@@ -66,6 +76,25 @@ export async function describeManagerState(
66
76
  const recoverableLock =
67
77
  lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
68
78
  const selection = syncIncludeSelection(config.include);
79
+ let currentAttention: SyncAttentionState | undefined;
80
+ if (attention) {
81
+ try {
82
+ const attentionConfig =
83
+ attention.decision.setupName === config.setupName
84
+ ? config
85
+ : await loadConfig(attention.decision.setupName);
86
+ if (syncAttentionMatchesConfig(attention, attentionConfig)) currentAttention = attention;
87
+ } catch {
88
+ currentAttention = undefined;
89
+ }
90
+ if (signal?.aborted) throw signal.reason;
91
+ }
92
+ const attentionComparison = currentAttention
93
+ ? compareSyncInclude(
94
+ currentAttention.decision.localInclude,
95
+ currentAttention.decision.remoteInclude,
96
+ )
97
+ : undefined;
69
98
  const noSyncedContent = config.include.length === 0;
70
99
  const syncState =
71
100
  liveLock || recoverableLock
@@ -92,7 +121,18 @@ export async function describeManagerState(
92
121
  `Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
93
122
  `Automatic sync: ${config.automatic ? "On" : "Off"}`,
94
123
  `Last applied: ${lastAppliedSnapshot}`,
95
- "Remote status: Not checked",
124
+ ...(currentAttention
125
+ ? [
126
+ currentAttention.decision.setupName === config.setupName
127
+ ? "Sync status: Review needed"
128
+ : `Sync status: Review needed for setup ${safeTerminalText(currentAttention.decision.setupName)}`,
129
+ attentionComparison?.remoteOnly.length === 0 &&
130
+ attentionComparison.localOnly.length === 0
131
+ ? "Only the synced-content order differs."
132
+ : `Remote-only paths: ${attentionComparison?.remoteOnly.length ?? 0} · Device-only paths: ${attentionComparison?.localOnly.length ?? 0}`,
133
+ "Nothing has been changed.",
134
+ ]
135
+ : ["Remote status: Not checked"]),
96
136
  ...(noSyncedContent
97
137
  ? [
98
138
  "",
@@ -117,6 +157,13 @@ export async function describeManagerState(
117
157
  : noSyncedContent
118
158
  ? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
119
159
  : mainActions,
160
+ ...(currentAttention
161
+ ? {
162
+ attention: currentAttention,
163
+ attentionBlocksSync: currentAttention.decision.setupName === config.setupName,
164
+ attentionReviewDisabled: liveLock || recoverableLock,
165
+ }
166
+ : {}),
120
167
  };
121
168
  } catch (error) {
122
169
  if (signal?.aborted) throw error;