@narumitw/pi-sync 0.24.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.ts CHANGED
@@ -1,38 +1,37 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
- import { gunzip, gzip } from "node:zlib";
7
5
  import { promisify } from "node:util";
8
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { completeSyncArguments, parseOptions, splitArgs, usage } from "./command.js";
6
+ import { gunzip, gzip } from "node:zlib";
7
+ import type {
8
+ ExtensionAPI,
9
+ ExtensionCommandContext,
10
+ ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { completeSyncArguments, parseOptions, resolveSyncCommand, usage } from "./command.js";
10
13
  import {
11
14
  agentDir,
12
15
  ensureStateDir,
13
- extraFilePathsByLower,
16
+ isEnabled,
17
+ isExplicitlyEnabled,
14
18
  isMissingConfigError,
15
- isStaleLock,
19
+ loadConfig,
16
20
  loadPartialConfig,
17
21
  localConfigPath,
18
- lockPath,
22
+ localConfigTemplate,
19
23
  normalizeExtraFiles,
20
- readLock,
21
- stateDir,
22
- writeJson,
23
- configuredSessionDir,
24
- isCloudflareR2Endpoint,
25
- isEnabled,
26
- isExplicitlyEnabled,
27
- loadConfig,
24
+ normalizeSyncFiles,
28
25
  readState,
29
26
  sessionDirForApply,
30
27
  sessionTokenWarnings,
28
+ stateDir,
31
29
  syncSessionsWarnings,
32
- withLock,
30
+ writeLocalConfigObject,
33
31
  writeState,
34
32
  } from "./config.js";
35
- import { encodeKey, posixJoin, safeJoin, safeName, toPosix } from "./paths.js";
33
+ import { showFileSelection } from "./file-selection.js";
34
+ import { inspectLock, isLockGuardHeld, isStaleLock, unlock, withLock } from "./lock.js";
36
35
  import {
37
36
  historyKey,
38
37
  latestKey,
@@ -42,27 +41,28 @@ import {
42
41
  snapshotKey,
43
42
  } from "./s3-client.js";
44
43
  import {
45
- addTopLevelCaseVariantDeletes,
46
- appliedFileHashMap,
47
- applySnapshot,
48
- preflightSnapshotApply,
49
- protectSnapshotApplyPlan,
50
- } from "./snapshot-apply.js";
51
- import {
52
- collectFiles,
53
44
  createSnapshot,
54
- canonicalSnapshotPathForConfig,
55
45
  filterSnapshotForConfigPolicy,
56
- isConfiguredSnapshotPath,
57
- isDeniedPath,
58
- isSessionPath,
59
- sessionSnapshotPathFromAbsolute,
60
46
  mergeRemotePreservedFiles,
61
- mergeRemoteSessionFiles,
62
47
  scanSnapshot,
48
+ sessionSnapshotPathFromAbsolute,
63
49
  snapshotIncludesSessions,
64
50
  snapshotWithoutSessions,
65
51
  } from "./snapshot.js";
52
+ import { applySnapshot } from "./snapshot-apply.js";
53
+ import {
54
+ canPullRemoteSessionsOnFirstSync,
55
+ canPullRemoteSettingsOnFirstSync,
56
+ fileHashMap,
57
+ hasLocalChanges,
58
+ hasRemoteChanges,
59
+ remoteChangedSinceState,
60
+ sameHashes,
61
+ shouldRefreshSyncedState,
62
+ snapshotHashesMatchState,
63
+ snapshotsMatch,
64
+ syncPolicyChanged,
65
+ } from "./sync-state.js";
66
66
  import type {
67
67
  CommandOptions,
68
68
  LatestPointer,
@@ -76,12 +76,11 @@ import type {
76
76
  const gzipAsync = promisify(gzip);
77
77
  const gunzipAsync = promisify(gunzip);
78
78
 
79
- const STATUS_KEY = "pisync";
79
+ const STATUS_KEY = "sync";
80
80
  const VERSION = 1;
81
81
  const DEFAULT_PROFILE = "default";
82
82
  const DEFAULT_PREFIX = "pi-sync";
83
83
  const DEFAULT_REGION = "auto";
84
- const LOCK_STALE_MS = 30 * 60 * 1000;
85
84
 
86
85
  interface PushInput {
87
86
  config: SyncConfig;
@@ -100,7 +99,7 @@ const AUTO_SYNC_OPTIONS: CommandOptions = {
100
99
  args: [],
101
100
  };
102
101
  export default function sync(pi: ExtensionAPI) {
103
- pi.registerCommand("pisync", {
102
+ pi.registerCommand("sync", {
104
103
  description: "Sync Pi settings through Cloudflare R2 or S3-compatible storage",
105
104
  getArgumentCompletions: completeSyncArguments,
106
105
  handler: async (args, ctx) => {
@@ -114,17 +113,19 @@ export default function sync(pi: ExtensionAPI) {
114
113
  });
115
114
 
116
115
  pi.on("session_shutdown", async (event, ctx) => {
117
- const reason = typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
116
+ const reason =
117
+ typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
118
118
  if (reason !== "reload") await autoPushSessions(ctx);
119
119
  ctx.ui.setStatus(STATUS_KEY, undefined);
120
120
  });
121
121
  }
122
122
 
123
123
  async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
124
- const [subcommand = "status", ...rest] = splitArgs(rawArgs);
125
- const options = parseOptions(rest);
126
-
127
124
  try {
125
+ const command = await resolveSyncCommand(rawArgs, ctx);
126
+ if (!command) return;
127
+ const { subcommand, rest } = command;
128
+ const options = parseOptions(rest);
128
129
  await ensureStateDir();
129
130
 
130
131
  switch (subcommand) {
@@ -137,6 +138,9 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
137
138
  case "config":
138
139
  await showConfig(ctx);
139
140
  return;
141
+ case "files":
142
+ await showFileSelection(ctx);
143
+ return;
140
144
  case "status":
141
145
  await status(ctx);
142
146
  return;
@@ -165,7 +169,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext) {
165
169
  await unlock(ctx, options);
166
170
  return;
167
171
  default:
168
- ctx.ui.notify(`Unknown /pisync command: ${subcommand}\n\n${usage()}`, "warning");
172
+ ctx.ui.notify(`Unknown /sync command: ${subcommand}\n\n${usage()}`, "warning");
169
173
  }
170
174
  } catch (error) {
171
175
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -218,20 +222,8 @@ async function initConfig(ctx: ExtensionCommandContext) {
218
222
  // Create below.
219
223
  }
220
224
 
221
- const sample = {
222
- endpoint: "https://<account-id>.r2.cloudflarestorage.com",
223
- bucket: "pi-sync",
224
- region: DEFAULT_REGION,
225
- accessKeyId: "<access-key-id>",
226
- secretAccessKey: "<secret-access-key>",
227
- profile: DEFAULT_PROFILE,
228
- prefix: DEFAULT_PREFIX,
229
- autoSync: true,
230
- syncSessions: false,
231
- extraFiles: [],
232
- };
233
- await writeJson(configPath, sample);
234
- ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /pisync doctor.`, "info");
225
+ await writeLocalConfigObject(localConfigTemplate());
226
+ ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /sync doctor.`, "info");
235
227
  }
236
228
 
237
229
  async function showConfig(ctx: ExtensionCommandContext) {
@@ -250,6 +242,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
250
242
  `profile: ${partial.profile ?? DEFAULT_PROFILE}`,
251
243
  `prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
252
244
  `autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
245
+ `syncFiles: ${normalizeSyncFiles(partial.syncFiles).join(", ") || "none"}`,
253
246
  `syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
254
247
  `extraFiles: ${normalizeExtraFiles(partial.extraFiles).join(", ") || "none"}`,
255
248
  `local config: ${localConfigPath()}`,
@@ -269,7 +262,7 @@ async function status(ctx: ExtensionCommandContext) {
269
262
  const localChanged = hasLocalChanges(local, state, config);
270
263
 
271
264
  let remoteText = "remote: empty";
272
- let remoteChanged = remoteChangedSinceState(latest, state, config);
265
+ const remoteChanged = remoteChangedSinceState(latest, state, config);
273
266
  if (!latest.missing && latest.value) {
274
267
  remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
275
268
  }
@@ -279,6 +272,8 @@ async function status(ctx: ExtensionCommandContext) {
279
272
  ctx.ui.notify(
280
273
  [
281
274
  `profile: ${config.profile}`,
275
+ `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
276
+ `extra files: ${config.extraFiles.join(", ") || "none"}`,
282
277
  `sessions: ${config.syncSessions ? "included" : "excluded"}`,
283
278
  remoteText,
284
279
  `local files: ${local.files.length}`,
@@ -299,9 +294,12 @@ async function diff(ctx: ExtensionCommandContext) {
299
294
  ctx.ui.setStatus(STATUS_KEY, undefined);
300
295
 
301
296
  const warnings = syncSessionsWarnings(config);
302
- const header = [`sessions: ${config.syncSessions ? "included" : "excluded"}`, ...warnings].join(
303
- "\n",
304
- );
297
+ const header = [
298
+ `sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`,
299
+ `extra files: ${config.extraFiles.join(", ") || "none"}`,
300
+ `sessions: ${config.syncSessions ? "included" : "excluded"}`,
301
+ ...warnings,
302
+ ].join("\n");
305
303
  const level = warnings.length > 0 ? "warning" : "info";
306
304
  if (!remote) {
307
305
  ctx.ui.notify(
@@ -325,6 +323,8 @@ async function doctor(ctx: ExtensionCommandContext) {
325
323
  profile = config.profile;
326
324
  snapshotOptions = snapshotOptionsForContext(ctx, config);
327
325
  messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
326
+ messages.push(`sync files: ${normalizeSyncFiles(config.syncFiles).join(", ") || "none"}`);
327
+ messages.push(`extra files: ${config.extraFiles.join(", ") || "none"}`);
328
328
  messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
329
329
  const warnings = [...sessionTokenWarnings(config), ...syncSessionsWarnings(config)];
330
330
  if (warnings.length > 0) {
@@ -346,8 +346,25 @@ async function doctor(ctx: ExtensionCommandContext) {
346
346
  messages.push(`secret scan: ok (${local.files.length} files checked)`);
347
347
  }
348
348
 
349
- const lock = await readLock();
350
- messages.push(lock ? `lock: held by pid ${lock.pid} since ${lock.startedAt}` : "lock: free");
349
+ const lock = await inspectLock();
350
+ if (lock.status === "valid" && isStaleLock(lock.lock)) {
351
+ level = "warning";
352
+ messages.push(
353
+ `lock: stale (pid ${lock.lock.pid}); run /sync unlock after verifying no sync is running`,
354
+ );
355
+ } else if (lock.status === "valid") {
356
+ messages.push(`lock: held by pid ${lock.lock.pid} since ${lock.lock.startedAt}`);
357
+ } else if (lock.status === "unreadable") {
358
+ level = "warning";
359
+ messages.push(
360
+ "lock: unreadable; use /sync unlock --stale only after verifying no sync is running",
361
+ );
362
+ } else if (await isLockGuardHeld()) {
363
+ level = "warning";
364
+ messages.push("lock: guard active while metadata is missing or still being initialized");
365
+ } else {
366
+ messages.push("lock: free");
367
+ }
351
368
  ctx.ui.notify(messages.join("\n"), level);
352
369
  }
353
370
 
@@ -360,29 +377,28 @@ async function push(
360
377
  const config = input?.config ?? (await loadConfig());
361
378
  const client = input?.client ?? new S3Client(config);
362
379
  const state = input?.state ?? (await readState(config.profile));
363
- const local = input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
380
+ const local =
381
+ input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
364
382
 
365
383
  const latest = await client.getJson<LatestPointer>(latestKey(config));
366
- const remoteForUpload = options.force
367
- ? undefined
368
- : await readRemoteSnapshotForUpload(client, config, latest, state);
384
+ const remoteForUpload = await readRemoteSnapshotForUpload(client, config, latest, state);
369
385
  if (remoteChangedSinceState(latest, state, config) && !options.force) {
370
386
  const remoteForConflict = remoteForUpload
371
387
  ? filterSnapshotForConfigPolicy(remoteForUpload, config)
372
388
  : undefined;
373
389
  if (!remoteForConflict || !snapshotHashesMatchState(remoteForConflict, state, config)) {
374
390
  throw new Error(
375
- "Remote or sync policy changed since last sync. Run /pisync pull first or /pisync push --force.",
391
+ "Remote or sync policy changed since last sync. Run /sync pull first or /sync push --force.",
376
392
  );
377
393
  }
378
394
  }
379
395
 
380
- const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload, {
381
- preserveRemote: !options.force,
382
- });
396
+ const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
383
397
  const secrets = scanSnapshot(local);
384
398
  if (secrets.length > 0) {
385
- throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
399
+ throw new Error(
400
+ `Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`,
401
+ );
386
402
  }
387
403
 
388
404
  const preservedRemoteFileCount = countPreservedRemoteFiles(local, upload);
@@ -406,6 +422,7 @@ async function push(
406
422
  lastAppliedSnapshot: pointer.snapshot,
407
423
  lastRemoteEtag: undefined,
408
424
  lastFileHashes: fileHashMap(local),
425
+ syncFiles: config.syncFiles,
409
426
  syncSessions: config.syncSessions,
410
427
  extraFiles: config.extraFiles,
411
428
  });
@@ -422,12 +439,14 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
422
439
  const state = await readState(config.profile);
423
440
  const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
424
441
  const remote = await readRemoteSnapshot(client, config);
425
- if (!remote) throw new Error("Remote is empty. Run /pisync push from a configured machine first.");
442
+ if (!remote) throw new Error("Remote is empty. Run /sync push from a configured machine first.");
426
443
 
427
444
  const localChanged = hasLocalChanges(local, state, config);
428
445
  const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
429
446
  if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
430
- throw new Error("Both local and remote changed since last sync. Run /pisync diff, then choose /pisync pull --force or /pisync push --force.");
447
+ throw new Error(
448
+ "Both local and remote changed since last sync. Run /sync diff, then choose /sync pull --force or /sync push --force.",
449
+ );
431
450
  }
432
451
 
433
452
  if (
@@ -445,6 +464,7 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
445
464
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
446
465
  const applySessionDir = await sessionDirForApply(ctx, remote);
447
466
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
467
+ syncFiles: config.syncFiles,
448
468
  sessionDir: applySessionDir,
449
469
  extraFiles: config.extraFiles,
450
470
  });
@@ -454,12 +474,16 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
454
474
  lastAppliedSnapshot: remote.id,
455
475
  lastRemoteEtag: undefined,
456
476
  lastFileHashes,
477
+ syncFiles: config.syncFiles,
457
478
  syncSessions: config.syncSessions,
458
479
  extraFiles: config.extraFiles,
459
480
  });
460
481
  ctx.ui.setStatus(STATUS_KEY, undefined);
461
482
  if (!options.silent) {
462
- ctx.ui.notify(`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info");
483
+ ctx.ui.notify(
484
+ `Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`,
485
+ "info",
486
+ );
463
487
  } else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
464
488
  ctx.ui.notify(
465
489
  "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
@@ -483,11 +507,15 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
483
507
 
484
508
  if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
485
509
  if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
486
- throw new Error("Remote settings exist and this machine has different local Pi settings. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
510
+ throw new Error(
511
+ "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push.",
512
+ );
487
513
  }
488
514
  if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
489
515
  if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
490
- throw new Error("Remote settings match, but local and remote Pi sessions differ. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
516
+ throw new Error(
517
+ "Remote settings match, but local and remote Pi sessions differ. Run /sync diff, then manually choose /sync pull or /sync push.",
518
+ );
491
519
  }
492
520
  await pull(ctx, options);
493
521
  return;
@@ -498,10 +526,12 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
498
526
  lastAppliedSnapshot: remote.id,
499
527
  lastRemoteEtag: undefined,
500
528
  lastFileHashes: fileHashMap(remote),
529
+ syncFiles: config.syncFiles,
501
530
  syncSessions: config.syncSessions,
502
531
  extraFiles: config.extraFiles,
503
532
  });
504
- if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
533
+ if (!options.silent)
534
+ ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
505
535
  return;
506
536
  }
507
537
  if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
@@ -511,6 +541,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
511
541
  lastAppliedSnapshot: remote.id,
512
542
  lastRemoteEtag: undefined,
513
543
  lastFileHashes: fileHashMap(remote),
544
+ syncFiles: config.syncFiles,
514
545
  syncSessions: config.syncSessions,
515
546
  extraFiles: config.extraFiles,
516
547
  });
@@ -518,7 +549,9 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
518
549
  return;
519
550
  }
520
551
  if (localChanged && remoteChanged && state.lastAppliedSnapshot) {
521
- throw new Error("Both local and remote changed. Run /pisync diff and resolve with push --force or pull --force.");
552
+ throw new Error(
553
+ "Both local and remote changed. Run /sync diff and resolve with push --force or pull --force.",
554
+ );
522
555
  }
523
556
  if (remoteChanged) {
524
557
  await pull(ctx, options);
@@ -535,6 +568,7 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
535
568
  lastAppliedSnapshot: remote.id,
536
569
  lastRemoteEtag: undefined,
537
570
  lastFileHashes: fileHashMap(remote),
571
+ syncFiles: config.syncFiles,
538
572
  syncSessions: config.syncSessions,
539
573
  extraFiles: config.extraFiles,
540
574
  });
@@ -563,7 +597,7 @@ async function history(ctx: ExtensionCommandContext) {
563
597
 
564
598
  async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
565
599
  const target = options.args[0];
566
- if (!target) throw new Error("Usage: /pisync rollback <snapshot-id> [--yes]");
600
+ if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
567
601
 
568
602
  const config = await loadConfig();
569
603
  const client = new S3Client(config);
@@ -592,6 +626,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
592
626
  const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
593
627
  const applySessionDir = await sessionDirForApply(ctx, remote);
594
628
  const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
629
+ syncFiles: config.syncFiles,
595
630
  sessionDir: applySessionDir,
596
631
  extraFiles: config.extraFiles,
597
632
  });
@@ -612,6 +647,7 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
612
647
  lastAppliedSnapshot: pointer.snapshot,
613
648
  lastRemoteEtag: undefined,
614
649
  lastFileHashes,
650
+ syncFiles: config.syncFiles,
615
651
  syncSessions: config.syncSessions,
616
652
  extraFiles: config.extraFiles,
617
653
  });
@@ -619,20 +655,6 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
619
655
  await maybeReload(ctx);
620
656
  }
621
657
 
622
- async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
623
- const lock = await readLock();
624
- if (!lock) {
625
- ctx.ui.notify("No pi-sync lock is present.", "info");
626
- return;
627
- }
628
- if (!options.stale && !isStaleLock(lock)) {
629
- ctx.ui.notify("Lock is not stale. Use /pisync unlock --stale only after verifying no sync is running.", "warning");
630
- return;
631
- }
632
- await fs.rm(lockPath(), { force: true });
633
- ctx.ui.notify("Removed stale pi-sync lock.", "info");
634
- }
635
-
636
658
  function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
637
659
  const getSessionFile = ctx.sessionManager.getSessionFile;
638
660
  if (typeof getSessionFile !== "function") return new Set<string>();
@@ -648,6 +670,7 @@ function snapshotOptionsForContext(
648
670
  config: SyncConfig,
649
671
  ): SnapshotOptions {
650
672
  return {
673
+ syncFiles: config.syncFiles,
651
674
  syncSessions: config.syncSessions,
652
675
  sessionDir: sessionDirFromContext(ctx),
653
676
  extraFiles: config.extraFiles,
@@ -670,7 +693,13 @@ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext)
670
693
 
671
694
  async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
672
695
  if (!("reload" in ctx)) return;
673
- if (ctx.hasUI && (await ctx.ui.confirm("Reload Pi resources now?", "This reloads extensions, skills, prompts, themes, and context files."))) {
696
+ if (
697
+ ctx.hasUI &&
698
+ (await ctx.ui.confirm(
699
+ "Reload Pi resources now?",
700
+ "This reloads extensions, skills, prompts, themes, and context files.",
701
+ ))
702
+ ) {
674
703
  await ctx.reload();
675
704
  }
676
705
  }
@@ -697,9 +726,9 @@ async function snapshotForUpload(
697
726
  local: Snapshot,
698
727
  latest: RemoteObject<LatestPointer>,
699
728
  remote?: Snapshot,
700
- options: { preserveRemote?: boolean; ignoreUnreadableRemote?: boolean } = {},
729
+ options: { ignoreUnreadableRemote?: boolean } = {},
701
730
  ) {
702
- if (options.preserveRemote === false || latest.missing || !latest.value) return local;
731
+ if (latest.missing || !latest.value) return local;
703
732
  let snapshot = remote;
704
733
  if (!snapshot) {
705
734
  try {
@@ -724,12 +753,14 @@ async function uploadSnapshot(
724
753
  await client.putBuffer(snapshotKey(config, snapshot.id), encoded, "application/gzip");
725
754
  const current = await client.getJson<LatestPointer>(latestKey(config));
726
755
  if (!force && remoteIdentity(current) !== remoteIdentity(latest)) {
727
- throw new Error("Remote changed while pushing. Run /pisync pull first, then retry.");
756
+ throw new Error("Remote changed while pushing. Run /sync pull first, then retry.");
728
757
  }
729
758
  await client.putJson(latestKey(config), pointer);
730
759
  const verified = await client.getJson<LatestPointer>(latestKey(config));
731
760
  if (verified.value?.snapshot !== pointer.snapshot) {
732
- throw new Error("Remote latest changed immediately after push. Run /pisync status before continuing.");
761
+ throw new Error(
762
+ "Remote latest changed immediately after push. Run /sync status before continuing.",
763
+ );
733
764
  }
734
765
  return pointer;
735
766
  }
@@ -743,8 +774,10 @@ async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
743
774
  const latest = await client.getJson<LatestPointer>(latestKey(config));
744
775
  if (latest.missing || !latest.value) return undefined;
745
776
  const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
746
- if (!object.value) throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
747
- if (sha256(object.value) !== latest.value.sha256) throw new Error("Remote snapshot checksum mismatch.");
777
+ if (!object.value)
778
+ throw new Error(`Remote latest points to missing snapshot: ${latest.value.snapshot}`);
779
+ if (sha256(object.value) !== latest.value.sha256)
780
+ throw new Error("Remote snapshot checksum mismatch.");
748
781
  return decodeSnapshot(object.value);
749
782
  }
750
783
 
@@ -755,7 +788,8 @@ async function encodeSnapshot(snapshot: Snapshot) {
755
788
  async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
756
789
  const decoded = await gunzipAsync(buffer);
757
790
  const parsed = JSON.parse(decoded.toString("utf8")) as Snapshot;
758
- if (parsed.version !== VERSION || !Array.isArray(parsed.files)) throw new Error("Unsupported snapshot format.");
791
+ if (parsed.version !== VERSION || !Array.isArray(parsed.files))
792
+ throw new Error("Unsupported snapshot format.");
759
793
  return parsed;
760
794
  }
761
795
 
@@ -769,7 +803,9 @@ export async function backupLocal(profile: string, options: SnapshotOptions = {}
769
803
  }
770
804
 
771
805
  async function updateHistory(client: S3Client, config: SyncConfig, pointer: LatestPointer) {
772
- const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(historyKey(config));
806
+ const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
807
+ historyKey(config),
808
+ );
773
809
  const snapshots = object.value?.snapshots ?? [];
774
810
  const next = [
775
811
  ...snapshots.filter((snapshot) => snapshot.snapshot !== pointer.snapshot),
@@ -782,7 +818,11 @@ function formatDiff(local: Snapshot, remote: Snapshot) {
782
818
  const localMap = fileHashMap(local);
783
819
  const remoteMap = fileHashMap(remote);
784
820
  const allPaths = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
785
- const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
821
+ const lines = [
822
+ `local: ${local.files.length} files`,
823
+ `remote: ${remote.id} (${remote.files.length} files)`,
824
+ "",
825
+ ];
786
826
  let changed = 0;
787
827
  for (const filePath of allPaths) {
788
828
  if (!localMap[filePath]) {
@@ -818,145 +858,15 @@ function formatPushSummary(
818
858
  ].join("\n");
819
859
  }
820
860
 
821
- function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncConfig) {
822
- return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
823
- }
824
-
825
- function remoteChangedSinceState(
826
- latest: RemoteObject<LatestPointer>,
827
- state: SyncState,
828
- config: SyncConfig,
829
- ) {
830
- if (latest.missing) return Boolean(state.lastAppliedSnapshot);
831
- if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
832
- if (extraFilesChanged(state, config)) return true;
833
- return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
834
- }
835
-
836
- export function hasRemoteChanges(
837
- remote: Snapshot,
838
- state: SyncState,
839
- config: SyncConfig,
840
- ignoredPaths = new Set<string>(),
841
- ) {
842
- if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
843
- return !snapshotHashesMatchState(filterSnapshotForConfigPolicy(remote, config), state, config, ignoredPaths);
844
- }
845
-
846
861
  function remoteIdentity(remote: RemoteObject<LatestPointer>) {
847
862
  return remote.missing ? "missing" : (remote.value?.snapshot ?? "unknown");
848
863
  }
849
864
 
850
- function sameHashes(left: Record<string, string>, right: Record<string, string>) {
851
- const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
852
- for (const key of keys) {
853
- if (left[key] !== right[key]) return false;
854
- }
855
- return true;
856
- }
857
-
858
- function fileHashMap(snapshot: Snapshot) {
859
- return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
860
- }
861
-
862
- function stateHashMapForConfig(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
863
- const extraFilePaths = extraFilePathsByLower(config.extraFiles);
864
- const extraFiles = new Set(extraFilePaths.keys());
865
- return Object.fromEntries(
866
- Object.entries(state.lastFileHashes)
867
- .filter(([filePath]) => isConfiguredSnapshotPath(filePath, config, extraFiles))
868
- .map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, extraFilePaths), hash]),
869
- );
870
- }
871
-
872
- function snapshotHashesMatchState(
873
- snapshot: Snapshot,
874
- state: SyncState,
875
- config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
876
- ignoredPaths = new Set<string>(),
877
- ) {
878
- return sameHashes(
879
- withoutHashPaths(fileHashMap(snapshot), ignoredPaths),
880
- withoutHashPaths(stateHashMapForConfig(state, config), ignoredPaths),
881
- );
882
- }
883
-
884
- function snapshotsMatch(left: Snapshot, right: Snapshot) {
885
- return left.syncSessions === right.syncSessions && sameHashes(fileHashMap(left), fileHashMap(right));
886
- }
887
-
888
- function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<string>) {
889
- if (ignoredPaths.size === 0) return hashes;
890
- return Object.fromEntries(
891
- Object.entries(hashes).filter(([filePath]) => !ignoredPaths.has(toPosix(filePath))),
892
- );
893
- }
894
-
895
- function syncPolicyChanged(state: SyncState, config: Pick<SyncConfig, "syncSessions" | "extraFiles">) {
896
- return (state.syncSessions ?? false) !== config.syncSessions || extraFilesChanged(state, config);
897
- }
898
-
899
- function shouldRefreshSyncedState(
900
- remote: Snapshot,
901
- state: SyncState,
902
- config: Pick<SyncConfig, "syncSessions" | "extraFiles">,
903
- ) {
904
- return remote.id !== state.lastAppliedSnapshot || syncPolicyChanged(state, config);
905
- }
906
-
907
- function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
908
- return !sameStringSet(normalizeExtraFiles(state.extraFiles), normalizeExtraFiles(config.extraFiles));
909
- }
910
-
911
- function sameStringSet(left: string[], right: string[]) {
912
- const leftSet = new Set(left);
913
- const rightSet = new Set(right);
914
- if (leftSet.size !== rightSet.size) return false;
915
- return [...leftSet].every((item) => rightSet.has(item));
916
- }
917
-
918
865
  function countPreservedRemoteFiles(local: Snapshot, upload: Snapshot) {
919
866
  const localPaths = new Set(local.files.map((file) => file.path));
920
867
  return upload.files.filter((file) => !localPaths.has(file.path)).length;
921
868
  }
922
869
 
923
- export function settingsHashMap(snapshot: Snapshot) {
924
- return Object.fromEntries(
925
- snapshot.files
926
- .filter((file) => !isSessionPath(file.path))
927
- .map((file) => [file.path, file.sha256]),
928
- );
929
- }
930
-
931
- export function sessionHashMap(snapshot: Snapshot) {
932
- return Object.fromEntries(
933
- snapshot.files.filter((file) => isSessionPath(file.path)).map((file) => [file.path, file.sha256]),
934
- );
935
- }
936
-
937
- export function settingsHashMapFromState(state: SyncState) {
938
- return Object.fromEntries(
939
- Object.entries(state.lastFileHashes).filter(([filePath]) => !isSessionPath(filePath)),
940
- );
941
- }
942
-
943
- export function settingsHashesMatchState(remote: Snapshot, state: SyncState) {
944
- return sameHashes(settingsHashMap(remote), settingsHashMapFromState(state));
945
- }
946
-
947
- export function canPullRemoteSettingsOnFirstSync(local: Snapshot, remote: Snapshot) {
948
- const remoteSettings = settingsHashMap(remote);
949
- return Object.entries(settingsHashMap(local)).every(
950
- ([filePath, hash]) => remoteSettings[filePath] === hash,
951
- );
952
- }
953
-
954
- export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapshot) {
955
- const localSessions = sessionHashMap(local);
956
- const remoteSessions = sessionHashMap(remote);
957
- return Object.entries(localSessions).every(([filePath, hash]) => remoteSessions[filePath] === hash);
958
- }
959
-
960
870
  function sha256(value: Buffer) {
961
871
  return createHash("sha256").update(value).digest("hex");
962
872
  }
@@ -970,24 +880,39 @@ function errorMessage(error: unknown) {
970
880
  }
971
881
 
972
882
  export { completeSyncArguments, parseOptions, splitArgs } from "./command.js";
973
- export { isCloudflareR2Endpoint, isEnabled, isExplicitlyEnabled, loadConfig, sessionTokenWarnings } from "./config.js";
974
- export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
975
883
  export {
976
- addTopLevelCaseVariantDeletes,
977
- appliedFileHashMap,
978
- preflightSnapshotApply,
979
- protectSnapshotApplyPlan,
980
- } from "./snapshot-apply.js";
884
+ isCloudflareR2Endpoint,
885
+ isEnabled,
886
+ isExplicitlyEnabled,
887
+ loadConfig,
888
+ sessionTokenWarnings,
889
+ } from "./config.js";
890
+ export { encodeKey, posixJoin, safeJoin, safeName } from "./paths.js";
981
891
  export {
982
- collectFiles,
983
892
  canonicalSnapshotPathForConfig,
893
+ collectFiles,
984
894
  filterSnapshotForConfigPolicy,
985
895
  isConfiguredSnapshotPath,
986
896
  isDeniedPath,
987
897
  isSessionPath,
988
- sessionSnapshotPathFromAbsolute,
989
898
  mergeRemotePreservedFiles,
990
899
  mergeRemoteSessionFiles,
991
900
  scanSnapshot,
901
+ sessionSnapshotPathFromAbsolute,
992
902
  snapshotWithoutSessions,
993
903
  } from "./snapshot.js";
904
+ export {
905
+ addTopLevelCaseVariantDeletes,
906
+ appliedFileHashMap,
907
+ preflightSnapshotApply,
908
+ protectSnapshotApplyPlan,
909
+ } from "./snapshot-apply.js";
910
+ export {
911
+ canPullRemoteSessionsOnFirstSync,
912
+ canPullRemoteSettingsOnFirstSync,
913
+ hasRemoteChanges,
914
+ sessionHashMap,
915
+ settingsHashesMatchState,
916
+ settingsHashMap,
917
+ settingsHashMapFromState,
918
+ } from "./sync-state.js";