@narumitw/pi-sync 0.4.3 → 0.6.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/README.md +18 -3
- package/package.json +2 -2
- package/src/sync.ts +563 -80
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
|
|
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.
|
|
3
|
+
"version": "0.6.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.
|
|
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", (
|
|
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
|
|
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"),
|
|
@@ -251,58 +299,73 @@ async function showConfig(ctx: ExtensionCommandContext) {
|
|
|
251
299
|
}
|
|
252
300
|
|
|
253
301
|
async function status(ctx: ExtensionCommandContext) {
|
|
254
|
-
ctx.ui.setStatus(STATUS_KEY, "
|
|
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 =
|
|
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
|
|
|
282
332
|
async function diff(ctx: ExtensionCommandContext) {
|
|
283
|
-
ctx.ui.setStatus(STATUS_KEY, "
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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(
|
|
331
|
-
ctx
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
393
|
+
async function push(
|
|
394
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
395
|
+
options: CommandOptions,
|
|
396
|
+
input?: PushInput,
|
|
397
|
+
) {
|
|
398
|
+
ctx.ui.setStatus(STATUS_KEY, "pushing");
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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,
|
|
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,46 +437,60 @@ 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 ${
|
|
444
|
+
ctx.ui.notify(`Pushed ${upload.files.length} files as ${pointer.snapshot}.`, "info");
|
|
364
445
|
}
|
|
365
446
|
}
|
|
366
447
|
|
|
367
448
|
async function pull(ctx: ExtensionCommandContext | ExtensionContext, options: CommandOptions) {
|
|
368
|
-
ctx.ui.setStatus(STATUS_KEY, "
|
|
449
|
+
ctx.ui.setStatus(STATUS_KEY, "pulling");
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
478
|
-
const
|
|
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:
|
|
602
|
+
lastAppliedSnapshot: pointer.snapshot,
|
|
485
603
|
lastRemoteEtag: undefined,
|
|
486
|
-
lastFileHashes
|
|
604
|
+
lastFileHashes,
|
|
605
|
+
syncSessions: config.syncSessions,
|
|
487
606
|
});
|
|
488
|
-
ctx.ui.notify(`Rolled back to ${
|
|
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
|
|
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
|
|
591
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
628
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
712
|
-
|
|
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(
|
|
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 (
|
|
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 =
|
|
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))
|
|
746
|
-
|
|
747
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1219
|
+
function remoteChangedSinceState(
|
|
1220
|
+
latest: RemoteObject<LatestPointer>,
|
|
1221
|
+
state: SyncState,
|
|
1222
|
+
config: SyncConfig,
|
|
1223
|
+
) {
|
|
820
1224
|
if (latest.missing) return Boolean(state.lastAppliedSnapshot);
|
|
821
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|