@narumitw/pi-sync 0.49.2 β†’ 0.49.4

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
@@ -11,8 +11,9 @@
11
11
  - Immutable snapshots, secret scanning, local locks, conflict checks, pull backups, transactional apply, and recovery journals.
12
12
  - Exact reviewed storage paths that do not change when a local sync setup is renamed.
13
13
  - One ordered `sync.include` list for Pi roots, safe agent-relative paths, and the privacy-sensitive `sessions` root.
14
+ - Portable, credential-free snapshot selection with explicit cross-environment review and adoption.
14
15
  - Atomic private settings writes that preserve unknown fields and reject stale concurrent edits.
15
- - Fail-closed validation for missing references, mixed backend fields, duplicate remote locations, unsafe paths, malformed credentials, and credential-bearing Git URLs.
16
+ - Fail-closed validation for missing references, mixed backend fields, duplicate remote locations, unsafe or oversized selection policies, malformed credentials, and credential-bearing Git URLs.
16
17
 
17
18
  ## πŸ“¦ Install
18
19
 
@@ -29,7 +30,7 @@ pi -e npm:@narumitw/pi-sync
29
30
  Try a local checkout:
30
31
 
31
32
  ```bash
32
- pi -e ./extensions/pi-sync
33
+ pi -e ./packages/pi-sync
33
34
  ```
34
35
 
35
36
  ## πŸš€ Quick start
@@ -63,7 +64,9 @@ and confirmations.
63
64
  When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
64
65
  direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
65
66
  error. The flow names the current sync setup and explains whether local content, remote content, or
66
- the included-content policy changed.
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
70
 
68
71
  Choose **Review differences (recommended)** to inspect the exact affected paths without changing
69
72
  local files, remote data, or sync state. Then choose one reviewed direction:
@@ -193,11 +196,28 @@ Safe agent-relative custom files or directories may also be included. Absolute p
193
196
  An empty array is valid. It means no useful transfer is selected: **Sync now** reports the condition and does not claim that the setup is up to date. Unselected content remains unmanaged locally and is preserved when republishing existing remote snapshots.
194
197
 
195
198
  The Included Content editor is a standard bounded multi-select backed by one in-memory draft. Toggles
196
- never write settings. Leaving the editor opens an exact Include/Exclude review with **Save changes**,
199
+ never write settings. **Add custom path…** accepts a safe agent-relative file or directory even when
200
+ it does not exist locally yet, allowing a new environment to select content that exists only in the
201
+ remote snapshot. Leaving the editor opens an exact Include/Exclude review with **Save changes**,
197
202
  **Discard changes**, and **Continue editing**; only reviewed Save publishes, while Continue preserves
198
203
  the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
199
204
  the manual `sync.include` path.
200
205
 
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.
217
+ Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
218
+ clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
219
+ preserved-unmanaged intent cannot be reconstructed. Use **Add custom path…** for any needed path.
220
+
201
221
  Adding `sessions` requires a privacy acknowledgement in interactive flows. Session JSONL can contain prompts, tool output, file paths, images, and secrets. Automatic apply protects the currently open session file; restart Pi or resume a pulled session to use newly synchronized conversations.
202
222
 
203
223
  ### Unsupported old settings and recovery
@@ -279,7 +299,7 @@ Pi exposes terminal components rather than a semantic/ARIA tree. Release validat
279
299
  ## πŸ—‚οΈ Package layout
280
300
 
281
301
  ```text
282
- extensions/pi-sync/
302
+ packages/pi-sync/
283
303
  β”œβ”€β”€ src/
284
304
  β”‚ β”œβ”€β”€ index.ts
285
305
  β”‚ β”œβ”€β”€ sync.ts
@@ -290,6 +310,8 @@ extensions/pi-sync/
290
310
  β”‚ β”œβ”€β”€ storage-connections-ui.ts
291
311
  β”‚ β”œβ”€β”€ sync-setups-ui.ts
292
312
  β”‚ β”œβ”€β”€ file-selection.ts
313
+ β”‚ β”œβ”€β”€ remote-selection-ui.ts
314
+ β”‚ β”œβ”€β”€ remote-snapshot.ts
293
315
  β”‚ β”œβ”€β”€ sync-operations.ts
294
316
  β”‚ β”œβ”€β”€ sync-backend.ts
295
317
  β”‚ β”œβ”€β”€ backend-factory.ts # lazy selected-backend loader
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.49.2",
3
+ "version": "0.49.4",
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",
@@ -30,6 +30,9 @@
30
30
  "./src/index.ts"
31
31
  ]
32
32
  },
33
+ "piExtension": {
34
+ "lifecycle": "stable"
35
+ },
33
36
  "scripts": {
34
37
  "check": "biome check . && npm run typecheck",
35
38
  "format": "biome check --write .",
@@ -41,9 +44,9 @@
41
44
  "proper-lockfile": "^4.1.2"
42
45
  },
43
46
  "devDependencies": {
44
- "@biomejs/biome": "2.5.6",
45
- "@earendil-works/pi-coding-agent": "0.83.0",
46
- "@earendil-works/pi-tui": "0.83.0",
47
+ "@biomejs/biome": "2.5.7",
48
+ "@earendil-works/pi-coding-agent": "0.84.1",
49
+ "@earendil-works/pi-tui": "0.84.1",
47
50
  "@types/proper-lockfile": "^4.1.4",
48
51
  "typescript": "7.0.2"
49
52
  },
@@ -54,6 +57,6 @@
54
57
  "repository": {
55
58
  "type": "git",
56
59
  "url": "https://github.com/narumiruna/pi-extensions",
57
- "directory": "extensions/pi-sync"
60
+ "directory": "packages/pi-sync"
58
61
  }
59
62
  }
@@ -6,12 +6,14 @@ import { updateSyncSetup } from "./settings-management.js";
6
6
  import {
7
7
  BUILT_IN_SYNC_ROOTS,
8
8
  isSafeCustomIncludePath,
9
+ normalizeSyncInclude,
9
10
  syncIncludeSelection,
10
11
  } from "./sync-policy.js";
11
12
 
12
13
  const BUILT_IN_PREFIX = "builtin:";
13
14
  const CUSTOM_PREFIX = "custom:";
14
15
  const SESSIONS_ID = "sessions";
16
+ const ADD_CUSTOM_ID = "add-custom";
15
17
 
16
18
  interface SelectionDraft {
17
19
  builtIns: Set<string>;
@@ -107,7 +109,7 @@ async function showDraftEditor(
107
109
  customCandidates: string[],
108
110
  signal?: AbortSignal,
109
111
  ) {
110
- const menu = defineMenu<undefined, "editor", "toggle", ExtensionCommandContext>({
112
+ const menu = defineMenu<undefined, "editor", "toggle" | "addCustom", ExtensionCommandContext>({
111
113
  start: "editor",
112
114
  screens: {
113
115
  editor: () => ({
@@ -139,6 +141,15 @@ async function showDraftEditor(
139
141
  },
140
142
  ],
141
143
  action: "toggle",
144
+ actions: [
145
+ {
146
+ id: ADD_CUSTOM_ID,
147
+ label: "Add custom path…",
148
+ description:
149
+ "Include an agent-relative file or directory even when it exists only remotely.",
150
+ action: "addCustom",
151
+ },
152
+ ],
142
153
  hint: "close",
143
154
  doneLabel: "Review changes",
144
155
  }),
@@ -148,6 +159,45 @@ async function showDraftEditor(
148
159
  updateDraft(draft, itemId, selected === true);
149
160
  return { kind: "stay" };
150
161
  },
162
+ addCustom: async ({ ctx: actionCtx, signal: actionSignal }) => {
163
+ const entered = await actionCtx.ui.input(
164
+ "Add included content",
165
+ "Agent-relative path, for example custom.toml or snippets",
166
+ { signal: actionSignal },
167
+ );
168
+ if (actionSignal.aborted) return { kind: "stay" };
169
+ const requested = entered?.trim();
170
+ if (!requested) return { kind: "stay" };
171
+ const existing = customCandidates.find(
172
+ (candidate) => candidate.toLowerCase() === requested.toLowerCase(),
173
+ );
174
+ const relativePath = existing ?? requested;
175
+ if (draft.custom.has(relativePath)) return { kind: "stay" };
176
+ try {
177
+ if (!isSafeCustomIncludePath(relativePath)) {
178
+ throw new Error("Enter a safe agent-relative file or directory path.");
179
+ }
180
+ normalizeSyncInclude([
181
+ ...draft.builtIns,
182
+ ...draft.custom,
183
+ ...(draft.sessions ? ["sessions"] : []),
184
+ relativePath,
185
+ ]);
186
+ } catch (error) {
187
+ if (actionSignal.aborted) return { kind: "stay" };
188
+ actionCtx.ui.notify(
189
+ `Could not add included content: ${safeTerminalText(error instanceof Error ? error.message : String(error))}`,
190
+ "error",
191
+ );
192
+ return { kind: "stay" };
193
+ }
194
+ if (!existing) {
195
+ customCandidates.push(relativePath);
196
+ customCandidates.sort((left, right) => left.localeCompare(right));
197
+ }
198
+ draft.custom.add(relativePath);
199
+ return { kind: "stay" };
200
+ },
151
201
  },
152
202
  });
153
203
  await runMenu(ctx, menu, {
@@ -140,6 +140,7 @@ export class GitSyncBackend implements SyncBackend {
140
140
  ...(manifest.snapshotSyncSessions === undefined
141
141
  ? {}
142
142
  : { syncSessions: manifest.snapshotSyncSessions }),
143
+ ...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
143
144
  files,
144
145
  };
145
146
  validateGitSnapshot(snapshot, manifest, this.config.destination.namespace);
@@ -174,6 +175,7 @@ export class GitSyncBackend implements SyncBackend {
174
175
  ...(snapshot.syncSessions === undefined
175
176
  ? {}
176
177
  : { snapshotSyncSessions: snapshot.syncSessions }),
178
+ ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
177
179
  files: files.map(({ path: filePath, sha256: fileSha, size }) => ({
178
180
  path: filePath,
179
181
  sha256: fileSha,
@@ -688,6 +690,7 @@ function remoteHead(sha: string, manifest: GitManifest, identity: string): Remot
688
690
  createdAt: manifest.createdAt,
689
691
  machine: manifest.machine,
690
692
  syncSessions: manifest.syncSessions,
693
+ ...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
691
694
  };
692
695
  }
693
696
 
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { Snapshot } from "./types.js";
2
+ import { portableSnapshotSelection, snapshotSelectionInclude } from "./sync-policy.js";
3
+ import type { Snapshot, SnapshotSelection } from "./types.js";
3
4
 
4
5
  export const GIT_MANIFEST_VERSION = 2;
5
6
  export const MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
@@ -23,6 +24,7 @@ export interface GitManifest {
23
24
  profile: string;
24
25
  syncSessions: boolean;
25
26
  snapshotSyncSessions?: boolean;
27
+ selection?: SnapshotSelection;
26
28
  files: GitManifestFile[];
27
29
  }
28
30
 
@@ -81,11 +83,13 @@ export function requireGitManifest(value: unknown): GitManifest {
81
83
  "profile",
82
84
  "syncSessions",
83
85
  ...(manifest.snapshotSyncSessions === undefined ? [] : ["snapshotSyncSessions"]),
86
+ ...(manifest.selection === undefined ? [] : ["selection"]),
84
87
  "files",
85
88
  ])
86
89
  ) {
87
90
  throw new Error("Git publication manifest is malformed.");
88
91
  }
92
+ if (manifest.selection !== undefined) portableSnapshotSelection(manifest.selection);
89
93
  let total = 0;
90
94
  const paths = new Set<string>();
91
95
  for (const rawFile of manifest.files) {
@@ -126,6 +130,12 @@ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, n
126
130
  snapshot.profile !== manifest.profile ||
127
131
  snapshot.syncSessions !== manifest.snapshotSyncSessions ||
128
132
  syncSessions !== manifest.syncSessions ||
133
+ !sameOptionalInclude(
134
+ snapshotSelectionInclude(snapshot),
135
+ manifest.selection === undefined
136
+ ? undefined
137
+ : portableSnapshotSelection(manifest.selection).include,
138
+ ) ||
129
139
  prepared.length !== manifest.files.length ||
130
140
  prepared.some((file, index) => {
131
141
  const expected = manifest.files[index];
@@ -142,6 +152,7 @@ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, n
142
152
  }
143
153
 
144
154
  export function prepareGitSnapshot(snapshot: Snapshot, namespace: string) {
155
+ snapshotSelectionInclude(snapshot);
145
156
  if (
146
157
  snapshot.version !== SNAPSHOT_VERSION ||
147
158
  typeof snapshot.id !== "string" ||
@@ -242,6 +253,11 @@ export function validateGitPublicationTree(
242
253
  return manifest.files.map((file) => byPath.get(filePath(file.path)) as GitTreeEntry);
243
254
  }
244
255
 
256
+ function sameOptionalInclude(left: string[] | undefined, right: string[] | undefined) {
257
+ if (!left || !right) return left === right;
258
+ return left.length === right.length && left.every((item, index) => item === right[index]);
259
+ }
260
+
245
261
  function isSafeSnapshotPath(value: unknown): value is string {
246
262
  return (
247
263
  typeof value === "string" &&
@@ -0,0 +1,279 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import { createSyncBackend, type SyncBackendFactory } from "./backend-factory.js";
4
+ import { loadConfig, loadPartialConfig } from "./config.js";
5
+ import { readSnapshotForHead } from "./remote-snapshot.js";
6
+ import { updateSyncSetup } from "./settings-management.js";
7
+ import type { RemoteHead, SyncBackend } from "./sync-backend.js";
8
+ import { errorMessage, safeTerminalText } from "./sync-format.js";
9
+ import {
10
+ inspectRemoteSelection,
11
+ type RemoteSelectionState,
12
+ sameSyncInclude,
13
+ } from "./sync-policy.js";
14
+ import type { AnySyncConfig } from "./types.js";
15
+
16
+ const STATUS_KEY = "sync";
17
+
18
+ export async function showRemoteSelectionReview(
19
+ ctx: ExtensionCommandContext,
20
+ setupName?: string,
21
+ signal?: AbortSignal,
22
+ factory: SyncBackendFactory = createSyncBackend,
23
+ ) {
24
+ try {
25
+ const config = await loadConfig(setupName);
26
+ if (signal?.aborted) return;
27
+ const storageReview = await loadPartialConfig(config.setupName);
28
+ if (signal?.aborted) return;
29
+ ctx.ui.setStatus(STATUS_KEY, `checking remote selection for ${config.setupName}`);
30
+ const backend = await factory(config);
31
+ if (signal?.aborted) return;
32
+ const head = await backend.readHead(signal);
33
+ if (signal?.aborted) return;
34
+ if (!head) {
35
+ ctx.ui.setStatus(STATUS_KEY, undefined);
36
+ ctx.ui.notify("Remote storage has no snapshot or included-content policy yet.", "info");
37
+ return;
38
+ }
39
+ const snapshot = await readSnapshotForHead(backend, head, signal);
40
+ if (signal?.aborted) return;
41
+ const state = inspectRemoteSelection(config.include, snapshot);
42
+ ctx.ui.setStatus(STATUS_KEY, undefined);
43
+
44
+ if (ctx.mode !== "tui") {
45
+ ctx.ui.notify(
46
+ formatRemoteSelectionSummary(config, state),
47
+ state.kind === "different" ? "warning" : "info",
48
+ );
49
+ return;
50
+ }
51
+ if (state.kind === "same") {
52
+ ctx.ui.notify("Remote included content already matches this sync setup.", "info");
53
+ return;
54
+ }
55
+ if (state.kind === "legacy") {
56
+ await showLegacyDiscovery(ctx, config, state.discovered, signal);
57
+ return;
58
+ }
59
+ await showSelectionDifference(ctx, config, storageReview, backend, head, state, signal);
60
+ } catch (error) {
61
+ if (signal?.aborted) return;
62
+ ctx.ui.notify(`Could not review remote included content: ${errorMessage(error)}`, "error");
63
+ } finally {
64
+ ctx.ui.setStatus(STATUS_KEY, undefined);
65
+ }
66
+ }
67
+
68
+ async function showSelectionDifference(
69
+ ctx: ExtensionCommandContext,
70
+ config: AnySyncConfig,
71
+ storageReview: Awaited<ReturnType<typeof loadPartialConfig>>,
72
+ backend: SyncBackend,
73
+ reviewedHead: RemoteHead,
74
+ state: Extract<RemoteSelectionState, { kind: "different" }>,
75
+ sessionSignal?: AbortSignal,
76
+ ) {
77
+ type Screen = "choice" | "review";
78
+ type Action = "adopt" | "keep" | "cancel";
79
+ const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
80
+ start: "choice",
81
+ screens: {
82
+ choice: () => ({
83
+ kind: "actions",
84
+ title: `Remote included content Β· ${safeTerminalText(config.setupName)}`,
85
+ lines: [
86
+ "The remote selection differs from this local sync setup.",
87
+ "No settings or files have been changed.",
88
+ ],
89
+ items: [
90
+ {
91
+ id: "adopt",
92
+ label: "Adopt remote included content",
93
+ description: "Save the reviewed remote paths locally without pulling files.",
94
+ action: "adopt",
95
+ },
96
+ {
97
+ id: "keep",
98
+ label: "Keep local included content",
99
+ description: "Make no settings change; a reviewed force push can publish it later.",
100
+ action: "keep",
101
+ },
102
+ {
103
+ id: "review",
104
+ label: "Review paths",
105
+ description: "Compare exact local-only and remote-only selection paths.",
106
+ to: "review",
107
+ },
108
+ { id: "cancel", label: "Cancel", action: "cancel" },
109
+ ],
110
+ hint: "close",
111
+ }),
112
+ review: () => ({
113
+ kind: "review",
114
+ title: `Review remote included content Β· ${safeTerminalText(config.setupName)}`,
115
+ content: formatSelectionDifference(config.include, state),
116
+ format: { kind: "text" },
117
+ viewportSize: "adaptive",
118
+ hint: "back",
119
+ }),
120
+ },
121
+ actions: {
122
+ adopt: async ({ signal: actionSignal }) => {
123
+ const signal = sessionSignal
124
+ ? AbortSignal.any([sessionSignal, actionSignal])
125
+ : actionSignal;
126
+ try {
127
+ if (!config.include.includes("sessions") && state.include.includes("sessions")) {
128
+ const acknowledged = await ctx.ui.confirm(
129
+ "Adopt session conversations?",
130
+ "Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the selection only; it does not pull files.",
131
+ { signal },
132
+ );
133
+ if (signal.aborted) return { kind: "close" as const };
134
+ if (!acknowledged) return { kind: "stay" as const };
135
+ }
136
+ if (signal.aborted) return { kind: "close" as const };
137
+ const currentHead = await backend.readHead(signal);
138
+ if (signal.aborted) return { kind: "close" as const };
139
+ if (!currentHead || !backend.sameRevision(reviewedHead.revision, currentHead.revision)) {
140
+ throw new Error(
141
+ "Remote changed while the included-content review was open; reopen it.",
142
+ );
143
+ }
144
+ const currentSnapshot = await readSnapshotForHead(backend, currentHead, signal);
145
+ if (signal.aborted) return { kind: "close" as const };
146
+ const currentState = inspectRemoteSelection(config.include, currentSnapshot);
147
+ if (
148
+ currentState.kind === "legacy" ||
149
+ !sameSyncInclude(currentState.include, state.include)
150
+ ) {
151
+ throw new Error(
152
+ "Remote included content changed while the review was open; reopen it.",
153
+ );
154
+ }
155
+ await updateSyncSetup(
156
+ config.setupName,
157
+ (setup) => ({
158
+ ...setup,
159
+ sync: { ...setup.sync, include: [...state.include] },
160
+ }),
161
+ {
162
+ expectedStorage: storageReview,
163
+ expectedInclude: config.include,
164
+ signal,
165
+ },
166
+ );
167
+ if (signal.aborted) return { kind: "close" as const };
168
+ ctx.ui.notify(
169
+ `Saved remote included content for sync setup β€œ${safeTerminalText(config.setupName)}”. No files were pulled; review Sync now separately.`,
170
+ "info",
171
+ );
172
+ return { kind: "close" as const };
173
+ } catch (error) {
174
+ if (signal.aborted) return { kind: "close" as const };
175
+ ctx.ui.notify(`Could not adopt remote included content: ${errorMessage(error)}`, "error");
176
+ return { kind: "close" as const };
177
+ }
178
+ },
179
+ keep: async () => {
180
+ ctx.ui.notify(
181
+ "Kept local included content. No settings or files changed; use a reviewed force push to publish the local selection.",
182
+ "info",
183
+ );
184
+ return { kind: "close" };
185
+ },
186
+ cancel: async () => ({ kind: "close" }),
187
+ },
188
+ });
189
+ await runMenu(ctx, menu, {
190
+ getState: () => undefined,
191
+ signal: sessionSignal,
192
+ isCurrent: () => !sessionSignal?.aborted,
193
+ });
194
+ }
195
+
196
+ async function showLegacyDiscovery(
197
+ ctx: ExtensionCommandContext,
198
+ config: AnySyncConfig,
199
+ discovered: string[],
200
+ signal?: AbortSignal,
201
+ ) {
202
+ const menu = defineMenu<undefined, "choice" | "review", "back", ExtensionCommandContext>({
203
+ start: "choice",
204
+ screens: {
205
+ choice: () => ({
206
+ kind: "actions",
207
+ title: `Remote included content Β· ${safeTerminalText(config.setupName)}`,
208
+ lines: [
209
+ "This legacy snapshot has no portable included-content policy.",
210
+ "Discovered paths are partial and read-only; preserved files may not have been selected.",
211
+ ],
212
+ items: [
213
+ { id: "review", label: "Review discovered paths", to: "review" },
214
+ { id: "back", label: "Back", action: "back" },
215
+ ],
216
+ hint: "close",
217
+ }),
218
+ review: () => ({
219
+ kind: "review",
220
+ title: "Partial discovery from legacy snapshot",
221
+ content: [
222
+ "Partial discovery only β€” not an authoritative selection.",
223
+ "",
224
+ ...(discovered.length > 0
225
+ ? discovered.map((item) => `Discovered: ${safeTerminalText(item)}`)
226
+ : ["No safe paths were discovered."]),
227
+ "",
228
+ "Use Add custom path… in the local Included Content editor if needed.",
229
+ ].join("\n"),
230
+ format: { kind: "text" },
231
+ viewportSize: "adaptive",
232
+ hint: "back",
233
+ }),
234
+ },
235
+ actions: { back: async () => ({ kind: "close" }) },
236
+ });
237
+ await runMenu(ctx, menu, {
238
+ getState: () => undefined,
239
+ signal,
240
+ isCurrent: () => !signal?.aborted,
241
+ });
242
+ }
243
+
244
+ function formatSelectionDifference(
245
+ localInclude: readonly string[],
246
+ state: Extract<RemoteSelectionState, { kind: "different" }>,
247
+ ) {
248
+ return [
249
+ "Remote-only selection:",
250
+ ...(state.remoteOnly.length > 0
251
+ ? state.remoteOnly.map((item) => `+ ${safeTerminalText(item)}`)
252
+ : ["(none)"]),
253
+ "",
254
+ "Local-only selection:",
255
+ ...(state.localOnly.length > 0
256
+ ? state.localOnly.map((item) => `- ${safeTerminalText(item)}`)
257
+ : ["(none)"]),
258
+ "",
259
+ `Remote order: ${state.include.map(safeTerminalText).join(", ") || "none"}`,
260
+ `Local order: ${localInclude.map(safeTerminalText).join(", ") || "none"}`,
261
+ "",
262
+ "Adopting saves settings only and does not pull files.",
263
+ ].join("\n");
264
+ }
265
+
266
+ function formatRemoteSelectionSummary(config: AnySyncConfig, state: RemoteSelectionState) {
267
+ if (state.kind === "same") {
268
+ return `Remote included content for β€œ${safeTerminalText(config.setupName)}” matches local settings.`;
269
+ }
270
+ if (state.kind === "legacy") {
271
+ return `Remote snapshot for β€œ${safeTerminalText(config.setupName)}” has no portable included-content policy; ${state.discovered.length} safe path${state.discovered.length === 1 ? " was" : "s were"} discovered, but the result is partial and read-only.`;
272
+ }
273
+ return [
274
+ `Remote included content for β€œ${safeTerminalText(config.setupName)}” differs from local settings.`,
275
+ `Remote-only: ${state.remoteOnly.map(safeTerminalText).join(", ") || "none"}`,
276
+ `Local-only: ${state.localOnly.map(safeTerminalText).join(", ") || "none"}`,
277
+ "Use TUI Settings to review or adopt it; RPC is read-only.",
278
+ ].join("\n");
279
+ }
@@ -0,0 +1,59 @@
1
+ import type { RemoteHead, SyncBackend } from "./sync-backend.js";
2
+ import { safeTerminalText } from "./sync-format.js";
3
+ import {
4
+ inspectRemoteSelection,
5
+ RemoteSelectionMismatchError,
6
+ type RemoteSelectionState,
7
+ sameSyncInclude,
8
+ snapshotSelectionInclude,
9
+ } from "./sync-policy.js";
10
+ import type { AnySyncConfig, Snapshot } from "./types.js";
11
+
12
+ export async function readSnapshotForHead(
13
+ backend: SyncBackend,
14
+ head: RemoteHead,
15
+ signal?: AbortSignal,
16
+ ) {
17
+ const snapshot = await backend.readSnapshot(head.snapshotRef, signal);
18
+ if (signal?.aborted) {
19
+ throw signal.reason instanceof Error
20
+ ? signal.reason
21
+ : new DOMException("The operation was aborted", "AbortError");
22
+ }
23
+ if (snapshot.id !== head.snapshotId) {
24
+ throw new Error(
25
+ `Remote head ${head.snapshotId} resolved to unexpected snapshot ${snapshot.id}.`,
26
+ );
27
+ }
28
+ if (head.selection) {
29
+ const snapshotInclude = snapshotSelectionInclude(snapshot);
30
+ if (!snapshotInclude || !sameSyncInclude(head.selection.include, snapshotInclude)) {
31
+ throw new Error("Remote head selection does not match its immutable snapshot.");
32
+ }
33
+ }
34
+ return snapshot;
35
+ }
36
+
37
+ export function requireCompatibleRemoteSelection(config: AnySyncConfig, snapshot: Snapshot) {
38
+ const state = inspectRemoteSelection(config.include, snapshot);
39
+ if (state.kind === "different") {
40
+ throw new RemoteSelectionMismatchError(config.include, state.include);
41
+ }
42
+ }
43
+
44
+ export function formatRemoteSelectionStatus(state: RemoteSelectionState | undefined) {
45
+ if (!state) return "remote included content: unavailable (remote is empty)";
46
+ if (state.kind === "same") return "remote included content: matches this setup";
47
+ if (state.kind === "legacy") {
48
+ return `remote included content: unavailable in legacy snapshot (${state.discovered.length} path${state.discovered.length === 1 ? "" : "s"} discovered, partial)`;
49
+ }
50
+ return [
51
+ "remote included content: differs from this setup",
52
+ `remote-only selection: ${safeList(state.remoteOnly)}`,
53
+ `local-only selection: ${safeList(state.localOnly)}`,
54
+ ].join("\n");
55
+ }
56
+
57
+ function safeList(values: readonly string[]) {
58
+ return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
59
+ }
package/src/s3-backend.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  SyncBackendConflictError,
15
15
  SyncBackendPublicationOutcomeUnknownError,
16
16
  } from "./sync-backend.js";
17
+ import { portableSnapshotSelection } from "./sync-policy.js";
17
18
  import type { LatestPointer, RemoteObject, ResolvedS3Backend, Snapshot } from "./types.js";
18
19
 
19
20
  const VERSION = 1;
@@ -274,6 +275,7 @@ export function pointerFor(
274
275
  syncSessions:
275
276
  snapshot.syncSessions === true ||
276
277
  snapshot.files.some((file) => file.path.startsWith("sessions/")),
278
+ ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
277
279
  };
278
280
  }
279
281
 
@@ -330,6 +332,7 @@ function remoteHead(pointer: LatestPointer, identity: string, etag?: string): Re
330
332
  createdAt: pointer.createdAt,
331
333
  machine: pointer.machine,
332
334
  syncSessions: pointer.syncSessions === true,
335
+ ...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
333
336
  };
334
337
  }
335
338
 
@@ -377,6 +380,7 @@ function requirePointer(
377
380
  ) {
378
381
  throw new Error(message);
379
382
  }
383
+ if (value.selection !== undefined) portableSnapshotSelection(value.selection);
380
384
  return value;
381
385
  }
382
386
 
@@ -25,7 +25,7 @@ export async function showSyncSettings(
25
25
  const initial = await loadConfig();
26
26
  if (signal?.aborted) return;
27
27
  const setupName = initial.setupName;
28
- type Action = "automatic" | "on-switch" | "include";
28
+ type Action = "automatic" | "on-switch" | "include" | "remote-include";
29
29
  const menu = defineMenu<
30
30
  Awaited<ReturnType<typeof loadConfig>>,
31
31
  "settings",
@@ -65,6 +65,14 @@ export async function showSyncSettings(
65
65
  currentValue: "Open editor",
66
66
  action: "include",
67
67
  },
68
+ {
69
+ id: "remoteInclude",
70
+ label: "Remote included content",
71
+ description:
72
+ "Compare portable remote selection, then adopt it or keep local settings without pulling files.",
73
+ currentValue: "Review",
74
+ action: "remote-include",
75
+ },
68
76
  ],
69
77
  }),
70
78
  },
@@ -114,6 +122,13 @@ export async function showSyncSettings(
114
122
  await runRoute("files", editorSignal, undefined, setupName);
115
123
  return editorSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
116
124
  },
125
+ "remote-include": async ({ signal: actionSignal }) => {
126
+ const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
127
+ const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
128
+ if (reviewSignal.aborted) return { kind: "rejected" };
129
+ await showRemoteSelectionReview(ctx, setupName, reviewSignal);
130
+ return reviewSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
131
+ },
117
132
  },
118
133
  });
119
134
  await runMenu(ctx, menu, {
@@ -2,6 +2,7 @@ import { Readable, Writable } from "node:stream";
2
2
  import { pipeline } from "node:stream/promises";
3
3
  import { promisify } from "node:util";
4
4
  import { createGunzip, gzip } from "node:zlib";
5
+ import { snapshotSelectionInclude } from "./sync-policy.js";
5
6
  import type { Snapshot } from "./types.js";
6
7
 
7
8
  const VERSION = 1;
@@ -9,6 +10,7 @@ const MAX_DECOMPRESSED_SNAPSHOT_BYTES = 512 * 1024 * 1024;
9
10
  const gzipAsync = promisify(gzip);
10
11
 
11
12
  export async function encodeSnapshot(snapshot: Snapshot) {
13
+ snapshotSelectionInclude(snapshot);
12
14
  return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
13
15
  }
14
16
 
@@ -37,6 +39,7 @@ export async function decodeSnapshot(
37
39
  if (parsed.version !== VERSION || !Array.isArray(parsed.files)) {
38
40
  throw new Error("Unsupported snapshot format.");
39
41
  }
42
+ snapshotSelectionInclude(parsed);
40
43
  return parsed;
41
44
  }
42
45
 
package/src/snapshot.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  normalizeExtraFiles,
19
19
  normalizeSyncFiles,
20
20
  type SyncSelectionConfig,
21
+ selectionForSnapshot,
22
+ snapshotSelectionInclude,
21
23
  syncIncludeSelection,
22
24
  } from "./sync-policy.js";
23
25
  import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
@@ -74,6 +76,8 @@ function snapshotsMatch(left: Snapshot, right: Snapshot) {
74
76
  const leftHashes = new Map(left.files.map((file) => [file.path, file.sha256]));
75
77
  const rightHashes = new Map(right.files.map((file) => [file.path, file.sha256]));
76
78
  return (
79
+ left.syncSessions === right.syncSessions &&
80
+ sameOptionalInclude(snapshotSelectionInclude(left), snapshotSelectionInclude(right)) &&
77
81
  leftHashes.size === rightHashes.size &&
78
82
  [...leftHashes].every(([filePath, hash]) => rightHashes.get(filePath) === hash)
79
83
  );
@@ -109,6 +113,7 @@ export async function createSnapshot(
109
113
  machine: os.hostname(),
110
114
  profile,
111
115
  syncSessions,
116
+ selection: selectionForSnapshot(include),
112
117
  files,
113
118
  };
114
119
  }
@@ -249,7 +254,11 @@ export function snapshotTarget(root: string, relativePath: string, configuredSes
249
254
  }
250
255
 
251
256
  export function snapshotIncludesSessions(snapshot: Snapshot) {
252
- return snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
257
+ return (
258
+ snapshot.syncSessions === true ||
259
+ snapshotSelectionInclude(snapshot)?.includes("sessions") === true ||
260
+ snapshot.files.some((file) => isSessionPath(file.path))
261
+ );
253
262
  }
254
263
 
255
264
  export function filterSnapshotForConfigPolicy(
@@ -262,6 +271,7 @@ export function filterSnapshotForConfigPolicy(
262
271
  const filtered = {
263
272
  ...snapshot,
264
273
  syncSessions: include.includes("sessions") ? snapshot.syncSessions : false,
274
+ selection: selectionForSnapshot(include),
265
275
  files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, includePaths),
266
276
  };
267
277
  if (!options.regenerateId || snapshotsMatch(snapshot, filtered)) return filtered;
@@ -319,13 +329,21 @@ function isPreferredExtraCandidate(
319
329
 
320
330
  export function snapshotWithoutSessions(snapshot: Snapshot) {
321
331
  const files = snapshot.files.filter((file) => !isSessionPath(file.path));
322
- if (files.length === snapshot.files.length && snapshot.syncSessions !== true) return snapshot;
332
+ const include = snapshotSelectionInclude(snapshot)?.filter((item) => item !== "sessions");
333
+ if (
334
+ files.length === snapshot.files.length &&
335
+ snapshot.syncSessions !== true &&
336
+ include?.length === snapshot.selection?.include.length
337
+ ) {
338
+ return snapshot;
339
+ }
323
340
  return {
324
341
  ...snapshot,
325
342
  id: snapshotId(),
326
343
  createdAt: new Date().toISOString(),
327
344
  machine: os.hostname(),
328
345
  syncSessions: false,
346
+ ...(include ? { selection: selectionForSnapshot(include) } : {}),
329
347
  files,
330
348
  };
331
349
  }
@@ -387,14 +405,27 @@ export function mergeRemoteSessionFiles(local: Snapshot, remote: Snapshot) {
387
405
  return isSessionFilePath(normalized) && isSafeSnapshotPath(file.path);
388
406
  });
389
407
  if (remoteSessions.length === 0 && !snapshotIncludesSessions(remote)) return local;
408
+ const localInclude = snapshotSelectionInclude(local);
390
409
  return {
391
410
  ...local,
392
411
  id: snapshotId(),
393
412
  createdAt: new Date().toISOString(),
394
413
  machine: os.hostname(),
395
414
  syncSessions: true,
415
+ ...(localInclude
416
+ ? {
417
+ selection: selectionForSnapshot(
418
+ localInclude.includes("sessions") ? localInclude : [...localInclude, "sessions"],
419
+ ),
420
+ }
421
+ : {}),
396
422
  files: [...local.files.filter((file) => !isSessionPath(file.path)), ...remoteSessions].sort(
397
423
  (left, right) => left.path.localeCompare(right.path),
398
424
  ),
399
425
  };
400
426
  }
427
+
428
+ function sameOptionalInclude(left: string[] | undefined, right: string[] | undefined) {
429
+ if (!left || !right) return left === right;
430
+ return left.length === right.length && left.every((item, index) => item === right[index]);
431
+ }
@@ -1,4 +1,4 @@
1
- import type { Snapshot } from "./types.js";
1
+ import type { Snapshot, SnapshotSelection } from "./types.js";
2
2
 
3
3
  export type PublicationCapability =
4
4
  | "read-check-write-verify"
@@ -15,6 +15,8 @@ export interface RemoteHead {
15
15
  createdAt: string;
16
16
  machine: string;
17
17
  syncSessions: boolean;
18
+ /** Validated lightweight projection; revalidate it against the immutable snapshot before adoption. */
19
+ selection?: SnapshotSelection;
18
20
  }
19
21
 
20
22
  export interface RemoteHistoryEntry {
@@ -1,5 +1,6 @@
1
1
  import { agentDir } from "./config.js";
2
2
  import type { PublicationCapability, RemoteHead } from "./sync-backend.js";
3
+ import { inspectRemoteSelection } from "./sync-policy.js";
3
4
  import { fileHashMap } from "./sync-state.js";
4
5
  import type { CommonSyncConfig, Snapshot } from "./types.js";
5
6
 
@@ -50,6 +51,11 @@ export function formatPushSummary(
50
51
  `Sessions: ${upload.syncSessions ? "included β€” may contain private conversations" : "not included"}`,
51
52
  head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
52
53
  "Publication effect: the backend's active head will reference the new immutable snapshot.",
54
+ ...(remote && inspectRemoteSelection(config.include, remote).kind === "different"
55
+ ? [
56
+ "Included-content policy effect: replace the differing remote selection with this setup's local selection.",
57
+ ]
58
+ : []),
53
59
  formatPublicationPreview(remote, upload),
54
60
  preservedRemoteFileCount > 0
55
61
  ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
@@ -11,6 +11,11 @@ import {
11
11
  writeStateForConfig,
12
12
  } from "./config.js";
13
13
  import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "./lock.js";
14
+ import {
15
+ formatRemoteSelectionStatus,
16
+ readSnapshotForHead,
17
+ requireCompatibleRemoteSelection,
18
+ } from "./remote-snapshot.js";
14
19
  import {
15
20
  createSnapshot,
16
21
  filterSnapshotForConfigPolicy,
@@ -41,6 +46,7 @@ import {
41
46
  publicationCapabilityDescription,
42
47
  safeTerminalText,
43
48
  } from "./sync-format.js";
49
+ import { inspectRemoteSelection, RemoteSelectionMismatchError } from "./sync-policy.js";
44
50
  import {
45
51
  canPullRemoteSessionsOnFirstSync,
46
52
  canPullRemoteSettingsOnFirstSync,
@@ -124,6 +130,9 @@ export async function status(
124
130
  throwIfAborted(options.signal);
125
131
  const head = await backend.readHead(options.signal);
126
132
  throwIfAborted(options.signal);
133
+ const selectionState = head
134
+ ? inspectRemoteSelection(config.include, { selection: head.selection, files: [] })
135
+ : undefined;
127
136
  const localChanged = hasLocalChanges(local, state, config);
128
137
 
129
138
  const remoteText = head
@@ -143,12 +152,15 @@ export async function status(
143
152
  `included content: ${config.include.join(", ") || "none"}`,
144
153
  `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
145
154
  remoteText,
155
+ formatRemoteSelectionStatus(selectionState),
146
156
  `local files: ${local.files.length}`,
147
157
  `local changed since last sync: ${localChanged ? "yes" : "no"}`,
148
158
  `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
149
159
  ...warnings,
150
160
  ].join("\n"),
151
- localChanged || remoteChanged || warnings.length > 0 ? "warning" : "info",
161
+ localChanged || remoteChanged || selectionState?.kind === "different" || warnings.length > 0
162
+ ? "warning"
163
+ : "info",
152
164
  );
153
165
  }
154
166
 
@@ -166,7 +178,12 @@ export async function diff(
166
178
  snapshotOptionsForContext(ctx, config),
167
179
  );
168
180
  throwIfAborted(options.signal);
169
- const { snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
181
+ const { snapshot: remote, selectionState } = await readRemoteSnapshot(
182
+ backend,
183
+ config,
184
+ options.signal,
185
+ { allowSelectionDifference: true },
186
+ );
170
187
  throwIfAborted(options.signal);
171
188
  ctx.ui.setStatus(STATUS_KEY, undefined);
172
189
 
@@ -177,9 +194,10 @@ export async function diff(
177
194
  `storage location: ${safeTerminalText(backend.destination)}`,
178
195
  `included content: ${config.include.join(", ") || "none"}`,
179
196
  `sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
197
+ formatRemoteSelectionStatus(selectionState),
180
198
  ...warnings,
181
199
  ].join("\n");
182
- const level = warnings.length > 0 ? "warning" : "info";
200
+ const level = warnings.length > 0 || selectionState?.kind === "different" ? "warning" : "info";
183
201
  if (!remote) {
184
202
  ctx.ui.notify(
185
203
  `${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
@@ -299,6 +317,18 @@ export async function push(
299
317
  state,
300
318
  options.signal,
301
319
  );
320
+ if (
321
+ !options.force &&
322
+ !remoteForUpload &&
323
+ head?.selection &&
324
+ inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind ===
325
+ "different"
326
+ ) {
327
+ remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
328
+ }
329
+ if (remoteForUpload && !options.force) {
330
+ requireCompatibleRemoteSelection(config, remoteForUpload);
331
+ }
302
332
  if (
303
333
  remoteChangedSinceState(head, state, config, (left, right) =>
304
334
  backend.sameRevision(left, right),
@@ -886,16 +916,20 @@ async function readRemoteSnapshot(
886
916
  backend: SyncBackend,
887
917
  config: AnySyncConfig,
888
918
  signal?: AbortSignal,
919
+ options: { allowSelectionDifference?: boolean } = {},
889
920
  ) {
890
921
  const head = await backend.readHead(signal);
891
- if (!head) return { head: undefined, snapshot: undefined };
892
- const snapshot = await backend.readSnapshot(head.snapshotRef, signal);
893
- if (snapshot.id !== head.snapshotId) {
894
- throw new Error(
895
- `Remote head ${head.snapshotId} resolved to unexpected snapshot ${snapshot.id}.`,
896
- );
922
+ if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
923
+ const snapshot = await readSnapshotForHead(backend, head, signal);
924
+ const selectionState = inspectRemoteSelection(config.include, snapshot);
925
+ if (!options.allowSelectionDifference && selectionState.kind === "different") {
926
+ throw new RemoteSelectionMismatchError(config.include, selectionState.include);
897
927
  }
898
- return { head, snapshot: filterSnapshotForConfigPolicy(snapshot, config) };
928
+ return {
929
+ head,
930
+ snapshot: filterSnapshotForConfigPolicy(snapshot, config),
931
+ selectionState,
932
+ };
899
933
  }
900
934
 
901
935
  async function confirmPush(
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { isDeniedPath, toPosix } from "./paths.js";
3
+ import type { Snapshot, SnapshotSelection } from "./types.js";
3
4
 
4
5
  export const BUILT_IN_SYNC_ROOTS = [
5
6
  "settings.json",
@@ -15,6 +16,15 @@ export const BUILT_IN_SYNC_ROOTS = [
15
16
 
16
17
  export const DEFAULT_SYNC_INCLUDE = [...BUILT_IN_SYNC_ROOTS] as const;
17
18
  export type BuiltInSyncFile = (typeof BUILT_IN_SYNC_ROOTS)[number];
19
+ const SNAPSHOT_SELECTION_VERSION = 1;
20
+ const MAX_SYNC_INCLUDE_ITEMS = 1_024;
21
+ const MAX_SYNC_INCLUDE_PATH_BYTES = 4_096;
22
+ const MAX_SYNC_INCLUDE_TOTAL_BYTES = 256 * 1_024;
23
+
24
+ interface IncludePathNode {
25
+ selected?: string;
26
+ children: Map<string, IncludePathNode>;
27
+ }
18
28
 
19
29
  const BUILT_IN_BY_LOWER = new Map<string, BuiltInSyncFile>(
20
30
  BUILT_IN_SYNC_ROOTS.map((fileName) => [fileName.toLowerCase(), fileName]),
@@ -34,12 +44,31 @@ export function normalizeSyncInclude(value: unknown): string[] {
34
44
  if (!Array.isArray(value)) {
35
45
  throw new Error("Invalid pi-sync settings: sync.include must be an array.");
36
46
  }
47
+ if (value.length > MAX_SYNC_INCLUDE_ITEMS) {
48
+ throw new Error(
49
+ `Invalid pi-sync settings: sync.include has too many items; limit: ${MAX_SYNC_INCLUDE_ITEMS}.`,
50
+ );
51
+ }
37
52
  const result: string[] = [];
38
53
  const seen = new Set<string>();
54
+ const pathRoot: IncludePathNode = { children: new Map() };
55
+ let totalBytes = 0;
39
56
  for (const item of value) {
40
57
  if (typeof item !== "string") {
41
58
  throw new Error("Invalid pi-sync settings: sync.include items must be strings.");
42
59
  }
60
+ const itemBytes = Buffer.byteLength(item, "utf8");
61
+ if (itemBytes > MAX_SYNC_INCLUDE_PATH_BYTES) {
62
+ throw new Error(
63
+ `Invalid pi-sync settings: sync.include item is too long; limit: ${MAX_SYNC_INCLUDE_PATH_BYTES} bytes.`,
64
+ );
65
+ }
66
+ totalBytes += itemBytes;
67
+ if (totalBytes > MAX_SYNC_INCLUDE_TOTAL_BYTES) {
68
+ throw new Error(
69
+ `Invalid pi-sync settings: sync.include is too large; limit: ${MAX_SYNC_INCLUDE_TOTAL_BYTES} bytes.`,
70
+ );
71
+ }
43
72
  const trimmed = item.trim();
44
73
  const builtIn = BUILT_IN_BY_LOWER.get(trimmed.toLowerCase());
45
74
  const normalized = builtIn ?? (trimmed.toLowerCase() === "sessions" ? "sessions" : trimmed);
@@ -48,19 +77,34 @@ export function normalizeSyncInclude(value: unknown): string[] {
48
77
  if (seen.has(identity)) {
49
78
  throw new Error(`Invalid pi-sync settings: duplicate sync.include item: ${item}`);
50
79
  }
51
- for (const previous of seen) {
52
- if (identity.startsWith(`${previous}/`) || previous.startsWith(`${identity}/`)) {
53
- throw new Error(
54
- `Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
55
- );
56
- }
57
- }
80
+ addIncludePath(pathRoot, identity, item);
58
81
  seen.add(identity);
59
82
  result.push(normalized);
60
83
  }
61
84
  return result;
62
85
  }
63
86
 
87
+ function addIncludePath(root: IncludePathNode, identity: string, source: string) {
88
+ let node = root;
89
+ for (const segment of identity.split("/")) {
90
+ if (node.selected !== undefined) throwOverlappingInclude(source);
91
+ let child = node.children.get(segment);
92
+ if (!child) {
93
+ child = { children: new Map() };
94
+ node.children.set(segment, child);
95
+ }
96
+ node = child;
97
+ }
98
+ if (node.children.size > 0) throwOverlappingInclude(source);
99
+ node.selected = identity;
100
+ }
101
+
102
+ function throwOverlappingInclude(item: string): never {
103
+ throw new Error(
104
+ `Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
105
+ );
106
+ }
107
+
64
108
  function validateAgentRelativeInclude(value: string) {
65
109
  const normalized = toPosix(value);
66
110
  const topLevel = normalized.split("/")[0]?.toLowerCase();
@@ -89,6 +133,138 @@ function validateAgentRelativeInclude(value: string) {
89
133
  }
90
134
  }
91
135
 
136
+ export function portableSnapshotSelection(value: unknown): SnapshotSelection {
137
+ const selection = value as Partial<SnapshotSelection> | null;
138
+ if (
139
+ !selection ||
140
+ typeof selection !== "object" ||
141
+ Array.isArray(selection) ||
142
+ selection.version !== SNAPSHOT_SELECTION_VERSION ||
143
+ !Object.hasOwn(selection, "include") ||
144
+ Object.keys(selection).some((key) => key !== "version" && key !== "include")
145
+ ) {
146
+ throw new Error("Invalid snapshot selection policy.");
147
+ }
148
+ return {
149
+ version: SNAPSHOT_SELECTION_VERSION,
150
+ include: normalizeSyncInclude(selection.include),
151
+ };
152
+ }
153
+
154
+ export function snapshotSelectionInclude(snapshot: Pick<Snapshot, "selection">) {
155
+ return snapshot.selection === undefined
156
+ ? undefined
157
+ : portableSnapshotSelection(snapshot.selection).include;
158
+ }
159
+
160
+ export function selectionForSnapshot(include: unknown): SnapshotSelection {
161
+ return { version: SNAPSHOT_SELECTION_VERSION, include: normalizeSyncInclude(include) };
162
+ }
163
+
164
+ export function sameSyncInclude(left: unknown, right: unknown) {
165
+ const normalizedLeft = normalizeSyncInclude(left);
166
+ const normalizedRight = normalizeSyncInclude(right);
167
+ return (
168
+ normalizedLeft.length === normalizedRight.length &&
169
+ normalizedLeft.every((item, index) => item === normalizedRight[index])
170
+ );
171
+ }
172
+
173
+ export function compareSyncInclude(local: unknown, remote: unknown) {
174
+ const localInclude = normalizeSyncInclude(local);
175
+ const remoteInclude = normalizeSyncInclude(remote);
176
+ const localSet = new Set(localInclude);
177
+ const remoteSet = new Set(remoteInclude);
178
+ return {
179
+ same: sameSyncInclude(localInclude, remoteInclude),
180
+ remoteOnly: remoteInclude.filter((item) => !localSet.has(item)),
181
+ localOnly: localInclude.filter((item) => !remoteSet.has(item)),
182
+ };
183
+ }
184
+
185
+ export type RemoteSelectionState =
186
+ | { kind: "legacy"; discovered: string[] }
187
+ | { kind: "same"; include: string[] }
188
+ | {
189
+ kind: "different";
190
+ include: string[];
191
+ remoteOnly: string[];
192
+ localOnly: string[];
193
+ };
194
+
195
+ export function inspectRemoteSelection(
196
+ localInclude: unknown,
197
+ snapshot: Pick<Snapshot, "selection" | "files">,
198
+ ): RemoteSelectionState {
199
+ const remoteInclude = snapshotSelectionInclude(snapshot);
200
+ if (!remoteInclude) {
201
+ return { kind: "legacy", discovered: discoverLegacySnapshotInclude(snapshot) };
202
+ }
203
+ const comparison = compareSyncInclude(localInclude, remoteInclude);
204
+ return comparison.same
205
+ ? { kind: "same", include: remoteInclude }
206
+ : { kind: "different", include: remoteInclude, ...comparison };
207
+ }
208
+
209
+ export class RemoteSelectionMismatchError extends Error {
210
+ readonly localInclude: string[];
211
+ readonly remoteInclude: string[];
212
+
213
+ constructor(localInclude: unknown, remoteInclude: unknown) {
214
+ const local = normalizeSyncInclude(localInclude);
215
+ 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
+ );
219
+ this.name = "RemoteSelectionMismatchError";
220
+ this.localInclude = local;
221
+ this.remoteInclude = remote;
222
+ }
223
+ }
224
+
225
+ export function discoverLegacySnapshotInclude(snapshot: Pick<Snapshot, "files">) {
226
+ const builtIns = new Set<BuiltInSyncFile>();
227
+ const custom = new Set<string>();
228
+ let sessions = false;
229
+ for (const file of snapshot.files) {
230
+ const normalized = toPosix(file.path);
231
+ if (
232
+ !normalized ||
233
+ file.path.includes("\\") ||
234
+ normalized.length > 4096 ||
235
+ normalized.startsWith("../") ||
236
+ path.posix.isAbsolute(normalized) ||
237
+ path.posix.normalize(normalized) !== normalized ||
238
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Ignore unsafe legacy paths.
239
+ /[\u0000-\u001f\u007f-\u009f]/u.test(normalized) ||
240
+ isDeniedPath(normalized)
241
+ ) {
242
+ continue;
243
+ }
244
+ const [topLevel, ...rest] = normalized.split("/");
245
+ if (!topLevel) continue;
246
+ if (topLevel === "sessions" && rest.length > 0) {
247
+ sessions = true;
248
+ continue;
249
+ }
250
+ const builtIn = BUILT_IN_BY_LOWER.get(topLevel.toLowerCase());
251
+ if (
252
+ builtIn &&
253
+ ((TOP_LEVEL_DIRS.has(builtIn) && rest.length > 0) ||
254
+ (!TOP_LEVEL_DIRS.has(builtIn) && rest.length === 0))
255
+ ) {
256
+ builtIns.add(builtIn);
257
+ continue;
258
+ }
259
+ if (isSafeCustomIncludePath(topLevel)) custom.add(topLevel);
260
+ }
261
+ return [
262
+ ...BUILT_IN_SYNC_ROOTS.filter((item) => builtIns.has(item)),
263
+ ...[...custom].sort((left, right) => left.localeCompare(right)),
264
+ ...(sessions ? ["sessions"] : []),
265
+ ];
266
+ }
267
+
92
268
  export function syncIncludeSelection(value: unknown) {
93
269
  const include = normalizeSyncInclude(value);
94
270
  const builtIns = include.filter((item): item is BuiltInSyncFile =>
package/src/types.ts CHANGED
@@ -193,6 +193,11 @@ export interface SnapshotFile {
193
193
  sha256: string;
194
194
  }
195
195
 
196
+ export interface SnapshotSelection {
197
+ version: 1;
198
+ include: string[];
199
+ }
200
+
196
201
  export interface Snapshot {
197
202
  version: number;
198
203
  id: string;
@@ -201,6 +206,8 @@ export interface Snapshot {
201
206
  /** Backend-scoped remote identity retained in the snapshot wire format. */
202
207
  profile: string;
203
208
  syncSessions?: boolean;
209
+ /** Portable, credential-free included-content intent. Absent on legacy snapshots. */
210
+ selection?: SnapshotSelection;
204
211
  files: SnapshotFile[];
205
212
  }
206
213
 
@@ -212,6 +219,8 @@ export interface LatestPointer {
212
219
  createdAt: string;
213
220
  machine: string;
214
221
  syncSessions?: boolean;
222
+ /** Lightweight projection; the immutable snapshot remains authoritative. */
223
+ selection?: SnapshotSelection;
215
224
  }
216
225
 
217
226
  export interface RemoteObject<T> {
@@ -11,6 +11,7 @@ import {
11
11
  SyncBackendConflictError,
12
12
  SyncBackendPublicationOutcomeUnknownError,
13
13
  } from "./sync-backend.js";
14
+ import { portableSnapshotSelection } from "./sync-policy.js";
14
15
  import type { LatestPointer, RemoteObject, ResolvedWebDavBackend, Snapshot } from "./types.js";
15
16
  import { WebDavClient, WebDavHttpError, WebDavPreconditionError } from "./webdav-client.js";
16
17
 
@@ -427,6 +428,7 @@ function pointerFor(
427
428
  syncSessions:
428
429
  snapshot.syncSessions === true ||
429
430
  snapshot.files.some((file) => file.path.startsWith("sessions/")),
431
+ ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
430
432
  };
431
433
  }
432
434
 
@@ -438,6 +440,7 @@ function remoteHead(pointer: LatestPointer, identity: string, etag?: string): Re
438
440
  createdAt: pointer.createdAt,
439
441
  machine: pointer.machine,
440
442
  syncSessions: pointer.syncSessions === true,
443
+ ...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
441
444
  };
442
445
  }
443
446
 
@@ -503,6 +506,7 @@ function requirePointer(value: LatestPointer | undefined, expectedProfile: strin
503
506
  ) {
504
507
  throw new Error("Remote latest pointer is malformed.");
505
508
  }
509
+ if (value.selection !== undefined) portableSnapshotSelection(value.selection);
506
510
  return value;
507
511
  }
508
512