@narumitw/pi-sync 0.4.3 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +18 -3
  2. package/package.json +2 -2
  3. package/src/sync.ts +559 -76
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  `@narumitw/pi-sync` is a native [Pi coding agent](https://pi.dev) extension that syncs selected Pi configuration through Cloudflare R2 or other S3-compatible object storage.
6
6
 
7
- It syncs automatically by default when Pi starts, then uses immutable snapshot bundles, a `latest.json` pointer, local locking, secret scanning, and pre-apply backups. Cross-machine pushes use a best-effort remote re-read guard because R2 rejected conditional `latest.json` writes during testing.
7
+ It syncs automatically by default when Pi starts, then uses immutable snapshot bundles, a `latest.json` pointer, local locking, secret scanning, and pre-apply backups. Conversation/session syncing is opt-in because session JSONL can contain prompts, tool output, paths, screenshots, and secrets. Cross-machine pushes use a best-effort remote re-read guard because R2 rejected conditional `latest.json` writes during testing.
8
8
 
9
9
  ## ✨ Features
10
10
 
@@ -15,6 +15,7 @@ It syncs automatically by default when Pi starts, then uses immutable snapshot b
15
15
  - `models.json`
16
16
  - `AGENTS.md`
17
17
  - `skills/`, `prompts/`, `themes/`, and `extensions/`
18
+ - optionally denylist-filtered `sessions/**/*.jsonl` when `syncSessions` is enabled
18
19
  - Stores each remote version as an immutable gzip-compressed JSON snapshot bundle.
19
20
  - Updates remote state through `latest.json` after re-reading remote state to reject already-visible remote changes.
20
21
  - Creates local backups before `pull` and `rollback` under `~/.pi/agent/.pisync/backups/`.
@@ -55,6 +56,8 @@ Then edit:
55
56
  ~/.pi/agent/pi-sync.local.json
56
57
  ```
57
58
 
59
+ If `PI_CODING_AGENT_DIR` is set, pi-sync uses that directory instead of `~/.pi/agent` for config, state, backups, and synced files.
60
+
58
61
  Example:
59
62
 
60
63
  ```json
@@ -66,7 +69,8 @@ Example:
66
69
  "secretAccessKey": "<secret-access-key>",
67
70
  "profile": "default",
68
71
  "prefix": "pi-sync",
69
- "autoSync": true
72
+ "autoSync": true,
73
+ "syncSessions": false
70
74
  }
71
75
  ```
72
76
 
@@ -82,6 +86,7 @@ export PI_SYNC_SESSION_TOKEN="..." # optional, for temporary credentials that re
82
86
  export PI_SYNC_PROFILE="default"
83
87
  export PI_SYNC_PREFIX="pi-sync"
84
88
  export PI_SYNC_AUTO_SYNC="true"
89
+ export PI_SYNC_SESSIONS="false" # opt in with true to sync Pi conversation JSONL files
85
90
  ```
86
91
 
87
92
  `PI_SYNC_ACCESS_KEY_ID`, `PI_SYNC_SECRET_ACCESS_KEY`, and `PI_SYNC_SESSION_TOKEN` are local-only credentials. Do not put them in files that pi-sync syncs. `PI_SYNC_SESSION_TOKEN` is optional and only needed for temporary credentials such as AWS STS, AWS SSO, assumed roles, or S3-compatible providers that issue short-lived credentials.
@@ -90,6 +95,16 @@ Cloudflare R2 static access keys do not use a session token and usually reject r
90
95
 
91
96
  pi-sync also reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`, `R2_ENDPOINT`, and `R2_BUCKET` as compatibility aliases when the matching `PI_SYNC_*` variable is not set.
92
97
 
98
+ ### Session syncing
99
+
100
+ `syncSessions` defaults to `false`. Set it to `true`, or set `PI_SYNC_SESSIONS=true`, to include Pi's configured session JSONL files in snapshots. pi-sync uses `PI_CODING_AGENT_SESSION_DIR`, Pi's `sessionDir` setting, or the default `${PI_CODING_AGENT_DIR:-~/.pi/agent}/sessions/**/*.jsonl` storage. Empty or misspelled `PI_SYNC_SESSIONS` values stay disabled. Only JSONL session files are included; other session-directory files and denylisted paths such as `.env*`, `.pisync`, `node_modules`, and names containing `token` or `secret` are ignored.
101
+
102
+ Session sync is snapshot-based, not live collaboration. If the same session changes on two machines, `/pisync sync` uses the same conflict rules as settings sync and skips when both local and remote changed. Run `/pisync diff`, then choose `/pisync pull --force` or `/pisync push --force` if needed.
103
+
104
+ When both `autoSync` and `syncSessions` are enabled, pi-sync syncs on startup and attempts a quiet session push on shutdown when local files changed. Startup session pulls happen after Pi has already selected the current session, so restart Pi or resume a pulled session to use newly synced conversations. If the remote changed first, the shutdown push is skipped with a warning instead of overwriting it.
105
+
106
+ Session files can contain prompts, model output, tool results, file paths, images, and secrets. Use trusted R2/S3 storage, keep credentials local, and recover local files from `${PI_CODING_AGENT_DIR:-~/.pi/agent}/.pisync/backups/` if a pull or rollback overwrites something unexpectedly.
107
+
93
108
  ## 🚀 Usage
94
109
 
95
110
  ```text
@@ -157,7 +172,7 @@ Before updating `latest.json`, pi-sync re-reads the current pointer and rejects
157
172
  ## 🛡️ Safety notes
158
173
 
159
174
  - pi-sync auto-syncs on startup by default, but skips instead of overwriting when first-run local settings and a remote snapshot both exist, or when both local and remote changed after a previous sync.
160
- - pi-sync does not sync Pi sessions, OAuth state, npm caches, `.env`, `.env.local`, `node_modules`, or `.pisync` state.
175
+ - With `syncSessions`/`PI_SYNC_SESSIONS` disabled, pi-sync does not collect or apply local Pi sessions, though settings-only pushes may preserve session files already present in remote snapshots. It never syncs OAuth state, npm caches, `.env`, `.env.local`, `node_modules`, or `.pisync` state.
161
176
  - If another Pi process is already syncing on the same machine, destructive commands stop at the local lock. `/pisync unlock --stale` is intended for locks whose process is gone or invalid.
162
177
  - If another machine's update is visible before this machine updates `latest.json`, push is rejected unless you explicitly use `--force`.
163
178
  - Pull and rollback create backups before writing local files, then preflight deletes and writes before mutating the local settings tree.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@biomejs/biome": "2.4.14",
33
- "@earendil-works/pi-coding-agent": "0.74.0",
33
+ "@earendil-works/pi-coding-agent": "0.79.8",
34
34
  "typescript": "6.0.3"
35
35
  },
36
36
  "repository": {
package/src/sync.ts CHANGED
@@ -40,6 +40,7 @@ interface SyncConfig {
40
40
  sessionToken?: string;
41
41
  profile: string;
42
42
  prefix: string;
43
+ syncSessions: boolean;
43
44
  }
44
45
 
45
46
  interface PartialConfig {
@@ -52,6 +53,7 @@ interface PartialConfig {
52
53
  profile?: string;
53
54
  prefix?: string;
54
55
  autoSync?: boolean | string;
56
+ syncSessions?: boolean | string;
55
57
  }
56
58
 
57
59
  interface SnapshotFile {
@@ -66,6 +68,7 @@ interface Snapshot {
66
68
  createdAt: string;
67
69
  machine: string;
68
70
  profile: string;
71
+ syncSessions?: boolean;
69
72
  files: SnapshotFile[];
70
73
  }
71
74
 
@@ -76,6 +79,7 @@ interface LatestPointer {
76
79
  sha256: string;
77
80
  createdAt: string;
78
81
  machine: string;
82
+ syncSessions?: boolean;
79
83
  }
80
84
 
81
85
  interface RemoteObject<T> {
@@ -90,6 +94,7 @@ interface SyncState {
90
94
  lastAppliedSnapshot?: string;
91
95
  lastRemoteEtag?: string;
92
96
  lastFileHashes: Record<string, string>;
97
+ syncSessions?: boolean;
93
98
  }
94
99
 
95
100
  interface LockFile {
@@ -109,6 +114,23 @@ interface CommandOptions {
109
114
  args: string[];
110
115
  }
111
116
 
117
+ interface SnapshotOptions {
118
+ syncSessions?: boolean;
119
+ sessionDir?: string;
120
+ }
121
+
122
+ interface PushInput {
123
+ config: SyncConfig;
124
+ state: SyncState;
125
+ local: Snapshot;
126
+ client?: S3Client;
127
+ }
128
+
129
+ interface SnapshotApplyPlan {
130
+ writes: Array<{ target: string; content: Buffer }>;
131
+ deletes: string[];
132
+ }
133
+
112
134
  const AUTO_SYNC_OPTIONS: CommandOptions = {
113
135
  yes: true,
114
136
  force: false,
@@ -132,7 +154,9 @@ export default function sync(pi: ExtensionAPI) {
132
154
  await autoSync(ctx);
133
155
  });
134
156
 
135
- pi.on("session_shutdown", (_event, ctx) => {
157
+ pi.on("session_shutdown", async (event, ctx) => {
158
+ const reason = typeof event === "object" && event ? (event as { reason?: string }).reason : undefined;
159
+ if (reason !== "reload") await autoPushSessions(ctx);
136
160
  ctx.ui.setStatus(STATUS_KEY, undefined);
137
161
  });
138
162
  }
@@ -204,6 +228,27 @@ async function autoSync(ctx: ExtensionContext) {
204
228
  }
205
229
  }
206
230
 
231
+ async function autoPushSessions(ctx: ExtensionContext) {
232
+ try {
233
+ const partial = await loadPartialConfig();
234
+ if (!isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true)) return;
235
+ if (!isExplicitlyEnabled(partial.syncSessions)) return;
236
+ await ensureStateDir();
237
+ const config = await loadConfig();
238
+ if (!config.syncSessions) return;
239
+ await withLock("auto-session-push", async () => {
240
+ const state = await readState(config.profile);
241
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
242
+ if (!hasLocalChanges(local, state)) return;
243
+ await push(ctx, AUTO_SYNC_OPTIONS, { config, state, local });
244
+ });
245
+ } catch (error) {
246
+ if (isMissingConfigError(error)) return;
247
+ ctx.ui.setStatus(STATUS_KEY, undefined);
248
+ ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
249
+ }
250
+ }
251
+
207
252
  async function initConfig(ctx: ExtensionCommandContext) {
208
253
  const configPath = localConfigPath();
209
254
  try {
@@ -223,6 +268,7 @@ async function initConfig(ctx: ExtensionCommandContext) {
223
268
  profile: DEFAULT_PROFILE,
224
269
  prefix: DEFAULT_PREFIX,
225
270
  autoSync: true,
271
+ syncSessions: false,
226
272
  };
227
273
  await writeJson(configPath, sample);
228
274
  ctx.ui.notify(`Created ${configPath}. Fill in R2 credentials, then run /pisync doctor.`, "info");
@@ -230,7 +276,8 @@ async function initConfig(ctx: ExtensionCommandContext) {
230
276
 
231
277
  async function showConfig(ctx: ExtensionCommandContext) {
232
278
  const partial = await loadPartialConfig();
233
- const warnings = sessionTokenWarnings(partial);
279
+ const syncSessions = isExplicitlyEnabled(partial.syncSessions);
280
+ const warnings = [...sessionTokenWarnings(partial), ...syncSessionsWarnings({ syncSessions })];
234
281
  ctx.ui.notify(
235
282
  [
236
283
  "pi-sync config:",
@@ -243,6 +290,7 @@ async function showConfig(ctx: ExtensionCommandContext) {
243
290
  `profile: ${partial.profile ?? DEFAULT_PROFILE}`,
244
291
  `prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
245
292
  `autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
293
+ `syncSessions: ${syncSessions ? "enabled" : "disabled"}`,
246
294
  `local config: ${localConfigPath()}`,
247
295
  ...warnings,
248
296
  ].join("\n"),
@@ -254,28 +302,30 @@ async function status(ctx: ExtensionCommandContext) {
254
302
  ctx.ui.setStatus(STATUS_KEY, "🔄 checking");
255
303
  const config = await loadConfig();
256
304
  const client = new S3Client(config);
257
- const local = await createSnapshot(config.profile);
305
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
258
306
  const state = await readState(config.profile);
259
307
  const latest = await client.getJson<LatestPointer>(latestKey(config));
260
308
  const localChanged = hasLocalChanges(local, state);
261
309
 
262
310
  let remoteText = "remote: empty";
263
- let remoteChanged = false;
311
+ let remoteChanged = remoteChangedSinceState(latest, state, config);
264
312
  if (!latest.missing && latest.value) {
265
- remoteChanged = latest.value.snapshot !== state.lastAppliedSnapshot;
266
313
  remoteText = `remote: ${latest.value.snapshot} from ${latest.value.machine} at ${latest.value.createdAt}`;
267
314
  }
268
315
 
316
+ const warnings = syncSessionsWarnings(config);
269
317
  ctx.ui.setStatus(STATUS_KEY, undefined);
270
318
  ctx.ui.notify(
271
319
  [
272
320
  `profile: ${config.profile}`,
321
+ `sessions: ${config.syncSessions ? "included" : "excluded"}`,
273
322
  remoteText,
274
323
  `local files: ${local.files.length}`,
275
324
  `local changed since last sync: ${localChanged ? "yes" : "no"}`,
276
325
  `remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
326
+ ...warnings,
277
327
  ].join("\n"),
278
- localChanged || remoteChanged ? "warning" : "info",
328
+ localChanged || remoteChanged || warnings.length > 0 ? "warning" : "info",
279
329
  );
280
330
  }
281
331
 
@@ -283,26 +333,39 @@ async function diff(ctx: ExtensionCommandContext) {
283
333
  ctx.ui.setStatus(STATUS_KEY, "🔄 diff");
284
334
  const config = await loadConfig();
285
335
  const client = new S3Client(config);
286
- const local = await createSnapshot(config.profile);
336
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
287
337
  const remote = await readRemoteSnapshot(client, config);
288
338
  ctx.ui.setStatus(STATUS_KEY, undefined);
289
339
 
340
+ const warnings = syncSessionsWarnings(config);
341
+ const header = [`sessions: ${config.syncSessions ? "included" : "excluded"}`, ...warnings].join(
342
+ "\n",
343
+ );
344
+ const level = warnings.length > 0 ? "warning" : "info";
290
345
  if (!remote) {
291
- ctx.ui.notify(formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local), "info");
346
+ ctx.ui.notify(
347
+ `${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
348
+ level,
349
+ );
292
350
  return;
293
351
  }
294
352
 
295
- ctx.ui.notify(formatDiff(local, remote), "info");
353
+ ctx.ui.notify(`${header}\n\n${formatDiff(local, remote)}`, level);
296
354
  }
297
355
 
298
356
  async function doctor(ctx: ExtensionCommandContext) {
299
357
  const messages: string[] = [];
300
358
  let level: "info" | "warning" = "info";
359
+ let snapshotOptions: SnapshotOptions = {};
360
+ let profile = DEFAULT_PROFILE;
301
361
 
302
362
  try {
303
363
  const config = await loadConfig();
364
+ profile = config.profile;
365
+ snapshotOptions = snapshotOptionsForContext(ctx, config);
304
366
  messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
305
- const warnings = sessionTokenWarnings(config);
367
+ messages.push(`sessions: ${config.syncSessions ? "included" : "excluded"}`);
368
+ const warnings = [...sessionTokenWarnings(config), ...syncSessionsWarnings(config)];
306
369
  if (warnings.length > 0) {
307
370
  level = "warning";
308
371
  messages.push(...warnings);
@@ -312,7 +375,7 @@ async function doctor(ctx: ExtensionCommandContext) {
312
375
  messages.push(`config: ${errorMessage(error)}`);
313
376
  }
314
377
 
315
- const local = await createSnapshot(DEFAULT_PROFILE);
378
+ const local = await createSnapshot(profile, snapshotOptions);
316
379
  const secrets = scanSnapshot(local);
317
380
  if (secrets.length > 0) {
318
381
  level = "warning";
@@ -327,29 +390,46 @@ async function doctor(ctx: ExtensionCommandContext) {
327
390
  ctx.ui.notify(messages.join("\n"), level);
328
391
  }
329
392
 
330
- async function push(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
393
+ async function push(
394
+ ctx: ExtensionCommandContext | ExtensionContext,
395
+ options: CommandOptions,
396
+ input?: PushInput,
397
+ ) {
331
398
  ctx.ui.setStatus(STATUS_KEY, "🔄 pushing");
332
- const config = await loadConfig();
333
- const client = new S3Client(config);
334
- const state = await readState(config.profile);
335
- const local = await createSnapshot(config.profile);
336
- const secrets = scanSnapshot(local);
337
- if (secrets.length > 0) {
338
- throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
339
- }
399
+ const config = input?.config ?? (await loadConfig());
400
+ const client = input?.client ?? new S3Client(config);
401
+ const state = input?.state ?? (await readState(config.profile));
402
+ const local = input?.local ?? (await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config)));
340
403
 
341
404
  const latest = await client.getJson<LatestPointer>(latestKey(config));
342
- if (remoteChangedSinceState(latest, state) && !options.force) {
343
- throw new Error("Remote changed since last sync. Run /pisync pull first or /pisync push --force.");
405
+ const remoteForUpload = await readRemoteSnapshotForSettingsOnlyUpload(client, config, latest, state);
406
+ if (remoteChangedSinceState(latest, state, config) && !options.force) {
407
+ if (!remoteForUpload || !settingsHashesMatchState(remoteForUpload, state)) {
408
+ throw new Error("Remote changed since last sync. Run /pisync pull first or /pisync push --force.");
409
+ }
410
+ }
411
+
412
+ const upload = await snapshotForUpload(client, config, local, latest, remoteForUpload);
413
+ const scanTarget = config.syncSessions ? upload : local;
414
+ const secrets = scanSnapshot(scanTarget);
415
+ if (secrets.length > 0) {
416
+ throw new Error(`Refusing to push possible secrets:\n${secrets.map((s) => `- ${s}`).join("\n")}`);
344
417
  }
345
418
 
346
- if (!options.yes && !(await ctx.ui.confirm("Push pi settings?", formatPushSummary(local, latest)))) {
419
+ const preservedRemoteSessionCount = Math.max(0, countSessionFiles(upload) - countSessionFiles(local));
420
+ if (
421
+ !options.yes &&
422
+ !(await ctx.ui.confirm(
423
+ snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?",
424
+ formatPushSummary(upload, latest, preservedRemoteSessionCount),
425
+ ))
426
+ ) {
347
427
  ctx.ui.setStatus(STATUS_KEY, undefined);
348
428
  ctx.ui.notify("Push cancelled.", "info");
349
429
  return;
350
430
  }
351
431
 
352
- const pointer = await uploadSnapshot(client, config, local, latest, options.force);
432
+ const pointer = await uploadSnapshot(client, config, upload, latest, options.force);
353
433
  await updateHistory(client, config, pointer);
354
434
  await writeState(config.profile, {
355
435
  version: VERSION,
@@ -357,10 +437,11 @@ async function push(ctx: ExtensionCommandContext | ExtensionContext, options: Co
357
437
  lastAppliedSnapshot: pointer.snapshot,
358
438
  lastRemoteEtag: undefined,
359
439
  lastFileHashes: fileHashMap(local),
440
+ syncSessions: config.syncSessions,
360
441
  });
361
442
  ctx.ui.setStatus(STATUS_KEY, undefined);
362
443
  if (!options.silent) {
363
- ctx.ui.notify(`Pushed ${local.files.length} files as ${pointer.snapshot}.`, "info");
444
+ ctx.ui.notify(`Pushed ${upload.files.length} files as ${pointer.snapshot}.`, "info");
364
445
  }
365
446
  }
366
447
 
@@ -369,34 +450,47 @@ async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: Co
369
450
  const config = await loadConfig();
370
451
  const client = new S3Client(config);
371
452
  const state = await readState(config.profile);
372
- const local = await createSnapshot(config.profile);
453
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
373
454
  const remote = await readRemoteSnapshot(client, config);
374
455
  if (!remote) throw new Error("Remote is empty. Run /pisync push from a configured machine first.");
375
456
 
376
457
  const localChanged = hasLocalChanges(local, state);
377
- const remoteChanged = remote.id !== state.lastAppliedSnapshot;
458
+ const remoteChanged = hasRemoteChanges(remote, state, config);
378
459
  if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
379
460
  throw new Error("Both local and remote changed since last sync. Run /pisync diff, then choose /pisync pull --force or /pisync push --force.");
380
461
  }
381
462
 
382
- if (!options.yes && !(await ctx.ui.confirm("Pull pi settings?", formatDiff(local, remote)))) {
463
+ if (
464
+ !options.yes &&
465
+ !(await ctx.ui.confirm(
466
+ snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
467
+ formatDiff(local, remote),
468
+ ))
469
+ ) {
383
470
  ctx.ui.setStatus(STATUS_KEY, undefined);
384
471
  ctx.ui.notify("Pull cancelled.", "info");
385
472
  return;
386
473
  }
387
474
 
388
- const backup = await backupLocal(config.profile);
389
- await applySnapshot(remote);
475
+ const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
476
+ const applySessionDir = await sessionDirForApply(ctx, remote);
477
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), applySessionDir);
390
478
  await writeState(config.profile, {
391
479
  version: VERSION,
392
480
  profile: config.profile,
393
481
  lastAppliedSnapshot: remote.id,
394
482
  lastRemoteEtag: undefined,
395
- lastFileHashes: fileHashMap(remote),
483
+ lastFileHashes,
484
+ syncSessions: config.syncSessions,
396
485
  });
397
486
  ctx.ui.setStatus(STATUS_KEY, undefined);
398
487
  if (!options.silent) {
399
488
  ctx.ui.notify(`Pulled ${remote.files.length} files from ${remote.id}. Backup: ${backup}`, "info");
489
+ } else if (options.auto && config.syncSessions && snapshotIncludesSessions(remote)) {
490
+ ctx.ui.notify(
491
+ "Pulled Pi sessions after startup selected the current session. Restart Pi or resume a pulled session to use newly synced conversations.",
492
+ "warning",
493
+ );
400
494
  }
401
495
  if (options.reload) await maybeReload(ctx);
402
496
  }
@@ -405,22 +499,30 @@ async function syncBoth(ctx: ExtensionCommandContext | ExtensionContext, options
405
499
  const config = await loadConfig();
406
500
  const client = new S3Client(config);
407
501
  const state = await readState(config.profile);
408
- const local = await createSnapshot(config.profile);
502
+ const local = await createSnapshot(config.profile, snapshotOptionsForContext(ctx, config));
409
503
  const remote = await readRemoteSnapshot(client, config);
410
504
  const localChanged = hasLocalChanges(local, state);
411
- const remoteChanged = remote ? remote.id !== state.lastAppliedSnapshot : false;
505
+ const remoteChanged = remote ? hasRemoteChanges(remote, state, config) : false;
412
506
  const firstSync = !state.lastAppliedSnapshot;
413
507
 
414
508
  if (firstSync && remote && local.files.length > 0) {
415
- if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
509
+ if (!sameHashes(settingsHashMap(local), settingsHashMap(remote))) {
416
510
  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.");
417
511
  }
512
+ if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
513
+ if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
514
+ throw new Error("Remote settings match, but local and remote Pi sessions differ. Run /pisync diff, then manually choose /pisync pull or /pisync push.");
515
+ }
516
+ await pull(ctx, options);
517
+ return;
518
+ }
418
519
  await writeState(config.profile, {
419
520
  version: VERSION,
420
521
  profile: config.profile,
421
522
  lastAppliedSnapshot: remote.id,
422
523
  lastRemoteEtag: undefined,
423
524
  lastFileHashes: fileHashMap(remote),
525
+ syncSessions: config.syncSessions,
424
526
  });
425
527
  if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
426
528
  return;
@@ -466,26 +568,43 @@ async function rollback(ctx: ExtensionCommandContext, options: CommandOptions) {
466
568
  const client = new S3Client(config);
467
569
  const snapshot = await client.getBuffer(snapshotKey(config, target));
468
570
  if (!snapshot.value) throw new Error(`Snapshot not found: ${target}`);
469
- const remote = await decodeSnapshot(snapshot.value);
470
-
471
- if (!options.yes && !(await ctx.ui.confirm("Rollback pi settings?", formatSnapshotOnlyDiff("Rollback would apply", remote)))) {
571
+ const decoded = await decodeSnapshot(snapshot.value);
572
+ const remote = config.syncSessions ? decoded : snapshotWithoutSessions(decoded);
573
+
574
+ if (
575
+ !options.yes &&
576
+ !(await ctx.ui.confirm(
577
+ snapshotIncludesSessions(remote)
578
+ ? "Rollback pi settings and sessions?"
579
+ : "Rollback pi settings?",
580
+ formatSnapshotOnlyDiff("Rollback would apply", remote),
581
+ ))
582
+ ) {
472
583
  ctx.ui.notify("Rollback cancelled.", "info");
473
584
  return;
474
585
  }
475
586
 
476
- const backup = await backupLocal(config.profile);
477
- await applySnapshot(remote);
478
- const pointer = pointerFor(config, remote, sha256(snapshot.value));
587
+ const backup = await backupLocal(config.profile, snapshotOptionsForContext(ctx, config));
588
+ const applySessionDir = await sessionDirForApply(ctx, remote);
589
+ const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), applySessionDir);
590
+ const latest = await client.getJson<LatestPointer>(latestKey(config));
591
+ const upload = await snapshotForUpload(client, config, remote, latest);
592
+ const encoded = upload.id === decoded.id ? snapshot.value : await encodeSnapshot(upload);
593
+ if (upload.id !== decoded.id) {
594
+ await client.putBuffer(snapshotKey(config, upload.id), encoded, "application/gzip");
595
+ }
596
+ const pointer = pointerFor(config, upload, sha256(encoded));
479
597
  await client.putJson(latestKey(config), pointer);
480
598
  await updateHistory(client, config, pointer);
481
599
  await writeState(config.profile, {
482
600
  version: VERSION,
483
601
  profile: config.profile,
484
- lastAppliedSnapshot: remote.id,
602
+ lastAppliedSnapshot: pointer.snapshot,
485
603
  lastRemoteEtag: undefined,
486
- lastFileHashes: fileHashMap(remote),
604
+ lastFileHashes,
605
+ syncSessions: config.syncSessions,
487
606
  });
488
- ctx.ui.notify(`Rolled back to ${remote.id}. Backup: ${backup}`, "info");
607
+ ctx.ui.notify(`Rolled back to ${target}; latest: ${pointer.snapshot}. Backup: ${backup}`, "info");
489
608
  await maybeReload(ctx);
490
609
  }
491
610
 
@@ -503,6 +622,37 @@ async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
503
622
  ctx.ui.notify("Removed stale pi-sync lock.", "info");
504
623
  }
505
624
 
625
+ function protectedSessionPaths(ctx: ExtensionCommandContext | ExtensionContext) {
626
+ const getSessionFile = ctx.sessionManager.getSessionFile;
627
+ if (typeof getSessionFile !== "function") return new Set<string>();
628
+ const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
629
+ const snapshotPath = sessionFile
630
+ ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx))
631
+ : undefined;
632
+ return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
633
+ }
634
+
635
+ function snapshotOptionsForContext(
636
+ ctx: ExtensionCommandContext | ExtensionContext,
637
+ config: SyncConfig,
638
+ ): SnapshotOptions {
639
+ return { syncSessions: config.syncSessions, sessionDir: sessionDirFromContext(ctx) };
640
+ }
641
+
642
+ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
643
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
644
+ usesDefaultSessionDir?: () => boolean;
645
+ };
646
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
647
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
648
+ return undefined;
649
+ }
650
+ const getSessionDir = manager.getSessionDir;
651
+ return typeof getSessionDir === "function"
652
+ ? (getSessionDir.call(manager) as string | undefined)
653
+ : undefined;
654
+ }
655
+
506
656
  async function maybeReload(ctx: ExtensionCommandContext | ExtensionContext) {
507
657
  if (!("reload" in ctx)) return;
508
658
  if (ctx.hasUI && (await ctx.ui.confirm("Reload Pi resources now?", "This reloads extensions, skills, prompts, themes, and context files."))) {
@@ -541,7 +691,7 @@ async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
541
691
  }
542
692
  }
543
693
 
544
- async function loadConfig(): Promise<SyncConfig> {
694
+ async function loadConfigInternal(): Promise<SyncConfig> {
545
695
  const partial = await loadPartialConfig();
546
696
  const endpoint = partial.endpoint;
547
697
  const bucket = partial.bucket;
@@ -568,9 +718,14 @@ async function loadConfig(): Promise<SyncConfig> {
568
718
  sessionToken: partial.sessionToken,
569
719
  profile: partial.profile ?? DEFAULT_PROFILE,
570
720
  prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
721
+ syncSessions: isExplicitlyEnabled(partial.syncSessions),
571
722
  };
572
723
  }
573
724
 
725
+ export async function loadConfig(): Promise<SyncConfig> {
726
+ return loadConfigInternal();
727
+ }
728
+
574
729
  async function loadPartialConfig(): Promise<PartialConfig> {
575
730
  const fileConfig = (await readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
576
731
  return {
@@ -584,22 +739,63 @@ async function loadPartialConfig(): Promise<PartialConfig> {
584
739
  profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
585
740
  prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
586
741
  autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
742
+ syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
587
743
  };
588
744
  }
589
745
 
590
- async function createSnapshot(profile: string): Promise<Snapshot> {
591
- const files = await collectFiles(agentDir());
746
+ async function configuredSessionDir() {
747
+ const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
748
+ if (envSessionDir) return expandHome(envSessionDir);
749
+ const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
750
+ return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
751
+ }
752
+
753
+ async function sessionDirForApply(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
754
+ const contextSessionDir = sessionDirFromContext(ctx);
755
+ const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
756
+ if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
757
+
758
+ const localSessionDir = await configuredSessionDir();
759
+ if (contextSessionDir && path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")) {
760
+ return contextSessionDir;
761
+ }
762
+ return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
763
+ }
764
+
765
+ function sessionDirFromSnapshot(snapshot: Snapshot) {
766
+ const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
767
+ if (!settingsFile) return undefined;
768
+ try {
769
+ const settings = JSON.parse(
770
+ decodeBase64Strict(settingsFile.contentBase64, settingsFile.path).toString("utf8"),
771
+ ) as { sessionDir?: string };
772
+ return settings.sessionDir ? expandHome(settings.sessionDir) : undefined;
773
+ } catch {
774
+ return undefined;
775
+ }
776
+ }
777
+
778
+ async function createSnapshot(profile: string, options: SnapshotOptions = {}): Promise<Snapshot> {
779
+ const syncSessions = Boolean(options.syncSessions);
780
+ const files = await collectFiles(agentDir(), {
781
+ syncSessions,
782
+ sessionDir: options.sessionDir ?? (await configuredSessionDir()),
783
+ });
592
784
  return {
593
785
  version: VERSION,
594
786
  id: snapshotId(),
595
787
  createdAt: new Date().toISOString(),
596
788
  machine: os.hostname(),
597
789
  profile,
790
+ syncSessions,
598
791
  files,
599
792
  };
600
793
  }
601
794
 
602
- async function collectFiles(root: string): Promise<SnapshotFile[]> {
795
+ export async function collectFiles(
796
+ root: string,
797
+ options: SnapshotOptions = {},
798
+ ): Promise<SnapshotFile[]> {
603
799
  const results: SnapshotFile[] = [];
604
800
  for (const entry of await fs.readdir(root, { withFileTypes: true })) {
605
801
  if (entry.isFile() && TOP_LEVEL_FILES.has(entry.name)) {
@@ -608,27 +804,50 @@ async function collectFiles(root: string): Promise<SnapshotFile[]> {
608
804
  await collectDirectory(results, root, entry.name);
609
805
  }
610
806
  }
807
+ if (options.syncSessions) {
808
+ try {
809
+ await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
810
+ sessionsOnly: true,
811
+ virtualPrefix: "sessions",
812
+ });
813
+ } catch (error) {
814
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
815
+ }
816
+ }
611
817
  return results.sort((left, right) => left.path.localeCompare(right.path));
612
818
  }
613
819
 
614
- async function collectDirectory(results: SnapshotFile[], root: string, relativeDirectory: string) {
820
+ async function collectDirectory(
821
+ results: SnapshotFile[],
822
+ root: string,
823
+ relativeDirectory: string,
824
+ options: { sessionsOnly?: boolean; virtualPrefix?: string } = {},
825
+ ) {
615
826
  const absoluteDirectory = path.join(root, relativeDirectory);
616
827
  for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
617
- const relativePath = posixJoin(relativeDirectory, entry.name);
618
- if (isDeniedPath(relativePath)) continue;
828
+ const relativePath = relativeDirectory ? posixJoin(relativeDirectory, entry.name) : entry.name;
829
+ const snapshotPath = options.virtualPrefix
830
+ ? posixJoin(options.virtualPrefix, relativePath)
831
+ : relativePath;
832
+ if (isDeniedPath(snapshotPath)) continue;
619
833
  if (entry.isDirectory()) {
620
- await collectDirectory(results, root, relativePath);
621
- } else if (entry.isFile()) {
622
- await addFile(results, root, relativePath);
834
+ await collectDirectory(results, root, relativePath, options);
835
+ } else if (entry.isFile() && (!options.sessionsOnly || isSessionFilePath(snapshotPath))) {
836
+ await addFile(results, root, relativePath, snapshotPath);
623
837
  }
624
838
  }
625
839
  }
626
840
 
627
- async function addFile(results: SnapshotFile[], root: string, relativePath: string) {
628
- if (isDeniedPath(relativePath)) return;
841
+ async function addFile(
842
+ results: SnapshotFile[],
843
+ root: string,
844
+ relativePath: string,
845
+ snapshotPath = relativePath,
846
+ ) {
847
+ if (isDeniedPath(snapshotPath)) return;
629
848
  const absolutePath = safeJoin(root, relativePath);
630
849
  const content = await fs.readFile(absolutePath);
631
- results.push({ path: relativePath, contentBase64: content.toString("base64"), sha256: sha256(content) });
850
+ results.push({ path: snapshotPath, contentBase64: content.toString("base64"), sha256: sha256(content) });
632
851
  }
633
852
 
634
853
  export function isDeniedPath(relativePath: string) {
@@ -647,6 +866,69 @@ export function isDeniedPath(relativePath: string) {
647
866
  );
648
867
  }
649
868
 
869
+ function isSessionPath(relativePath: string) {
870
+ return toPosix(relativePath).startsWith("sessions/");
871
+ }
872
+
873
+ function isSessionFilePath(relativePath: string) {
874
+ const normalized = toPosix(relativePath);
875
+ return isSessionPath(normalized) && normalized.endsWith(".jsonl");
876
+ }
877
+
878
+ function sessionStorageRoot(root: string, configuredSessionDir?: string) {
879
+ return configuredSessionDir ? path.resolve(expandHome(configuredSessionDir)) : path.resolve(root, "sessions");
880
+ }
881
+
882
+ function sessionSnapshotPathFromAbsolute(sessionFile: string, configuredSessionDir?: string) {
883
+ const relativePath = toPosix(path.relative(sessionStorageRoot(agentDir(), configuredSessionDir), sessionFile));
884
+ if (!relativePath || relativePath.startsWith("../") || path.posix.isAbsolute(relativePath)) {
885
+ return undefined;
886
+ }
887
+ const snapshotPath = posixJoin("sessions", relativePath);
888
+ return isSessionFilePath(snapshotPath) ? snapshotPath : undefined;
889
+ }
890
+
891
+ function snapshotTarget(root: string, relativePath: string, configuredSessionDir?: string) {
892
+ if (isSessionPath(relativePath)) {
893
+ return safeJoin(sessionStorageRoot(root, configuredSessionDir), relativePath.slice("sessions/".length));
894
+ }
895
+ return safeJoin(root, relativePath);
896
+ }
897
+
898
+ function isPathInside(parent: string, child: string) {
899
+ const relativePath = path.relative(parent, child);
900
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
901
+ }
902
+
903
+ function snapshotIncludesSessions(snapshot: Snapshot) {
904
+ return snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
905
+ }
906
+
907
+ function filterSnapshotForSessionPolicy(
908
+ snapshot: Snapshot | undefined,
909
+ syncSessions: boolean,
910
+ ): Snapshot | undefined {
911
+ if (!snapshot || syncSessions) return snapshot;
912
+ return {
913
+ ...snapshot,
914
+ syncSessions: false,
915
+ files: snapshot.files.filter((file) => !isSessionPath(file.path)),
916
+ };
917
+ }
918
+
919
+ export function snapshotWithoutSessions(snapshot: Snapshot) {
920
+ const files = snapshot.files.filter((file) => !isSessionPath(file.path));
921
+ if (files.length === snapshot.files.length && snapshot.syncSessions !== true) return snapshot;
922
+ return {
923
+ ...snapshot,
924
+ id: snapshotId(),
925
+ createdAt: new Date().toISOString(),
926
+ machine: os.hostname(),
927
+ syncSessions: false,
928
+ files,
929
+ };
930
+ }
931
+
650
932
  export function scanSnapshot(snapshot: Snapshot) {
651
933
  const findings: string[] = [];
652
934
  for (const file of snapshot.files) {
@@ -663,6 +945,48 @@ export function scanSnapshot(snapshot: Snapshot) {
663
945
  return findings;
664
946
  }
665
947
 
948
+ async function readRemoteSnapshotForSettingsOnlyUpload(
949
+ client: S3Client,
950
+ config: SyncConfig,
951
+ latest: RemoteObject<LatestPointer>,
952
+ state: SyncState,
953
+ ) {
954
+ if (config.syncSessions || latest.missing || !latest.value) return undefined;
955
+ if (!latest.value.syncSessions && latest.value.snapshot === state.lastAppliedSnapshot) return undefined;
956
+ return readRemoteSnapshotRaw(client, config);
957
+ }
958
+
959
+ async function snapshotForUpload(
960
+ client: S3Client,
961
+ config: SyncConfig,
962
+ local: Snapshot,
963
+ latest: RemoteObject<LatestPointer>,
964
+ remote?: Snapshot,
965
+ ) {
966
+ if (config.syncSessions || latest.missing || !latest.value || latest.value.syncSessions === false) {
967
+ return local;
968
+ }
969
+ const snapshot = remote ?? (await readRemoteSnapshotRaw(client, config));
970
+ return snapshot ? mergeRemoteSessionFiles(local, snapshot) : local;
971
+ }
972
+
973
+ export function mergeRemoteSessionFiles(local: Snapshot, remote: Snapshot) {
974
+ const remoteSessions = remote.files.filter(
975
+ (file) => isSessionFilePath(file.path) && !isDeniedPath(file.path),
976
+ );
977
+ if (remoteSessions.length === 0 && !snapshotIncludesSessions(remote)) return local;
978
+ return {
979
+ ...local,
980
+ id: snapshotId(),
981
+ createdAt: new Date().toISOString(),
982
+ machine: os.hostname(),
983
+ syncSessions: true,
984
+ files: [...local.files.filter((file) => !isSessionPath(file.path)), ...remoteSessions].sort(
985
+ (left, right) => left.path.localeCompare(right.path),
986
+ ),
987
+ };
988
+ }
989
+
666
990
  async function uploadSnapshot(
667
991
  client: S3Client,
668
992
  config: SyncConfig,
@@ -686,6 +1010,10 @@ async function uploadSnapshot(
686
1010
  }
687
1011
 
688
1012
  async function readRemoteSnapshot(client: S3Client, config: SyncConfig) {
1013
+ return filterSnapshotForSessionPolicy(await readRemoteSnapshotRaw(client, config), config.syncSessions);
1014
+ }
1015
+
1016
+ async function readRemoteSnapshotRaw(client: S3Client, config: SyncConfig) {
689
1017
  const latest = await client.getJson<LatestPointer>(latestKey(config));
690
1018
  if (latest.missing || !latest.value) return undefined;
691
1019
  const object = await client.getBuffer(snapshotKey(config, latest.value.snapshot));
@@ -705,20 +1033,38 @@ async function decodeSnapshot(buffer: Buffer): Promise<Snapshot> {
705
1033
  return parsed;
706
1034
  }
707
1035
 
708
- async function applySnapshot(snapshot: Snapshot) {
1036
+ async function applySnapshot(
1037
+ snapshot: Snapshot,
1038
+ protectedRelativePaths = new Set<string>(),
1039
+ sessionDir?: string,
1040
+ ) {
709
1041
  const root = agentDir();
710
- const current = await createSnapshot(snapshot.profile);
711
- const plan = preflightSnapshotApply(root, snapshot, current);
712
- await preflightSnapshotMutations(root, plan);
1042
+ const current = await createSnapshot(snapshot.profile, {
1043
+ syncSessions: snapshotIncludesSessions(snapshot),
1044
+ sessionDir,
1045
+ });
1046
+ const plan = protectSnapshotApplyPlan(
1047
+ root,
1048
+ preflightSnapshotApply(root, snapshot, current, { sessionDir }),
1049
+ protectedRelativePaths,
1050
+ sessionDir,
1051
+ );
1052
+ await preflightSnapshotMutations(root, plan, sessionDir);
713
1053
  for (const target of plan.deletes) {
714
1054
  await fs.rm(target, { force: true, recursive: true });
715
1055
  }
716
1056
  for (const item of plan.writes) {
717
1057
  await fs.writeFile(item.target, item.content);
718
1058
  }
1059
+ return appliedFileHashMap(snapshot, current, protectedRelativePaths);
719
1060
  }
720
1061
 
721
- export function preflightSnapshotApply(root: string, snapshot: Snapshot, current: Snapshot) {
1062
+ export function preflightSnapshotApply(
1063
+ root: string,
1064
+ snapshot: Snapshot,
1065
+ current: Snapshot,
1066
+ options: { sessionDir?: string } = {},
1067
+ ): SnapshotApplyPlan {
722
1068
  const seenPaths = new Set<string>();
723
1069
  const remotePaths = new Set<string>();
724
1070
  const writes: Array<{ target: string; content: Buffer }> = [];
@@ -726,14 +1072,23 @@ export function preflightSnapshotApply(root: string, snapshot: Snapshot, current
726
1072
 
727
1073
  for (const file of snapshot.files) {
728
1074
  const normalized = toPosix(file.path);
729
- if (!normalized || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
1075
+ if (
1076
+ !normalized ||
1077
+ normalized.startsWith("../") ||
1078
+ path.posix.isAbsolute(normalized) ||
1079
+ path.posix.normalize(normalized) !== normalized ||
1080
+ isDeniedPath(normalized)
1081
+ ) {
730
1082
  throw new Error(`Unsafe path in snapshot: ${file.path}`);
731
1083
  }
1084
+ if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
1085
+ throw new Error(`Unsafe session path in snapshot: ${file.path}`);
1086
+ }
732
1087
  if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
733
1088
  seenPaths.add(normalized);
734
1089
  remotePaths.add(normalized);
735
1090
 
736
- const target = safeJoin(root, normalized);
1091
+ const target = snapshotTarget(root, normalized, options.sessionDir);
737
1092
  const content = decodeBase64Strict(file.contentBase64, normalized);
738
1093
  if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
739
1094
  writes.push({ target, content });
@@ -742,9 +1097,13 @@ export function preflightSnapshotApply(root: string, snapshot: Snapshot, current
742
1097
  const deletePaths = new Set<string>();
743
1098
  for (const file of current.files) {
744
1099
  const normalized = toPosix(file.path);
745
- if (!remotePaths.has(normalized)) deletePaths.add(safeJoin(root, normalized));
746
- for (const remotePath of remotePaths) {
747
- if (normalized.startsWith(`${remotePath}/`)) deletePaths.add(safeJoin(root, remotePath));
1100
+ if (!remotePaths.has(normalized)) {
1101
+ deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
1102
+ }
1103
+ for (const remotePath of parentPaths(normalized)) {
1104
+ if (remotePaths.has(remotePath)) {
1105
+ deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
1106
+ }
748
1107
  }
749
1108
  }
750
1109
  deletes.push(...deletePaths);
@@ -752,6 +1111,41 @@ export function preflightSnapshotApply(root: string, snapshot: Snapshot, current
752
1111
  return { writes, deletes };
753
1112
  }
754
1113
 
1114
+ export function protectSnapshotApplyPlan(
1115
+ root: string,
1116
+ plan: SnapshotApplyPlan,
1117
+ protectedRelativePaths: Set<string>,
1118
+ sessionDir?: string,
1119
+ ): SnapshotApplyPlan {
1120
+ if (protectedRelativePaths.size === 0) return plan;
1121
+ const protectedTargets = new Set(
1122
+ [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir)),
1123
+ );
1124
+ return {
1125
+ writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
1126
+ deletes: plan.deletes.filter((target) => !protectedTargets.has(target)),
1127
+ };
1128
+ }
1129
+
1130
+ export function appliedFileHashMap(
1131
+ snapshot: Snapshot,
1132
+ current: Snapshot,
1133
+ protectedRelativePaths: Set<string>,
1134
+ ) {
1135
+ const hashes = fileHashMap(snapshot);
1136
+ if (protectedRelativePaths.size === 0) return hashes;
1137
+ const currentHashes = fileHashMap(current);
1138
+ for (const relativePath of protectedRelativePaths) {
1139
+ const normalized = toPosix(relativePath);
1140
+ if (currentHashes[normalized]) {
1141
+ hashes[normalized] = currentHashes[normalized];
1142
+ } else {
1143
+ delete hashes[normalized];
1144
+ }
1145
+ }
1146
+ return hashes;
1147
+ }
1148
+
755
1149
  function decodeBase64Strict(value: string, filePath: string) {
756
1150
  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
757
1151
  throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
@@ -759,8 +1153,8 @@ function decodeBase64Strict(value: string, filePath: string) {
759
1153
  return Buffer.from(value, "base64");
760
1154
  }
761
1155
 
762
- async function backupLocal(profile: string) {
763
- const snapshot = await createSnapshot(profile);
1156
+ export async function backupLocal(profile: string, options: SnapshotOptions = {}) {
1157
+ const snapshot = await createSnapshot(profile, options);
764
1158
  const backupDirectory = path.join(stateDir(), "backups");
765
1159
  await fs.mkdir(backupDirectory, { recursive: true });
766
1160
  const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
@@ -804,11 +1198,17 @@ function formatSnapshotOnlyDiff(title: string, snapshot: Snapshot) {
804
1198
  return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `+ ${file.path}`)].join("\n");
805
1199
  }
806
1200
 
807
- function formatPushSummary(local: Snapshot, latest: RemoteObject<LatestPointer>) {
1201
+ function formatPushSummary(
1202
+ local: Snapshot,
1203
+ latest: RemoteObject<LatestPointer>,
1204
+ preservedRemoteSessionCount = 0,
1205
+ ) {
808
1206
  return [
809
1207
  `Upload ${local.files.length} files from ${agentDir()}.`,
810
1208
  latest.value ? `Remote latest: ${latest.value.snapshot}` : "Remote latest: empty",
811
- "Possible secrets were scanned before this prompt.",
1209
+ preservedRemoteSessionCount > 0
1210
+ ? `Possible secrets in local files were scanned before this prompt; ${preservedRemoteSessionCount} preserved remote session file(s) were not rescanned.`
1211
+ : "Possible secrets were scanned before this prompt.",
812
1212
  ].join("\n");
813
1213
  }
814
1214
 
@@ -816,9 +1216,21 @@ function hasLocalChanges(local: Snapshot, state: SyncState) {
816
1216
  return !sameHashes(fileHashMap(local), state.lastFileHashes);
817
1217
  }
818
1218
 
819
- function remoteChangedSinceState(latest: RemoteObject<LatestPointer>, state: SyncState) {
1219
+ function remoteChangedSinceState(
1220
+ latest: RemoteObject<LatestPointer>,
1221
+ state: SyncState,
1222
+ config: SyncConfig,
1223
+ ) {
820
1224
  if (latest.missing) return Boolean(state.lastAppliedSnapshot);
821
- return latest.value?.snapshot !== state.lastAppliedSnapshot;
1225
+ if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
1226
+ return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
1227
+ }
1228
+
1229
+ export function hasRemoteChanges(remote: Snapshot, state: SyncState, config: SyncConfig) {
1230
+ if (remote.id !== state.lastAppliedSnapshot) {
1231
+ return config.syncSessions || !settingsHashesMatchState(remote, state);
1232
+ }
1233
+ return config.syncSessions && state.syncSessions !== true && snapshotIncludesSessions(remote);
822
1234
  }
823
1235
 
824
1236
  function remoteIdentity(remote: RemoteObject<LatestPointer>) {
@@ -837,6 +1249,40 @@ function fileHashMap(snapshot: Snapshot) {
837
1249
  return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
838
1250
  }
839
1251
 
1252
+ function countSessionFiles(snapshot: Snapshot) {
1253
+ return snapshot.files.filter((file) => isSessionPath(file.path)).length;
1254
+ }
1255
+
1256
+ export function settingsHashMap(snapshot: Snapshot) {
1257
+ return Object.fromEntries(
1258
+ snapshot.files
1259
+ .filter((file) => !isSessionPath(file.path))
1260
+ .map((file) => [file.path, file.sha256]),
1261
+ );
1262
+ }
1263
+
1264
+ export function sessionHashMap(snapshot: Snapshot) {
1265
+ return Object.fromEntries(
1266
+ snapshot.files.filter((file) => isSessionPath(file.path)).map((file) => [file.path, file.sha256]),
1267
+ );
1268
+ }
1269
+
1270
+ export function settingsHashMapFromState(state: SyncState) {
1271
+ return Object.fromEntries(
1272
+ Object.entries(state.lastFileHashes).filter(([filePath]) => !isSessionPath(filePath)),
1273
+ );
1274
+ }
1275
+
1276
+ export function settingsHashesMatchState(remote: Snapshot, state: SyncState) {
1277
+ return sameHashes(settingsHashMap(remote), settingsHashMapFromState(state));
1278
+ }
1279
+
1280
+ export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapshot) {
1281
+ const localSessions = sessionHashMap(local);
1282
+ const remoteSessions = sessionHashMap(remote);
1283
+ return Object.entries(localSessions).every(([filePath, hash]) => remoteSessions[filePath] === hash);
1284
+ }
1285
+
840
1286
  async function readState(profile: string): Promise<SyncState> {
841
1287
  return (
842
1288
  (await readJsonIfExists<SyncState>(statePath(profile))) ?? {
@@ -1005,11 +1451,19 @@ function pointerFor(config: SyncConfig, snapshot: Snapshot, checksum: string): L
1005
1451
  sha256: checksum,
1006
1452
  createdAt: snapshot.createdAt,
1007
1453
  machine: snapshot.machine,
1454
+ syncSessions: snapshotIncludesSessions(snapshot),
1008
1455
  };
1009
1456
  }
1010
1457
 
1011
1458
  function agentDir() {
1012
- return path.join(os.homedir(), ".pi", "agent");
1459
+ const configured = normalizeOptionalString(process.env.PI_CODING_AGENT_DIR);
1460
+ return configured ? expandHome(configured) : path.join(os.homedir(), ".pi", "agent");
1461
+ }
1462
+
1463
+ function expandHome(value: string) {
1464
+ return value === "~" || value.startsWith("~/")
1465
+ ? path.join(os.homedir(), value.slice(2))
1466
+ : value;
1013
1467
  }
1014
1468
 
1015
1469
  function stateDir() {
@@ -1064,16 +1518,23 @@ async function writeJson(filePath: string, value: unknown) {
1064
1518
  async function preflightSnapshotMutations(
1065
1519
  root: string,
1066
1520
  plan: { deletes: string[]; writes: Array<{ target: string; content: Buffer }> },
1521
+ sessionDir?: string,
1067
1522
  ) {
1068
1523
  const deletePaths = new Set(plan.deletes);
1069
1524
  for (const target of plan.deletes) {
1070
- await assertNoSymlinkParents(root, target);
1525
+ await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
1071
1526
  }
1072
1527
  for (const item of plan.writes) {
1073
- await prepareSnapshotWrite(root, item.target, deletePaths);
1528
+ await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
1074
1529
  }
1075
1530
  }
1076
1531
 
1532
+ function rootForTarget(root: string, target: string, sessionDir?: string) {
1533
+ const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : undefined;
1534
+ if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
1535
+ return root;
1536
+ }
1537
+
1077
1538
  async function prepareSnapshotWrite(root: string, target: string, deletePaths: Set<string>) {
1078
1539
  await ensureSafeDirectory(root, path.dirname(target));
1079
1540
  try {
@@ -1158,7 +1619,7 @@ function usage() {
1158
1619
  return [
1159
1620
  "Usage: /pisync <command>",
1160
1621
  "Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
1161
- "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN (ignored for R2)/region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json.",
1622
+ "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
1162
1623
  ].join("\n");
1163
1624
  }
1164
1625
 
@@ -1178,6 +1639,16 @@ export function posixJoin(...parts: string[]) {
1178
1639
  return parts.map((part) => trimSlashes(part)).filter(Boolean).join("/");
1179
1640
  }
1180
1641
 
1642
+ function parentPaths(relativePath: string) {
1643
+ const results: string[] = [];
1644
+ let index = relativePath.lastIndexOf("/");
1645
+ while (index > 0) {
1646
+ results.push(relativePath.slice(0, index));
1647
+ index = relativePath.lastIndexOf("/", index - 1);
1648
+ }
1649
+ return results;
1650
+ }
1651
+
1181
1652
  function toPosix(value: string) {
1182
1653
  return value.split(path.sep).join("/");
1183
1654
  }
@@ -1214,6 +1685,13 @@ export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?:
1214
1685
  ];
1215
1686
  }
1216
1687
 
1688
+ function syncSessionsWarnings(config: { syncSessions?: boolean }) {
1689
+ if (!config.syncSessions) return [];
1690
+ return [
1691
+ "sessions: enabled; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
1692
+ ];
1693
+ }
1694
+
1217
1695
  function isSecurityTokenInvalidArgument(text: string) {
1218
1696
  return (
1219
1697
  text.includes("<Code>InvalidArgument</Code>") &&
@@ -1247,6 +1725,11 @@ export function isEnabled(value: boolean | string | undefined, defaultValue: boo
1247
1725
  return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
1248
1726
  }
1249
1727
 
1728
+ export function isExplicitlyEnabled(value: boolean | string | undefined) {
1729
+ if (typeof value === "boolean") return value;
1730
+ return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
1731
+ }
1732
+
1250
1733
  function isMissingConfigError(error: unknown) {
1251
1734
  return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
1252
1735
  }