@wuyaos/pi-sync 1.1.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/LICENSE +21 -0
- package/PROMO.md +95 -0
- package/README.md +205 -0
- package/README.zh-CN.md +205 -0
- package/docs/sync-menu.png +0 -0
- package/extensions/_shared/box-drawing.ts +58 -0
- package/extensions/_shared/enhanced-select.ts +477 -0
- package/extensions/_shared/fetch-utils.ts +46 -0
- package/extensions/_shared/json-io.ts +120 -0
- package/extensions/_shared/spawn.ts +91 -0
- package/extensions/sync/archive.ts +264 -0
- package/extensions/sync/config.ts +93 -0
- package/extensions/sync/index.ts +59 -0
- package/extensions/sync/menus.ts +258 -0
- package/extensions/sync/restore.ts +140 -0
- package/extensions/sync/session-sync.ts +231 -0
- package/extensions/sync/webdav.ts +91 -0
- package/package.json +36 -0
- package/pi-bootstrap.ps1 +113 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared spawn wrapper for Pi extensions.
|
|
3
|
+
*
|
|
4
|
+
* Single unified async spawn with timeout, Windows process-tree kill,
|
|
5
|
+
* and consistent { ok, stdout, stderr, status } result.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { runCommand, type SpawnResult } from "../_shared/spawn";
|
|
9
|
+
* const r = await runCommand("git", ["status"], { cwd: repoDir, timeoutMs: 30000 });
|
|
10
|
+
* if (!r.ok) throw new Error(r.stderr);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
export interface SpawnResult {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
status: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SpawnOptions {
|
|
23
|
+
cwd?: string;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
shell?: boolean;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
windowsHide?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
31
|
+
|
|
32
|
+
export function runCommand(
|
|
33
|
+
cmd: string,
|
|
34
|
+
args: string[],
|
|
35
|
+
opts: SpawnOptions = {},
|
|
36
|
+
): Promise<SpawnResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn(cmd, args, {
|
|
39
|
+
cwd: opts.cwd,
|
|
40
|
+
env: opts.env,
|
|
41
|
+
shell: opts.shell ?? false,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
windowsHide: opts.windowsHide ?? true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let stdout = "";
|
|
47
|
+
let stderr = "";
|
|
48
|
+
let done = false;
|
|
49
|
+
|
|
50
|
+
child.stdout?.on("data", (d: Buffer) => (stdout += d.toString()));
|
|
51
|
+
child.stderr?.on("data", (d: Buffer) => (stderr += d.toString()));
|
|
52
|
+
|
|
53
|
+
const ms = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
if (done) return;
|
|
56
|
+
done = true;
|
|
57
|
+
try {
|
|
58
|
+
if (process.platform === "win32" && child.pid) {
|
|
59
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
60
|
+
stdio: "ignore",
|
|
61
|
+
windowsHide: true,
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
child.kill();
|
|
65
|
+
}
|
|
66
|
+
} catch (_error) {
|
|
67
|
+
// Ignore process cleanup failure.
|
|
68
|
+
}
|
|
69
|
+
resolve({
|
|
70
|
+
ok: false,
|
|
71
|
+
stdout,
|
|
72
|
+
stderr: `${stderr}\nTimeout ${ms}ms`,
|
|
73
|
+
status: null,
|
|
74
|
+
});
|
|
75
|
+
}, ms);
|
|
76
|
+
|
|
77
|
+
child.on("error", (err) => {
|
|
78
|
+
if (done) return;
|
|
79
|
+
done = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
resolve({ ok: false, stdout, stderr: stderr || err.message, status: null });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
child.on("close", (code) => {
|
|
85
|
+
if (done) return;
|
|
86
|
+
done = true;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve({ ok: code === 0, stdout, stderr, status: code });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { runCommand } from "../_shared/spawn";
|
|
5
|
+
import {
|
|
6
|
+
AGENT_DIR,
|
|
7
|
+
AGENT_ROOT_MARKDOWN_FILES,
|
|
8
|
+
AGENT_SKILLS_DIR,
|
|
9
|
+
MEMORY_MARKDOWN_FILES,
|
|
10
|
+
SESSIONS_DIR,
|
|
11
|
+
ensureDir,
|
|
12
|
+
isProjectAllowed,
|
|
13
|
+
type ManifestFile,
|
|
14
|
+
type SyncConfig,
|
|
15
|
+
} from "./config";
|
|
16
|
+
|
|
17
|
+
const TAR_TIMEOUT_MS = 300_000;
|
|
18
|
+
|
|
19
|
+
export function platformTag(): string {
|
|
20
|
+
const platform = os.platform();
|
|
21
|
+
if (platform === "win32") {
|
|
22
|
+
const build = parseInt(os.release().split(".")[2] ?? "0", 10);
|
|
23
|
+
return build >= 22000 ? "windows11" : "windows10";
|
|
24
|
+
}
|
|
25
|
+
if (platform === "darwin") return "macos";
|
|
26
|
+
if (platform === "linux") return "linux";
|
|
27
|
+
return platform;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function archiveTimestamp(): string {
|
|
31
|
+
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function yieldToUI(): Promise<void> {
|
|
35
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runTar(args: string[], options: { capture?: boolean; timeoutMs?: number } = {}): Promise<string> {
|
|
39
|
+
const result = await runCommand("tar", args, { timeoutMs: options.timeoutMs ?? TAR_TIMEOUT_MS });
|
|
40
|
+
if (!result.ok) throw new Error(result.stderr || `tar ${args[0]} failed with status ${result.status}`);
|
|
41
|
+
return options.capture ? result.stdout : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function copyRecursiveSync(src: string, dest: string): void {
|
|
45
|
+
if (!fs.existsSync(src)) return;
|
|
46
|
+
const stats = fs.statSync(src);
|
|
47
|
+
if (stats.isDirectory()) {
|
|
48
|
+
ensureDir(dest);
|
|
49
|
+
for (const child of fs.readdirSync(src)) copyRecursiveSync(path.join(src, child), path.join(dest, child));
|
|
50
|
+
} else {
|
|
51
|
+
ensureDir(path.dirname(dest));
|
|
52
|
+
fs.copyFileSync(src, dest);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function copyRecursiveSyncFiltered(src: string, dest: string, include: (name: string, isDirectory: boolean) => boolean): void {
|
|
57
|
+
if (!fs.existsSync(src)) return;
|
|
58
|
+
const stats = fs.statSync(src);
|
|
59
|
+
if (!include(path.basename(src), stats.isDirectory())) return;
|
|
60
|
+
if (stats.isDirectory()) {
|
|
61
|
+
ensureDir(dest);
|
|
62
|
+
for (const child of fs.readdirSync(src)) copyRecursiveSyncFiltered(path.join(src, child), path.join(dest, child), include);
|
|
63
|
+
} else {
|
|
64
|
+
ensureDir(path.dirname(dest));
|
|
65
|
+
fs.copyFileSync(src, dest);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function collectManifest(dir: string, archivePrefix: string, sourcePrefix: string, files: ManifestFile[]): void {
|
|
70
|
+
if (!fs.existsSync(dir)) return;
|
|
71
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
72
|
+
const archivePath = `${archivePrefix}/${entry.name}`;
|
|
73
|
+
const sourcePath = sourcePrefix ? `${sourcePrefix}/${entry.name}` : entry.name;
|
|
74
|
+
const full = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) collectManifest(full, archivePath, sourcePath, files);
|
|
76
|
+
else files.push({ archive: archivePath, source: sourcePath });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function writeManifest(tempDir: string, agentDir: string, files: ManifestFile[]): void {
|
|
81
|
+
fs.writeFileSync(path.join(tempDir, "manifest.json"), JSON.stringify({
|
|
82
|
+
version: 1,
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
agentDir,
|
|
85
|
+
fileCount: files.length,
|
|
86
|
+
files,
|
|
87
|
+
}, null, 2), "utf8");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function extractSessionTs(filename: string): string | null {
|
|
91
|
+
return filename.match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)_/)?.[1] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function listArchiveEntries(archivePath: string): Promise<string[]> {
|
|
95
|
+
return (await runTar(["-t", "-f", archivePath], { capture: true }))
|
|
96
|
+
.split(/\r?\n/)
|
|
97
|
+
.map((entry) => entry.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, ""))
|
|
98
|
+
.filter((entry) => entry && entry !== ".");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function validateArchiveEntries(entries: string[]): void {
|
|
102
|
+
const allowed = new Set(["config", "skills", "extensions", "sessions", "memory", "agent-skills", "manifest.json"]);
|
|
103
|
+
const legacyConfigFiles = new Set(["models.json", "settings.json", "auth.json"]);
|
|
104
|
+
const rootMarkdownFiles = new Set<string>(AGENT_ROOT_MARKDOWN_FILES);
|
|
105
|
+
if (entries.length === 0) throw new Error("Backup archive is empty or unreadable");
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const parts = entry.split("/");
|
|
108
|
+
if (entry.startsWith("/") || /^[a-zA-Z]:\//.test(entry) || parts.includes("..") || !allowed.has(parts[0]!)) {
|
|
109
|
+
throw new Error(`Unsafe archive path rejected: ${entry}`);
|
|
110
|
+
}
|
|
111
|
+
if (parts[0] !== "config" || !parts[1]) continue;
|
|
112
|
+
if (parts[1] === "root") {
|
|
113
|
+
if (!parts[2]) continue;
|
|
114
|
+
if (parts.length !== 3 || (!parts[2].endsWith(".json") && !rootMarkdownFiles.has(parts[2]))) {
|
|
115
|
+
throw new Error(`Unexpected root config file rejected: ${entry}`);
|
|
116
|
+
}
|
|
117
|
+
} else if (parts[1] === "sub") {
|
|
118
|
+
continue;
|
|
119
|
+
} else if (parts.length !== 2 || !legacyConfigFiles.has(parts[1])) {
|
|
120
|
+
throw new Error(`Unexpected config file rejected: ${entry}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function packTemporaryArchive(tempDir: string, archivePath: string): Promise<void> {
|
|
126
|
+
await yieldToUI();
|
|
127
|
+
await runTar(["-J", "-c", "-f", archivePath, "-C", tempDir, "."]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function createConfigZip(config: SyncConfig, archivePath: string): Promise<string[]> {
|
|
131
|
+
const tempDir = path.join(os.tmpdir(), `pi_config_temp_${Date.now()}`);
|
|
132
|
+
const rootDir = path.join(tempDir, "config", "root");
|
|
133
|
+
const subDir = path.join(tempDir, "config", "sub");
|
|
134
|
+
const manifest: ManifestFile[] = [];
|
|
135
|
+
const contents: string[] = [];
|
|
136
|
+
ensureDir(rootDir);
|
|
137
|
+
ensureDir(subDir);
|
|
138
|
+
try {
|
|
139
|
+
if (config.backupProviders) {
|
|
140
|
+
const rootNames = fs.readdirSync(AGENT_DIR).filter((name) => name.endsWith(".json"));
|
|
141
|
+
for (const name of AGENT_ROOT_MARKDOWN_FILES) if (!rootNames.includes(name)) rootNames.push(name);
|
|
142
|
+
for (const name of rootNames) {
|
|
143
|
+
const src = path.join(AGENT_DIR, name);
|
|
144
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
|
|
145
|
+
fs.copyFileSync(src, path.join(rootDir, name));
|
|
146
|
+
manifest.push({ archive: `config/root/${name}`, source: name });
|
|
147
|
+
contents.push(`Config: ${name}`);
|
|
148
|
+
}
|
|
149
|
+
const configDir = path.join(AGENT_DIR, "config");
|
|
150
|
+
if (fs.existsSync(configDir)) {
|
|
151
|
+
copyRecursiveSync(configDir, subDir);
|
|
152
|
+
collectManifest(subDir, "config/sub", "config", manifest);
|
|
153
|
+
contents.push("Config directory");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (config.backupExtensions) {
|
|
157
|
+
const source = path.join(AGENT_DIR, "extensions");
|
|
158
|
+
const dest = path.join(tempDir, "extensions");
|
|
159
|
+
if (fs.existsSync(source)) {
|
|
160
|
+
copyRecursiveSync(source, dest);
|
|
161
|
+
fs.rmSync(path.join(dest, "sync"), { recursive: true, force: true });
|
|
162
|
+
collectManifest(dest, "extensions", "extensions", manifest);
|
|
163
|
+
contents.push("Extensions directory");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (manifest.length === 0) throw new Error("No config or extension files found to back up.");
|
|
167
|
+
writeManifest(tempDir, AGENT_DIR, manifest);
|
|
168
|
+
await packTemporaryArchive(tempDir, archivePath);
|
|
169
|
+
return contents;
|
|
170
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function createMemoryZip(archivePath: string): Promise<string[]> {
|
|
174
|
+
const sourceDir = path.join(AGENT_DIR, "pi-hermes-memory");
|
|
175
|
+
const tempDir = path.join(os.tmpdir(), `pi_memory_temp_${Date.now()}`);
|
|
176
|
+
const destDir = path.join(tempDir, "memory");
|
|
177
|
+
const manifest: ManifestFile[] = [];
|
|
178
|
+
ensureDir(destDir);
|
|
179
|
+
try {
|
|
180
|
+
for (const name of MEMORY_MARKDOWN_FILES) {
|
|
181
|
+
const src = path.join(sourceDir, name);
|
|
182
|
+
if (!fs.existsSync(src)) continue;
|
|
183
|
+
fs.copyFileSync(src, path.join(destDir, name));
|
|
184
|
+
manifest.push({ archive: `memory/${name}`, source: `pi-hermes-memory/${name}` });
|
|
185
|
+
}
|
|
186
|
+
if (manifest.length === 0) throw new Error("No durable pi-hermes-memory markdown files found.");
|
|
187
|
+
writeManifest(tempDir, AGENT_DIR, manifest);
|
|
188
|
+
await packTemporaryArchive(tempDir, archivePath);
|
|
189
|
+
return manifest.map((item) => `Memory: ${path.basename(item.source)}`);
|
|
190
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function createAgentSkillsZip(archivePath: string): Promise<string[]> {
|
|
194
|
+
if (!fs.existsSync(AGENT_SKILLS_DIR)) throw new Error("~/.agents/skills does not exist.");
|
|
195
|
+
const tempDir = path.join(os.tmpdir(), `pi_agent_skills_temp_${Date.now()}`);
|
|
196
|
+
const destDir = path.join(tempDir, "agent-skills");
|
|
197
|
+
const manifest: ManifestFile[] = [];
|
|
198
|
+
try {
|
|
199
|
+
copyRecursiveSyncFiltered(AGENT_SKILLS_DIR, destDir, (name, isDirectory) => isDirectory ? name !== "__pycache__" : !name.endsWith(".pyc"));
|
|
200
|
+
collectManifest(destDir, "agent-skills", ".agents/skills", manifest);
|
|
201
|
+
if (manifest.length === 0) throw new Error("No shared agent skill files found.");
|
|
202
|
+
writeManifest(tempDir, os.homedir(), manifest);
|
|
203
|
+
await packTemporaryArchive(tempDir, archivePath);
|
|
204
|
+
return [`Shared agent skills: ${manifest.length} file(s)`];
|
|
205
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function createSessionsArchiveZip(projectDir: string, archivePath: string): Promise<string[]> {
|
|
209
|
+
const sourceDir = path.join(SESSIONS_DIR, projectDir);
|
|
210
|
+
if (!fs.existsSync(sourceDir)) throw new Error(`Session project not found: ${projectDir}`);
|
|
211
|
+
const tempDir = path.join(os.tmpdir(), `pi_sessions_temp_${Date.now()}`);
|
|
212
|
+
const destDir = path.join(tempDir, "sessions", projectDir);
|
|
213
|
+
const manifest: ManifestFile[] = [];
|
|
214
|
+
try {
|
|
215
|
+
copyRecursiveSync(sourceDir, destDir);
|
|
216
|
+
collectManifest(destDir, `sessions/${projectDir}`, `sessions/${projectDir}`, manifest);
|
|
217
|
+
if (manifest.length === 0) throw new Error(`No session files found for ${projectDir}.`);
|
|
218
|
+
writeManifest(tempDir, AGENT_DIR, manifest);
|
|
219
|
+
await packTemporaryArchive(tempDir, archivePath);
|
|
220
|
+
return [`Sessions: ${projectDir} (${manifest.length} file(s))`];
|
|
221
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function createLegacyZip(config: SyncConfig, archivePath: string): Promise<string[]> {
|
|
225
|
+
const tempDir = path.join(os.tmpdir(), `pi_sync_temp_${Date.now()}`);
|
|
226
|
+
const manifest: ManifestFile[] = [];
|
|
227
|
+
const contents: string[] = [];
|
|
228
|
+
ensureDir(tempDir);
|
|
229
|
+
try {
|
|
230
|
+
if (config.backupProviders) {
|
|
231
|
+
const configDir = path.join(tempDir, "config");
|
|
232
|
+
ensureDir(configDir);
|
|
233
|
+
for (const name of ["models.json", "settings.json", "auth.json"]) {
|
|
234
|
+
const src = path.join(AGENT_DIR, name);
|
|
235
|
+
if (!fs.existsSync(src)) continue;
|
|
236
|
+
fs.copyFileSync(src, path.join(configDir, name));
|
|
237
|
+
manifest.push({ archive: `config/${name}`, source: name });
|
|
238
|
+
contents.push(`Config: ${name}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (config.backupSkills) {
|
|
242
|
+
const src = path.join(AGENT_DIR, "skills"), dest = path.join(tempDir, "skills");
|
|
243
|
+
if (fs.existsSync(src)) { copyRecursiveSync(src, dest); collectManifest(dest, "skills", "skills", manifest); contents.push("Skills Directory"); }
|
|
244
|
+
}
|
|
245
|
+
if (config.backupExtensions) {
|
|
246
|
+
const src = path.join(AGENT_DIR, "extensions"), dest = path.join(tempDir, "extensions");
|
|
247
|
+
if (fs.existsSync(src)) { copyRecursiveSync(src, dest); fs.rmSync(path.join(dest, "sync"), { recursive: true, force: true }); collectManifest(dest, "extensions", "extensions", manifest); contents.push("Extensions Directory"); }
|
|
248
|
+
}
|
|
249
|
+
if (config.backupSessions) {
|
|
250
|
+
for (const entry of fs.existsSync(SESSIONS_DIR) ? fs.readdirSync(SESSIONS_DIR, { withFileTypes: true }) : []) {
|
|
251
|
+
if (!entry.isDirectory()) continue;
|
|
252
|
+
if (!isProjectAllowed(entry.name, config)) continue;
|
|
253
|
+
const dest = path.join(tempDir, "sessions", entry.name);
|
|
254
|
+
copyRecursiveSync(path.join(SESSIONS_DIR, entry.name), dest);
|
|
255
|
+
}
|
|
256
|
+
const sessionsDest = path.join(tempDir, "sessions");
|
|
257
|
+
if (fs.existsSync(sessionsDest)) { collectManifest(sessionsDest, "sessions", "sessions", manifest); contents.push("Sessions"); }
|
|
258
|
+
}
|
|
259
|
+
if (manifest.length === 0) throw new Error("No components selected or found to backup.");
|
|
260
|
+
writeManifest(tempDir, AGENT_DIR, manifest);
|
|
261
|
+
await packTemporaryArchive(tempDir, archivePath);
|
|
262
|
+
return contents;
|
|
263
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
264
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { ensureDir as ensureSharedDir, readJsonSafe, writeJsonAtomic } from "../_shared/json-io";
|
|
6
|
+
|
|
7
|
+
export const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
8
|
+
export const AGENT_SKILLS_DIR = path.join(os.homedir(), ".agents", "skills");
|
|
9
|
+
export const SESSIONS_DIR = path.join(AGENT_DIR, "sessions");
|
|
10
|
+
export const SYNC_CONFIG_DIR = path.join(AGENT_DIR, "config");
|
|
11
|
+
export const SYNC_CONFIG_PATH = path.join(SYNC_CONFIG_DIR, "sync.json");
|
|
12
|
+
export const LEGACY_SYNC_CONFIG_PATH = path.join(AGENT_DIR, "sync_config.json");
|
|
13
|
+
export const AGENT_ROOT_MARKDOWN_FILES = ["SYSTEM.md", "AGENTS.md", "APPEND_SYSTEM.md"] as const;
|
|
14
|
+
export const MEMORY_MARKDOWN_FILES = ["USER.md", "MEMORY.md", "failures.md"] as const;
|
|
15
|
+
|
|
16
|
+
export type ManifestFile = { archive: string; source: string };
|
|
17
|
+
|
|
18
|
+
export interface SyncConfig {
|
|
19
|
+
webdavUrl: string;
|
|
20
|
+
webdavUser: string;
|
|
21
|
+
webdavPass: string;
|
|
22
|
+
backupProviders: boolean;
|
|
23
|
+
backupSkills: boolean;
|
|
24
|
+
backupExtensions: boolean;
|
|
25
|
+
backupSessions: boolean;
|
|
26
|
+
sessionProjects: string[];
|
|
27
|
+
liveSessionBackup: boolean;
|
|
28
|
+
liveBackupDebounceMs: number;
|
|
29
|
+
syncIntervalTurns: number;
|
|
30
|
+
syncSessionOnExit: boolean;
|
|
31
|
+
backupMemory: boolean;
|
|
32
|
+
backupAgentSkills: boolean;
|
|
33
|
+
sessionProjectMode: "whitelist" | "blacklist";
|
|
34
|
+
maxBackups: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ensureDir(dir: string): void {
|
|
38
|
+
ensureSharedDir(dir);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfig(): SyncConfig {
|
|
42
|
+
if (!fs.existsSync(SYNC_CONFIG_PATH) && fs.existsSync(LEGACY_SYNC_CONFIG_PATH)) {
|
|
43
|
+
ensureDir(SYNC_CONFIG_DIR);
|
|
44
|
+
try { fs.copyFileSync(LEGACY_SYNC_CONFIG_PATH, SYNC_CONFIG_PATH); } catch { /* best effort migration */ }
|
|
45
|
+
}
|
|
46
|
+
const data = readJsonSafe<Partial<SyncConfig>>(SYNC_CONFIG_PATH, {});
|
|
47
|
+
const normalizeList = (value: unknown): string[] => Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
48
|
+
return {
|
|
49
|
+
webdavUrl: data.webdavUrl || "",
|
|
50
|
+
webdavUser: data.webdavUser || "",
|
|
51
|
+
webdavPass: data.webdavPass || "",
|
|
52
|
+
backupProviders: data.backupProviders !== false,
|
|
53
|
+
backupSkills: data.backupSkills !== false,
|
|
54
|
+
backupExtensions: data.backupExtensions !== false,
|
|
55
|
+
backupSessions: data.backupSessions === true,
|
|
56
|
+
sessionProjects: normalizeList(data.sessionProjects),
|
|
57
|
+
liveSessionBackup: data.liveSessionBackup === true,
|
|
58
|
+
liveBackupDebounceMs: typeof data.liveBackupDebounceMs === "number" && data.liveBackupDebounceMs > 0 ? data.liveBackupDebounceMs : 3000,
|
|
59
|
+
syncIntervalTurns: typeof data.syncIntervalTurns === "number" && data.syncIntervalTurns >= 0 ? Math.floor(data.syncIntervalTurns) : 0,
|
|
60
|
+
syncSessionOnExit: data.syncSessionOnExit !== false,
|
|
61
|
+
backupMemory: data.backupMemory !== false,
|
|
62
|
+
backupAgentSkills: data.backupAgentSkills !== false,
|
|
63
|
+
sessionProjectMode: data.sessionProjectMode === "blacklist" ? "blacklist" : "whitelist",
|
|
64
|
+
maxBackups: typeof data.maxBackups === "number" && data.maxBackups >= 0 ? Math.floor(data.maxBackups) : 10,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveConfig(config: SyncConfig, ctx?: Pick<ExtensionContext, "ui">): void {
|
|
69
|
+
ensureDir(path.dirname(SYNC_CONFIG_PATH));
|
|
70
|
+
writeJsonAtomic(SYNC_CONFIG_PATH, config, { backup: true });
|
|
71
|
+
if (ctx) refreshFooterStatusFromConfig(ctx, config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Refresh footer status from an already-loaded config (avoids re-reading the file). */
|
|
75
|
+
export function refreshFooterStatusFromConfig(ctx: Pick<ExtensionContext, "ui">, config: SyncConfig): void {
|
|
76
|
+
const parts: string[] = [];
|
|
77
|
+
if (config.liveSessionBackup) parts.push("⚡");
|
|
78
|
+
if (config.syncIntervalTurns > 0) parts.push(`🔄${config.syncIntervalTurns}`);
|
|
79
|
+
if (config.syncSessionOnExit) parts.push("📤");
|
|
80
|
+
ctx.ui.setStatus("pi-sync", parts.length ? `sync:${parts.join("")}` : undefined);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isProjectAllowed(projectDir: string | undefined, config: SyncConfig): boolean {
|
|
84
|
+
if (!projectDir) return false;
|
|
85
|
+
const listed = config.sessionProjects.includes(projectDir);
|
|
86
|
+
// whitelist: only listed projects are backed up (empty = none)
|
|
87
|
+
// blacklist: listed projects are skipped (empty = all)
|
|
88
|
+
return config.sessionProjectMode === "blacklist" ? !listed : listed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolvePassword(pass: string): string {
|
|
92
|
+
return pass.startsWith("$") ? process.env[pass.slice(1)] ?? pass : pass;
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { loadConfig, refreshFooterStatusFromConfig } from "./config";
|
|
4
|
+
import { registerSyncCommand } from "./menus";
|
|
5
|
+
import {
|
|
6
|
+
clearLiveBackupTimer,
|
|
7
|
+
currentProjectIsAllowed,
|
|
8
|
+
incrementTurnAndShouldSync,
|
|
9
|
+
refreshSessionFile,
|
|
10
|
+
scheduleLiveBackup,
|
|
11
|
+
setSessionContext,
|
|
12
|
+
uploadCurrentSession,
|
|
13
|
+
} from "./session-sync";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* pi-sync extension entrypoint.
|
|
17
|
+
*
|
|
18
|
+
* Wires session lifecycle hooks to the session-sync state module and registers
|
|
19
|
+
* the `/sync` interactive command. All implementation lives in the responsibility
|
|
20
|
+
* modules: config, webdav, archive, restore, session-sync, menus.
|
|
21
|
+
*/
|
|
22
|
+
export default function (pi: ExtensionAPI): void {
|
|
23
|
+
// Capture the active session file/project dir for live backup and interval sync.
|
|
24
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
25
|
+
setSessionContext(ctx.sessionManager.getSessionFile() ?? undefined, ctx.sessionManager.getSessionDir());
|
|
26
|
+
refreshFooterStatusFromConfig(ctx, loadConfig());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Refresh the session path (compaction/fork may change it) and schedule a debounced live backup.
|
|
30
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
31
|
+
refreshSessionFile(ctx.sessionManager.getSessionFile() ?? undefined);
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
if (config.liveSessionBackup && currentProjectIsAllowed(config)) {
|
|
34
|
+
scheduleLiveBackup(ctx);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Every-N-turns interval sync (0 = off). Only counts turns for allowed projects.
|
|
39
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
if (!config.syncIntervalTurns || config.syncIntervalTurns <= 0) return;
|
|
42
|
+
if (!currentProjectIsAllowed(config)) return;
|
|
43
|
+
refreshSessionFile(ctx.sessionManager.getSessionFile() ?? undefined);
|
|
44
|
+
if (incrementTurnAndShouldSync(config.syncIntervalTurns)) {
|
|
45
|
+
await uploadCurrentSession(ctx, true).catch(() => { /* silent */ });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Flush on exit: upload if sync-on-exit or live backup is enabled.
|
|
50
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
51
|
+
clearLiveBackupTimer();
|
|
52
|
+
const config = loadConfig();
|
|
53
|
+
if (config.syncSessionOnExit || config.liveSessionBackup) {
|
|
54
|
+
await uploadCurrentSession(ctx, true).catch(() => { /* silent */ });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
registerSyncCommand(pi);
|
|
59
|
+
}
|