@codedealer/pi-sync 1.0.0 → 1.4.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 +29 -2
- package/package.json +5 -2
- package/resources/extensions/pi-sync/README.md +100 -0
- package/resources/extensions/pi-sync/index.ts +564 -0
- package/resources/extensions/pi-sync/status.js +217 -0
- package/resources/skills/pi-sync/SKILL.md +21 -1
- package/src/cli.js +264 -39
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Sync settings watcher.
|
|
3
|
+
*
|
|
4
|
+
* Pi writes settings.json (and other managed settings files) whenever the
|
|
5
|
+
* user changes a model, theme, thinking level, or any other setting in the
|
|
6
|
+
* TUI. Pi Sync manages selected fields of those files through the private
|
|
7
|
+
* setup repository, so a local-only change would silently drift away from
|
|
8
|
+
* the manifest until someone edits it by hand.
|
|
9
|
+
*
|
|
10
|
+
* This extension closes that loop: it watches the agent directory for
|
|
11
|
+
* changes to the files the setup manifest declares as settings targets and,
|
|
12
|
+
* after a short debounce, runs `pi-sync sync --apply`, which promotes
|
|
13
|
+
* local-only changes into the manifest (three-way compared against the last
|
|
14
|
+
* applied baseline, portability-validated, and atomically written). All
|
|
15
|
+
* sync logic lives in the pi-sync CLI; this extension is a thin trigger
|
|
16
|
+
* and relay, so the CLI remains the single source of truth and stays usable
|
|
17
|
+
* headless.
|
|
18
|
+
*
|
|
19
|
+
* The extension also renders the CLI's sync summary as a persistent footer.
|
|
20
|
+
* A single `refresh` path keeps it current: it computes a flat metadata
|
|
21
|
+
* fingerprint (git refs, manifest, lockfile, agent baseline, settings
|
|
22
|
+
* targets), skips when the in-memory fingerprint is unchanged, reuses a
|
|
23
|
+
* matching entry from the shared status cache written by another session,
|
|
24
|
+
* and otherwise runs `pi-sync status` and publishes its `summary` to the
|
|
25
|
+
* cache. Refreshes are triggered at session start, after every sync
|
|
26
|
+
* completion, on top-level setup-repository changes (a debounced watcher),
|
|
27
|
+
* and by an unref'ed 60-second tick that costs only the fingerprint's stat
|
|
28
|
+
* calls in steady state. The session-start path never awaits the CLI, and
|
|
29
|
+
* the extension never computes drift itself; a failed refresh shows the
|
|
30
|
+
* failure state without erasing the last successful summary. A CLI result
|
|
31
|
+
* without a `summary` block (older CLI) shows no footer and falls back to
|
|
32
|
+
* the pre-footer status-entry behavior, and non-managed setups stay
|
|
33
|
+
* completely silent.
|
|
34
|
+
*
|
|
35
|
+
* At session start the extension also makes remote commits visible without
|
|
36
|
+
* pulling, merging, or applying them: a fetch-only `git fetch --quiet`,
|
|
37
|
+
* skipped when FETCH_HEAD is younger than five minutes, single-flight,
|
|
38
|
+
* bounded by a 15-second timeout, and triggered only at session start — the
|
|
39
|
+
* tick never touches the network. The fetch's completion (success or
|
|
40
|
+
* failure) runs the normal refresh path, so the ref and FETCH_HEAD changes
|
|
41
|
+
* are picked up by the fingerprint like any other change. A fetch failure
|
|
42
|
+
* is silent: network or authentication trouble is not a sync-state failure,
|
|
43
|
+
* so it never shows the failure state, never announces itself, and the
|
|
44
|
+
* footer keeps its last known state until the next session retries.
|
|
45
|
+
*
|
|
46
|
+
* At Pi process startup (session_start with reason "startup" — not on
|
|
47
|
+
* resume, fork, new-session, or reload starts) a managed setup also
|
|
48
|
+
* receives a one-line orientation notification naming the two flows a user
|
|
49
|
+
* is most likely to need: updating dependencies with `pi-sync update` and
|
|
50
|
+
* managing resources by editing the setup repository and running
|
|
51
|
+
* `pi-sync apply`, plus a pointer to the pi-sync skill for the full
|
|
52
|
+
* workflow. It is delivered as a user-facing info notification only —
|
|
53
|
+
* never a transcript entry, custom session message, or system-prompt
|
|
54
|
+
* section — so it stays out of the transcript and the LLM context, and it
|
|
55
|
+
* is absent on setups that are not managed by Pi Sync.
|
|
56
|
+
*
|
|
57
|
+
* Environment overrides:
|
|
58
|
+
* PI_SYNC_REPO_DIR setup repository path (default ~/.pi-sync/setup)
|
|
59
|
+
* PI_SYNC_BIN pi-sync executable (default `pi-sync` on PATH)
|
|
60
|
+
*/
|
|
61
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
62
|
+
import { execFile } from "node:child_process";
|
|
63
|
+
import { existsSync, readFileSync, statSync, watch, type FSWatcher } from "node:fs";
|
|
64
|
+
import os from "node:os";
|
|
65
|
+
import path from "node:path";
|
|
66
|
+
import { promisify } from "node:util";
|
|
67
|
+
import {
|
|
68
|
+
computeFingerprint,
|
|
69
|
+
extractSummary,
|
|
70
|
+
fetchHeadPath,
|
|
71
|
+
fingerprintPaths,
|
|
72
|
+
fingerprintsEqual,
|
|
73
|
+
isFetchFresh,
|
|
74
|
+
readStatusCache,
|
|
75
|
+
renderFooter,
|
|
76
|
+
statusCachePath,
|
|
77
|
+
writeStatusCache,
|
|
78
|
+
} from "./status.js";
|
|
79
|
+
|
|
80
|
+
const execFileAsync = promisify(execFile);
|
|
81
|
+
|
|
82
|
+
const STATUS_ID = "pi-sync";
|
|
83
|
+
const DEBOUNCE_MS = 400;
|
|
84
|
+
const INITIAL_SYNC_DELAY_MS = 1500;
|
|
85
|
+
const SYNC_TIMEOUT_MS = 20_000;
|
|
86
|
+
const TICK_MS = 60_000;
|
|
87
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
88
|
+
|
|
89
|
+
interface SyncedField {
|
|
90
|
+
target: string;
|
|
91
|
+
path: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface SyncResult {
|
|
95
|
+
apiVersion: number;
|
|
96
|
+
command: string;
|
|
97
|
+
ok: boolean;
|
|
98
|
+
exitCode: number;
|
|
99
|
+
mode: string;
|
|
100
|
+
synced?: SyncedField[];
|
|
101
|
+
conflicts?: unknown[];
|
|
102
|
+
rejected?: unknown[];
|
|
103
|
+
review?: unknown[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface SyncSummary {
|
|
107
|
+
localPending: number;
|
|
108
|
+
remotePending: number;
|
|
109
|
+
needsReview: number;
|
|
110
|
+
clean: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A fingerprint: ordered [path, mtimeMs] pairs, with the stable absent marker null for missing paths. */
|
|
114
|
+
type Fingerprint = [string, number | null][];
|
|
115
|
+
|
|
116
|
+
/** What the persistent footer currently renders. */
|
|
117
|
+
type FooterPhase = "checking" | "summary" | "failed" | "legacy";
|
|
118
|
+
|
|
119
|
+
/** True for a structured CLI failure result (ok:false plus an error string), as opposed to a valid result that merely reports conflicts. */
|
|
120
|
+
function isCliError(value: unknown): value is { ok: false; error: string } {
|
|
121
|
+
return (
|
|
122
|
+
value !== null &&
|
|
123
|
+
typeof value === "object" &&
|
|
124
|
+
(value as { ok?: unknown }).ok === false &&
|
|
125
|
+
typeof (value as { error?: unknown }).error === "string"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default function (pi: ExtensionAPI) {
|
|
130
|
+
let watcher: FSWatcher | undefined;
|
|
131
|
+
let repoWatcher: FSWatcher | undefined;
|
|
132
|
+
let timer: NodeJS.Timeout | undefined;
|
|
133
|
+
let refreshTimer: NodeJS.Timeout | undefined;
|
|
134
|
+
let tick: NodeJS.Timeout | undefined;
|
|
135
|
+
let running = false;
|
|
136
|
+
let pending = false;
|
|
137
|
+
let warned = false;
|
|
138
|
+
// Persistent footer state: the phase plus the last successfully rendered
|
|
139
|
+
// summary. A failed check never clears lastSummary — it only shows the
|
|
140
|
+
// failure state until the next successful check. footerShown tracks whether
|
|
141
|
+
// the footer currently owns the status entry: in the legacy phase (older
|
|
142
|
+
// CLI) it clears the entry once when leaving the footer and then leaves
|
|
143
|
+
// the entry alone so the pre-footer announce behavior keeps working.
|
|
144
|
+
let phase: FooterPhase = "checking";
|
|
145
|
+
let lastSummary: SyncSummary | undefined;
|
|
146
|
+
let footerShown = false;
|
|
147
|
+
let statusWarned = false;
|
|
148
|
+
let refreshGeneration = 0;
|
|
149
|
+
// Single-flight flag for the session-start fetch: while a fetch is in
|
|
150
|
+
// flight, further session starts skip their own fetch (the in-flight one's
|
|
151
|
+
// completion already triggers the refresh). The execFile timeout bounds
|
|
152
|
+
// the in-flight window, so the flag always clears within ~15 s. It is
|
|
153
|
+
// deliberately not reset on shutdown: the in-flight child finishes on its
|
|
154
|
+
// own and its completion refresh still runs, while resetting would allow
|
|
155
|
+
// two overlapping fetches in the same process.
|
|
156
|
+
let fetchInFlight = false;
|
|
157
|
+
// The fingerprint the footer currently reflects — advanced only by a
|
|
158
|
+
// successful refresh (or a cache hit), never by a failed one, so the next
|
|
159
|
+
// trigger retries a failed check. Reset at session start so the first
|
|
160
|
+
// refresh of each session always at least consults the shared cache.
|
|
161
|
+
let lastFingerprint: Fingerprint | undefined;
|
|
162
|
+
let targetsMemo: { mtime: number | null; targets: string[] } | undefined;
|
|
163
|
+
|
|
164
|
+
const repoDir = () => process.env.PI_SYNC_REPO_DIR ?? path.join(os.homedir(), ".pi-sync", "setup");
|
|
165
|
+
const agentDir = () => getAgentDir();
|
|
166
|
+
const cliBinary = () => process.env.PI_SYNC_BIN ?? "pi-sync";
|
|
167
|
+
|
|
168
|
+
/** File names the setup manifest declares as settings targets, or [] when this is not a pi-sync-managed setup. */
|
|
169
|
+
function managedTargets(): string[] {
|
|
170
|
+
try {
|
|
171
|
+
const manifest = JSON.parse(readFileSync(path.join(repoDir(), "pi-sync.json"), "utf8")) as {
|
|
172
|
+
settings?: Array<{ target?: unknown }>;
|
|
173
|
+
};
|
|
174
|
+
const targets = (manifest.settings ?? []).map((item) => item?.target);
|
|
175
|
+
return [...new Set(targets.filter((target): target is string => typeof target === "string"))];
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function styleOf(ctx: ExtensionContext) {
|
|
182
|
+
return (color: "muted" | "dim" | "warning" | "accent" | "error", text: string) => ctx.ui.theme.fg(color, text);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The manifest's declared settings targets, memoized on the manifest's
|
|
187
|
+
* mtime so the 60-second tick stays metadata-only (stat calls, no file
|
|
188
|
+
* reads). The manifest's mtime is part of the fingerprint, so any real
|
|
189
|
+
* manifest change invalidates the memo and the fingerprint together.
|
|
190
|
+
*/
|
|
191
|
+
function currentTargets(): string[] {
|
|
192
|
+
const manifestPath = path.join(repoDir(), "pi-sync.json");
|
|
193
|
+
let mtime: number | null = null;
|
|
194
|
+
try {
|
|
195
|
+
mtime = statSync(manifestPath).mtimeMs;
|
|
196
|
+
} catch {
|
|
197
|
+
// Absent manifest: the targets stay [] (unmanaged setup).
|
|
198
|
+
}
|
|
199
|
+
if (targetsMemo !== undefined && targetsMemo.mtime === mtime) return targetsMemo.targets;
|
|
200
|
+
targetsMemo = { mtime, targets: managedTargets() };
|
|
201
|
+
return targetsMemo.targets;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The footer text for the current state, or undefined when no footer is shown (legacy CLI). */
|
|
205
|
+
function footerText(ctx: ExtensionContext): string | undefined {
|
|
206
|
+
let input: unknown;
|
|
207
|
+
if (running) input = { state: "syncing" };
|
|
208
|
+
else if (phase === "failed") input = { state: "failed" };
|
|
209
|
+
else if (phase === "summary") input = lastSummary;
|
|
210
|
+
else if (phase === "checking") input = { state: "checking" };
|
|
211
|
+
else input = null; // legacy: no footer
|
|
212
|
+
return renderFooter(input, styleOf(ctx)) ?? undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function setFooter(ctx: ExtensionContext) {
|
|
216
|
+
if (!ctx.hasUI) return;
|
|
217
|
+
try {
|
|
218
|
+
if (phase === "legacy") {
|
|
219
|
+
if (footerShown) {
|
|
220
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
221
|
+
footerShown = false;
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
ctx.ui.setStatus(STATUS_ID, footerText(ctx));
|
|
226
|
+
footerShown = true;
|
|
227
|
+
} catch {
|
|
228
|
+
// UI state can be torn down while an async check is in flight; never let that break the session.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function announce(ctx: ExtensionContext, message: string, color: "dim" | "warning", level: "info" | "warning") {
|
|
233
|
+
if (!ctx.hasUI) return;
|
|
234
|
+
try {
|
|
235
|
+
ctx.ui.notify(message, level);
|
|
236
|
+
// The footer is the canonical persistent indicator once it is shown;
|
|
237
|
+
// only the legacy (older-CLI) fallback keeps the bare status entry.
|
|
238
|
+
if (phase === "legacy") {
|
|
239
|
+
ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg(color, "pi-sync"));
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// UI state can be torn down while a debounced sync is in flight; never let that break the session.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The single refresh path for the persistent footer.
|
|
248
|
+
*
|
|
249
|
+
* Computes the flat metadata fingerprint (git refs, manifest, lockfile,
|
|
250
|
+
* agent baseline, settings targets) and then, in order:
|
|
251
|
+
* 1. fingerprint equals the in-memory last fingerprint → do nothing
|
|
252
|
+
* (the footer already reflects this state; steady-state cost is the
|
|
253
|
+
* fingerprint's stat calls only);
|
|
254
|
+
* 2. the shared status cache carries this fingerprint → render the
|
|
255
|
+
* cached summary (another session already recomputed; no spawn);
|
|
256
|
+
* 3. otherwise run `pi-sync status`, render the summary, and publish
|
|
257
|
+
* { fingerprint, summary } to the shared cache.
|
|
258
|
+
*
|
|
259
|
+
* Never awaited by the session-start path. A non-zero exit is not itself
|
|
260
|
+
* a failure: the CLI emits its full JSON result on stdout even when
|
|
261
|
+
* conflicts need review (exit 1) or validation fails (exit 2); only
|
|
262
|
+
* spawn, timeout, structured CLI error, and unparseable output count as
|
|
263
|
+
* failures. A failure shows the failure state and warns once (reusing the
|
|
264
|
+
* existing one-time warning pattern); it never clears the last successful
|
|
265
|
+
* summary and never advances the in-memory fingerprint, so the next
|
|
266
|
+
* trigger retries.
|
|
267
|
+
*/
|
|
268
|
+
async function refresh(ctx: ExtensionContext) {
|
|
269
|
+
const generation = ++refreshGeneration;
|
|
270
|
+
const fingerprint = computeFingerprint(
|
|
271
|
+
fingerprintPaths({ repoDir: repoDir(), agentDir: agentDir(), settingsTargets: currentTargets() }),
|
|
272
|
+
) as Fingerprint;
|
|
273
|
+
if (fingerprintsEqual(fingerprint, lastFingerprint)) return; // footer already correct
|
|
274
|
+
const cached = readStatusCache(statusCachePath(agentDir()));
|
|
275
|
+
if (cached !== null && fingerprintsEqual(cached.fingerprint, fingerprint)) {
|
|
276
|
+
lastSummary = cached.summary;
|
|
277
|
+
phase = "summary";
|
|
278
|
+
lastFingerprint = fingerprint;
|
|
279
|
+
setFooter(ctx);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
let stdout: string;
|
|
283
|
+
try {
|
|
284
|
+
stdout = await new Promise<string>((resolve, reject) => {
|
|
285
|
+
execFile(
|
|
286
|
+
cliBinary(),
|
|
287
|
+
["status", "--repo", repoDir(), "--agent-dir", agentDir()],
|
|
288
|
+
{ timeout: SYNC_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 },
|
|
289
|
+
(error, out) => {
|
|
290
|
+
if (error !== null && (error.killed === true || error.signal !== undefined)) {
|
|
291
|
+
reject(error); // timeout or kill: no usable output
|
|
292
|
+
} else if (typeof out === "string" && out.length > 0) {
|
|
293
|
+
resolve(out);
|
|
294
|
+
} else {
|
|
295
|
+
reject(error ?? new Error("pi-sync status produced no output"));
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (generation !== refreshGeneration) return; // superseded by a newer session's check
|
|
302
|
+
phase = "failed";
|
|
303
|
+
if (!statusWarned) {
|
|
304
|
+
statusWarned = true;
|
|
305
|
+
const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
306
|
+
announce(ctx, `pi-sync: status check failed (${detail}); the footer shows the failure state until the next check`, "warning", "warning");
|
|
307
|
+
}
|
|
308
|
+
setFooter(ctx);
|
|
309
|
+
return; // lastSummary and lastFingerprint are untouched: the next trigger retries
|
|
310
|
+
}
|
|
311
|
+
if (generation !== refreshGeneration) return; // superseded by a newer session's check
|
|
312
|
+
let parsed: unknown;
|
|
313
|
+
let parseOk = false;
|
|
314
|
+
try {
|
|
315
|
+
parsed = JSON.parse(stdout);
|
|
316
|
+
parseOk = true;
|
|
317
|
+
} catch {
|
|
318
|
+
parsed = null;
|
|
319
|
+
}
|
|
320
|
+
const summary = extractSummary(parsed);
|
|
321
|
+
if (summary) {
|
|
322
|
+
lastSummary = summary;
|
|
323
|
+
phase = "summary";
|
|
324
|
+
lastFingerprint = fingerprint;
|
|
325
|
+
try {
|
|
326
|
+
writeStatusCache(statusCachePath(agentDir()), { fingerprint, summary });
|
|
327
|
+
} catch {
|
|
328
|
+
// The shared cache is a cross-session optimization; a write failure
|
|
329
|
+
// only costs other sessions a redundant recompute, not this render.
|
|
330
|
+
}
|
|
331
|
+
} else if (isCliError(parsed)) {
|
|
332
|
+
// A structured CLI failure (repository error, invalid manifest, …) is
|
|
333
|
+
// a status-check failure, not a sync state.
|
|
334
|
+
phase = "failed";
|
|
335
|
+
if (!statusWarned) {
|
|
336
|
+
statusWarned = true;
|
|
337
|
+
announce(ctx, `pi-sync: status check failed (${parsed.error}); run pi-sync status for details`, "warning", "warning");
|
|
338
|
+
}
|
|
339
|
+
} else if (parseOk) {
|
|
340
|
+
// Older CLI without a summary block: show no footer and keep the
|
|
341
|
+
// pre-footer post-sync status entry behavior. There is nothing to
|
|
342
|
+
// cache, but the result is deterministic for this fingerprint, so
|
|
343
|
+
// record it to keep the tick from re-spawning the CLI every minute.
|
|
344
|
+
phase = "legacy";
|
|
345
|
+
lastFingerprint = fingerprint;
|
|
346
|
+
} else {
|
|
347
|
+
// Unparseable output is a check failure per the spec, not a legacy result.
|
|
348
|
+
phase = "failed";
|
|
349
|
+
if (!statusWarned) {
|
|
350
|
+
statusWarned = true;
|
|
351
|
+
announce(ctx, "pi-sync: status check produced unparseable output; the footer shows the failure state until the next check", "warning", "warning");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
setFooter(ctx);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The session-start remote check: a fetch-only `git fetch --quiet`
|
|
359
|
+
* against the upstream that makes remote commits visible in the footer
|
|
360
|
+
* without ever pulling, merging, or applying them (applying remote
|
|
361
|
+
* changes remains the explicit human flow: pull, then `pi-sync apply`).
|
|
362
|
+
*
|
|
363
|
+
* Skipped when the repository's FETCH_HEAD (rewritten by every fetch) is
|
|
364
|
+
* younger than five minutes — this covers back-to-back sessions and a
|
|
365
|
+
* fetch the user just ran. Otherwise a single-flight (never overlapping)
|
|
366
|
+
* child process runs the fetch with a 15-second timeout. The extension
|
|
367
|
+
* invokes git directly for this one read-only network operation; no CLI
|
|
368
|
+
* subcommand is introduced, and nothing else in the extension touches the
|
|
369
|
+
* network (the 60-second tick stays offline).
|
|
370
|
+
*
|
|
371
|
+
* On completion — success or failure — the normal refresh path is
|
|
372
|
+
* triggered, so the ref and FETCH_HEAD mtime changes are picked up by the
|
|
373
|
+
* fingerprint like any other change; when the fetch changed nothing (or
|
|
374
|
+
* failed), the refresh dedupes in memory and costs stat calls only.
|
|
375
|
+
* Failure is silent by design: network or authentication trouble is not a
|
|
376
|
+
* sync-state failure, so it never shows the failed state, never warns, and
|
|
377
|
+
* never touches the footer — which keeps its last known state — and the
|
|
378
|
+
* next session retries. Never awaited by the session-start path.
|
|
379
|
+
*/
|
|
380
|
+
async function checkRemote(ctx: ExtensionContext) {
|
|
381
|
+
if (fetchInFlight) return; // single-flight: never overlapping
|
|
382
|
+
let fetchHeadMtimeMs: number | null = null;
|
|
383
|
+
try {
|
|
384
|
+
fetchHeadMtimeMs = statSync(fetchHeadPath(repoDir())).mtimeMs;
|
|
385
|
+
} catch {
|
|
386
|
+
// Absent or unreadable FETCH_HEAD: never fetched (or a non-git setup) — run the fetch.
|
|
387
|
+
}
|
|
388
|
+
if (isFetchFresh(fetchHeadMtimeMs, Date.now())) return; // a recent fetch already moved the upstream comparison
|
|
389
|
+
fetchInFlight = true;
|
|
390
|
+
try {
|
|
391
|
+
await new Promise<void>((resolve) => {
|
|
392
|
+
execFile(
|
|
393
|
+
"git",
|
|
394
|
+
["fetch", "--quiet"],
|
|
395
|
+
{ cwd: repoDir(), timeout: FETCH_TIMEOUT_MS },
|
|
396
|
+
() => resolve(), // success and failure alike: the refresh below runs either way
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
} finally {
|
|
400
|
+
fetchInFlight = false;
|
|
401
|
+
}
|
|
402
|
+
void refresh(ctx);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function runSync(ctx: ExtensionContext, manual: boolean) {
|
|
406
|
+
if (running) {
|
|
407
|
+
pending = true;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
running = true;
|
|
411
|
+
setFooter(ctx); // auto-sync in flight: footer shows ⟳
|
|
412
|
+
try {
|
|
413
|
+
const { stdout } = await execFileAsync(cliBinary(), ["sync", "--apply", "--repo", repoDir(), "--agent-dir", agentDir()], {
|
|
414
|
+
timeout: SYNC_TIMEOUT_MS,
|
|
415
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
416
|
+
});
|
|
417
|
+
const result = JSON.parse(stdout) as SyncResult;
|
|
418
|
+
if (result.ok && result.synced?.length) {
|
|
419
|
+
const summary = result.synced.map((item) => `${item.target} ${item.path}`).join(", ");
|
|
420
|
+
announce(ctx, `pi-sync: synced local setting change${result.synced.length > 1 ? "s" : ""} to the setup repo (${summary})`, "dim", "info");
|
|
421
|
+
} else if (!result.ok) {
|
|
422
|
+
const count = (result.conflicts?.length ?? 0) + (result.rejected?.length ?? 0) + (result.review?.length ?? 0);
|
|
423
|
+
announce(ctx, `pi-sync: ${count} managed setting change${count === 1 ? "" : "s"} need manual review; run pi-sync plan`, "warning", "warning");
|
|
424
|
+
} else if (manual) {
|
|
425
|
+
announce(ctx, "pi-sync: nothing to sync; local settings match the setup repo", "dim", "info");
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!warned) {
|
|
429
|
+
warned = true;
|
|
430
|
+
const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
431
|
+
announce(ctx, `pi-sync: automatic settings sync failed (${detail}); it will retry on the next change`, "warning", "warning");
|
|
432
|
+
}
|
|
433
|
+
} finally {
|
|
434
|
+
running = false;
|
|
435
|
+
setFooter(ctx); // back to the last known state (summary, failure, or checking)
|
|
436
|
+
if (pending) {
|
|
437
|
+
pending = false;
|
|
438
|
+
schedule(ctx);
|
|
439
|
+
}
|
|
440
|
+
// After every sync completion (watcher-triggered or manual), re-check
|
|
441
|
+
// so the footer matches the sync's result; the fingerprint dedupes if
|
|
442
|
+
// nothing the sync wrote is fingerprinted.
|
|
443
|
+
void refresh(ctx);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function schedule(ctx: ExtensionContext) {
|
|
448
|
+
if (timer) clearTimeout(timer);
|
|
449
|
+
timer = setTimeout(() => {
|
|
450
|
+
timer = undefined;
|
|
451
|
+
void runSync(ctx, false);
|
|
452
|
+
}, DEBOUNCE_MS);
|
|
453
|
+
timer.unref?.();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Debounced refresh from top-level setup-repository watcher events. */
|
|
457
|
+
function scheduleRefresh(ctx: ExtensionContext) {
|
|
458
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
459
|
+
refreshTimer = setTimeout(() => {
|
|
460
|
+
refreshTimer = undefined;
|
|
461
|
+
void refresh(ctx);
|
|
462
|
+
}, DEBOUNCE_MS);
|
|
463
|
+
refreshTimer.unref?.();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
pi.on("session_start", (event, ctx) => {
|
|
467
|
+
const targets = currentTargets();
|
|
468
|
+
const dir = agentDir();
|
|
469
|
+
if (!targets.length || !existsSync(dir)) return; // not a pi-sync-managed setup: stay silent
|
|
470
|
+
// One-line startup orientation for managed setups: process startup only
|
|
471
|
+
// (reason "startup" — not resume/fork/new/reload, whose users are
|
|
472
|
+
// already oriented), a user-facing info notification only (never a
|
|
473
|
+
// transcript entry or LLM context), and local and free (no freshness
|
|
474
|
+
// gate). The managed gate above keeps unmanaged setups silent.
|
|
475
|
+
if (event.reason === "startup" && ctx.hasUI) {
|
|
476
|
+
try {
|
|
477
|
+
ctx.ui.notify(
|
|
478
|
+
"pi-sync: managed setup — update dependencies with pi-sync update; manage resources by editing the setup repository, then pi-sync apply; the pi-sync skill documents the full workflow",
|
|
479
|
+
"info",
|
|
480
|
+
);
|
|
481
|
+
} catch {
|
|
482
|
+
// UI state can be unavailable while the session boots; orientation is best-effort.
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
watcher = watch(dir, (_eventType, filename) => {
|
|
486
|
+
const name = filename?.toString();
|
|
487
|
+
if (name && targets.includes(name)) schedule(ctx);
|
|
488
|
+
});
|
|
489
|
+
// New top-level, non-recursive watch of the setup repository: any
|
|
490
|
+
// top-level change (manual manifest edits, git pull file writes from
|
|
491
|
+
// another terminal, fetch ref updates) schedules a debounced refresh.
|
|
492
|
+
if (existsSync(repoDir())) {
|
|
493
|
+
repoWatcher = watch(repoDir(), () => scheduleRefresh(ctx));
|
|
494
|
+
repoWatcher.on("error", () => {
|
|
495
|
+
// The repository may be moved or deleted out from under us; the
|
|
496
|
+
// 60-second tick keeps the footer current from the agent side.
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
// Reconcile changes made while no watched Pi session was running.
|
|
500
|
+
timer = setTimeout(() => {
|
|
501
|
+
timer = undefined;
|
|
502
|
+
void runSync(ctx, false);
|
|
503
|
+
}, INITIAL_SYNC_DELAY_MS);
|
|
504
|
+
timer.unref?.();
|
|
505
|
+
// 60-second metadata-only tick: the same refresh path, which in steady
|
|
506
|
+
// state costs the fingerprint's stat calls only (in-memory skip) and
|
|
507
|
+
// never touches the network. unref'ed so it never keeps the process
|
|
508
|
+
// alive.
|
|
509
|
+
tick = setInterval(() => {
|
|
510
|
+
void refresh(ctx);
|
|
511
|
+
}, TICK_MS);
|
|
512
|
+
tick.unref?.();
|
|
513
|
+
// Persistent footer: show the checking state immediately and run the
|
|
514
|
+
// first refresh asynchronously; nothing on the session-start path
|
|
515
|
+
// awaits the CLI. lastFingerprint is reset so the first refresh of this
|
|
516
|
+
// session always at least consults the shared cache.
|
|
517
|
+
phase = "checking";
|
|
518
|
+
lastSummary = undefined;
|
|
519
|
+
lastFingerprint = undefined;
|
|
520
|
+
setFooter(ctx);
|
|
521
|
+
void refresh(ctx);
|
|
522
|
+
// Session-start remote awareness: fetch-only, freshness-gated,
|
|
523
|
+
// single-flight, bounded; never awaited, and its completion triggers
|
|
524
|
+
// the normal refresh path.
|
|
525
|
+
void checkRemote(ctx);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
529
|
+
if (timer) {
|
|
530
|
+
clearTimeout(timer);
|
|
531
|
+
timer = undefined;
|
|
532
|
+
}
|
|
533
|
+
if (refreshTimer) {
|
|
534
|
+
clearTimeout(refreshTimer);
|
|
535
|
+
refreshTimer = undefined;
|
|
536
|
+
}
|
|
537
|
+
if (tick) {
|
|
538
|
+
clearInterval(tick);
|
|
539
|
+
tick = undefined;
|
|
540
|
+
}
|
|
541
|
+
watcher?.close();
|
|
542
|
+
watcher = undefined;
|
|
543
|
+
repoWatcher?.close();
|
|
544
|
+
repoWatcher = undefined;
|
|
545
|
+
pending = false;
|
|
546
|
+
// Explicitly clear the footer entry (in addition to the existing
|
|
547
|
+
// teardown); the next session start re-checks and re-renders.
|
|
548
|
+
if (ctx.hasUI) {
|
|
549
|
+
try {
|
|
550
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
551
|
+
} catch {
|
|
552
|
+
// The UI may already be torn down; there is nothing left to clear.
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
footerShown = false;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
pi.registerCommand("pi-sync", {
|
|
559
|
+
description: "Check for local Pi setting changes and sync them into the pi-sync setup repo",
|
|
560
|
+
handler: async (_args, ctx) => {
|
|
561
|
+
await runSync(ctx, true);
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|