@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.
@@ -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
+ }