@singh-gur/pi-session-sync 0.1.0 → 0.2.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 CHANGED
@@ -20,7 +20,7 @@ Your [pi](https://pi.dev) sessions, on every machine. Push a conversation from o
20
20
  - **Work on both machines, lose nothing.** Sessions are merged entry by entry. Continue the same conversation in two places and both branches show up in `/tree`, with the newest one active.
21
21
  - **Your files stay the source of truth.** Pull never deletes local sessions, and the active session only reloads when the agent is idle.
22
22
  - **Just git.** Any remote `git` can reach: GitHub, GitLab, Gitea, a bare repo over SSH. Authentication uses your existing git setup.
23
- - **Hands-free if you want it.** Optional auto mode pulls when pi starts and pushes when you quit. Off by default; startup pulls run in the background, and network failures only show a warning.
23
+ - **Hands-free if you want it.** Optional auto mode pulls the current folder's sessions when pi starts and pushes when you quit, configurable per root or folder. Off by default; startup pulls run in the background, and network failures only show a warning.
24
24
 
25
25
  ## Get started
26
26
 
@@ -78,8 +78,26 @@ Create `~/.pi/agent/git-session-sync.json` on each machine. It is machine-local
78
78
  | `repoUrl` | yes | Any URL `git` can clone. Use an empty private repo. |
79
79
  | `branch` | no | Branch to sync. Defaults to `main`. |
80
80
  | `roots` | yes | Named root directories. Names use `a-z`, `0-9`, `_` and `-`; paths must be absolute. Only sessions under a root are synced. |
81
- | `auto.pullOnStart` | no | Pull new or changed sessions in the background when pi starts. Defaults to `false`. |
81
+ | `auto.pullOnStart` | no | When pi starts, pull new or changed sessions for the folder pi was started in, in the background. Defaults to `false`. |
82
82
  | `auto.pushOnShutdown` | no | Push the current session when you quit pi. Defaults to `false`. |
83
+ | `auto.overrides` | no | Per-root or per-folder auto settings; see below. |
84
+
85
+ ### Per-root and per-folder auto sync
86
+
87
+ `auto.overrides` keys are a root name (`dev`) or a root name plus a path under it (`dev/api`). Each entry sets `pullOnStart` and/or `pushOnShutdown`; unset fields fall back to the closest matching parent key, then to the global `auto` value. The longest matching key wins. Keys must start with a configured root name.
88
+
89
+ ```json
90
+ "auto": {
91
+ "pullOnStart": false,
92
+ "pushOnShutdown": true,
93
+ "overrides": {
94
+ "dev": { "pullOnStart": true },
95
+ "dev/scratch": { "pushOnShutdown": false }
96
+ }
97
+ }
98
+ ```
99
+
100
+ Here `/home/me/dev/api` pulls on start and pushes on quit, `/home/me/dev/scratch` pulls but never auto-pushes, and folders under `home` only push. Folders outside every root never auto-sync.
83
101
 
84
102
  ### Same project, different paths
85
103
 
@@ -100,7 +118,7 @@ Every root path in a session (cwd, messages, tool calls and results) is replaced
100
118
  | `/session-sync-push` | Pick sessions to push. Only the current session is pre-selected. |
101
119
  | `/session-sync-pull` | Fetch the repo and pick from sessions that are **[new]** or **[changed]** compared to your local copies. If the current session is updated, pi reloads it. |
102
120
 
103
- In the picklist: `Space`/`Enter` toggles a session, `Esc` finishes, then confirm with **Yes** or back out with **No**. Each run ends with a summary: synced, unchanged, new entries, conflicts and anything skipped (with the reason).
121
+ In the picklist: `↑`/`↓` move, `Space` checks or unchecks a session, `a` selects all/none, `Enter` syncs the checked sessions, `Esc` cancels. Each run ends with a summary: synced, unchanged, new entries, conflicts and anything skipped (with the reason).
104
122
 
105
123
  ## How it works
106
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@singh-gur/pi-session-sync",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension: sync your pi sessions across machines through a private git repo, with per-machine path mapping and branch-preserving merges.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { rootFor } from "./layout.ts";
2
3
 
4
+ export type AutoFlags = { pullOnStart: boolean; pushOnShutdown: boolean };
3
5
  export type SyncConfig = {
4
6
  repoUrl: string;
5
7
  branch: string;
6
8
  roots: Record<string, string>;
7
- auto: { pullOnStart: boolean; pushOnShutdown: boolean };
9
+ /** overrides: "<root>" or "<root>/<rel/path>" -> partial flags; longest matching key wins. */
10
+ auto: AutoFlags & { overrides: Record<string, Partial<AutoFlags>> };
8
11
  };
9
12
 
13
+ const FLAGS = ["pullOnStart", "pushOnShutdown"] as const;
14
+
10
15
  const ROOT_NAME = /^[a-z0-9_-]+$/;
11
16
 
12
17
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -30,14 +35,43 @@ export function parseConfig(raw: unknown): SyncConfig {
30
35
  }
31
36
 
32
37
  const auto = isObject(raw.auto) ? raw.auto : {};
38
+ if (auto.overrides !== undefined && !isObject(auto.overrides))
39
+ throw new Error("config.auto.overrides must be an object of \"root[/path]\" -> { pullOnStart?, pushOnShutdown? }");
40
+ const overrides: Record<string, Partial<AutoFlags>> = {};
41
+ for (const [rawKey, val] of Object.entries(auto.overrides ?? {})) {
42
+ const key = rawKey.replace(/^\/+|\/+$/g, "");
43
+ const segs = key.split("/");
44
+ if (!(segs[0]! in roots)) throw new Error(`config.auto.overrides: "${rawKey}" must start with a configured root name`);
45
+ if (segs.some((s) => !s || s === "." || s === ".."))
46
+ throw new Error(`config.auto.overrides: "${rawKey}" has an empty, "." or ".." segment`);
47
+ if (!isObject(val)) throw new Error(`config.auto.overrides.${rawKey} must be an object`);
48
+ const o: Partial<AutoFlags> = {};
49
+ for (const [k, v] of Object.entries(val)) {
50
+ if (!(FLAGS as readonly string[]).includes(k) || typeof v !== "boolean")
51
+ throw new Error(`config.auto.overrides.${rawKey}.${k}: expected pullOnStart or pushOnShutdown with a boolean`);
52
+ o[k as keyof AutoFlags] = v;
53
+ }
54
+ overrides[key] = o;
55
+ }
33
56
  return {
34
57
  repoUrl: raw.repoUrl,
35
58
  branch: (raw.branch as string | undefined) ?? "main",
36
59
  roots,
37
- auto: { pullOnStart: auto.pullOnStart === true, pushOnShutdown: auto.pushOnShutdown === true },
60
+ auto: { pullOnStart: auto.pullOnStart === true, pushOnShutdown: auto.pushOnShutdown === true, overrides },
38
61
  };
39
62
  }
40
63
 
64
+ /** Effective auto flags for a local cwd: global defaults, then matching overrides from shortest to longest key. Off outside roots. */
65
+ export function autoFor(cfg: SyncConfig, cwd: string): AutoFlags {
66
+ const r = rootFor(cwd, cfg.roots);
67
+ if (!r) return { pullOnStart: false, pushOnShutdown: false };
68
+ const path = r.rel ? `${r.name}/${r.rel}` : r.name;
69
+ const flags: AutoFlags = { pullOnStart: cfg.auto.pullOnStart, pushOnShutdown: cfg.auto.pushOnShutdown };
70
+ for (const key of Object.keys(cfg.auto.overrides).sort((a, b) => a.length - b.length))
71
+ if (path === key || path.startsWith(`${key}/`)) Object.assign(flags, cfg.auto.overrides[key]);
72
+ return flags;
73
+ }
74
+
41
75
  export function loadConfig(file: string): SyncConfig | undefined {
42
76
  if (!existsSync(file)) return undefined;
43
77
  try {
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { hostname } from "node:os";
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { CURRENT_SESSION_VERSION, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
6
- import { loadConfig, type SyncConfig } from "./config.ts";
6
+ import { autoFor, loadConfig, type SyncConfig } from "./config.ts";
7
7
  import { commitAndPush, ensureClone, type Exec, syncToRemote } from "./git.ts";
8
8
  import { localSessionDirFor, repoPathFor } from "./layout.ts";
9
9
  import { mergeSessions } from "./merge.ts";
@@ -123,12 +123,13 @@ function localize(cfg: SyncConfig, repoFile: string): { target: string; text: st
123
123
  return { target, text };
124
124
  }
125
125
 
126
- async function scanPull(exec: Exec, cfg: SyncConfig, s: Summary): Promise<PullCandidate[]> {
126
+ /** `only`: repo-relative dir (e.g. "sessions/dev/api") to restrict the scan to; defaults to all sessions. */
127
+ async function scanPull(exec: Exec, cfg: SyncConfig, s: Summary, only = "sessions"): Promise<PullCandidate[]> {
127
128
  const clone = cloneDir();
128
129
  await ensureClone(exec, cfg, clone);
129
130
  await syncToRemote(exec, cfg, clone);
130
131
  const out: PullCandidate[] = [];
131
- for (const repoFile of walkJsonl(join(clone, "sessions"))) {
132
+ for (const repoFile of walkJsonl(join(clone, only))) {
132
133
  const l = localize(cfg, repoFile);
133
134
  if ("skip" in l) {
134
135
  s.skipped.push(l.skip);
@@ -179,13 +180,15 @@ export default function (pi: ExtensionAPI) {
179
180
  const picked = await pickSessions(
180
181
  ctx,
181
182
  "Push sessions",
183
+ "push",
182
184
  infos.map((i) => ({
183
185
  id: i.path,
184
186
  label: `${(i.name || i.firstMessage || "(empty)").replace(/\s+/g, " ").slice(0, 60)} · ${i.cwd} · ${i.modified.toLocaleString()}`,
185
187
  checked: i.path === current,
186
188
  })),
187
189
  );
188
- if (!picked?.length) return ctx.ui.notify("session-sync: nothing selected", "info");
190
+ if (!picked) return ctx.ui.notify("session-sync: cancelled", "info");
191
+ if (!picked.length) return ctx.ui.notify("session-sync: nothing selected", "info");
189
192
  ctx.ui.setStatus("session-sync", "pushing sessions...");
190
193
  try {
191
194
  report(ctx, "pushed", await pushSessions(exec, cfg, picked));
@@ -213,9 +216,11 @@ export default function (pi: ExtensionAPI) {
213
216
  const picked = await pickSessions(
214
217
  ctx,
215
218
  "Pull sessions",
219
+ "pull",
216
220
  candidates.map((c) => ({ id: c.target, label: c.label, checked: c.target === current })),
217
221
  );
218
- if (!picked?.length) return ctx.ui.notify("session-sync: nothing selected", "info");
222
+ if (!picked) return ctx.ui.notify("session-sync: cancelled", "info");
223
+ if (!picked.length) return ctx.ui.notify("session-sync: nothing selected", "info");
219
224
  for (const c of candidates.filter((c) => picked.includes(c.target))) {
220
225
  if (c.target === current && !ctx.isIdle()) {
221
226
  s.skipped.push(`${basename(c.target)}: agent busy (current session)`);
@@ -239,12 +244,15 @@ export default function (pi: ExtensionAPI) {
239
244
  pi.on("session_start", async (event, ctx) => {
240
245
  if (event.reason !== "startup") return;
241
246
  const cfg = getConfig(ctx, true);
242
- if (!cfg?.auto.pullOnStart) return;
247
+ const cwd = resolve(ctx.cwd);
248
+ // Only this folder's sessions; repoPathFor is undefined outside roots, so no network call there.
249
+ const rel = cfg && autoFor(cfg, cwd).pullOnStart ? repoPathFor(cwd, "x", cfg.roots) : undefined;
250
+ if (!cfg || !rel) return;
243
251
  const current = ctx.sessionManager.getSessionFile();
244
252
  // Fire and forget: never block startup on the network.
245
253
  void (async () => {
246
254
  const s = newSummary();
247
- for (const c of await scanPull(exec, cfg, s)) if (c.target !== current) applyPull(cfg, c, s);
255
+ for (const c of await scanPull(exec, cfg, s, dirname(rel))) if (c.target !== current) applyPull(cfg, c, s);
248
256
  if (s.synced || s.skipped.length) report(ctx, "auto-pulled", s);
249
257
  })().catch((e) => ctx.ui.notify(`session-sync auto-pull failed: ${errMsg(e)}`, "warning"));
250
258
  });
@@ -253,9 +261,9 @@ export default function (pi: ExtensionAPI) {
253
261
  if (event.reason !== "quit") return;
254
262
  const cfg = getConfig(ctx, true);
255
263
  const current = ctx.sessionManager.getSessionFile();
256
- if (!cfg?.auto.pushOnShutdown || !current || !existsSync(current)) return;
264
+ if (!cfg || !current || !existsSync(current)) return;
257
265
  const h = header(readFileSync(current, "utf8"));
258
- if (typeof h?.cwd !== "string" || !repoPathFor(h.cwd, basename(current), cfg.roots)) return;
266
+ if (typeof h?.cwd !== "string" || !autoFor(cfg, h.cwd).pushOnShutdown) return;
259
267
  try {
260
268
  await pushSessions(exec, cfg, [current]);
261
269
  } catch (e) {
package/src/picklist.ts CHANGED
@@ -1,40 +1,83 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
- import { Container, SettingsList } from "@earendil-works/pi-tui";
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, getKeybindings, getSelectPageIndex, truncateToWidth } from "@earendil-works/pi-tui";
4
3
 
5
4
  export type PickItem = { id: string; label: string; checked: boolean };
5
+ type Style = Pick<Theme, "fg" | "bold">;
6
6
 
7
- /** Multi-select list (pattern from examples/extensions/tools.ts). Returns selected ids, or undefined if cancelled. */
8
- export async function pickSessions(ctx: ExtensionContext, title: string, items: PickItem[]): Promise<string[] | undefined> {
9
- if (ctx.mode !== "tui") return items.filter((i) => i.checked).map((i) => i.id);
7
+ /** Checkbox list: ↑/↓ move, space toggles, `a` toggles all, enter confirms, esc cancels. */
8
+ export class MultiSelect implements Component {
9
+ readonly capturesViewportInput = true;
10
+ cursor = 0;
11
+ readonly selected: Set<string>;
12
+
13
+ private title: string;
14
+ private verb: string;
15
+ private items: PickItem[];
16
+ private style: Style;
17
+ private done: (ids: string[] | undefined) => void;
18
+ private maxVisible = 12;
19
+
20
+ constructor(title: string, verb: string, items: PickItem[], style: Style, done: (ids: string[] | undefined) => void) {
21
+ this.title = title;
22
+ this.verb = verb;
23
+ this.items = items;
24
+ this.style = style;
25
+ this.done = done;
26
+ this.selected = new Set(items.filter((i) => i.checked).map((i) => i.id));
27
+ }
28
+
29
+ invalidate() {}
30
+
31
+ handleInput(data: string) {
32
+ const kb = getKeybindings();
33
+ const n = this.items.length;
34
+ const page = getSelectPageIndex(kb, data, this.cursor, n, this.maxVisible);
35
+ if (kb.matches(data, "tui.select.up")) this.cursor = (this.cursor - 1 + n) % n;
36
+ else if (kb.matches(data, "tui.select.down")) this.cursor = (this.cursor + 1) % n;
37
+ else if (page !== undefined) this.cursor = page;
38
+ else if (data === " ") {
39
+ const id = this.items[this.cursor]!.id;
40
+ if (!this.selected.delete(id)) this.selected.add(id);
41
+ } else if (data === "a" || data === "A") {
42
+ if (this.selected.size === n) this.selected.clear();
43
+ else for (const i of this.items) this.selected.add(i.id);
44
+ } else if (kb.matches(data, "tui.select.confirm")) this.done(this.items.filter((i) => this.selected.has(i.id)).map((i) => i.id));
45
+ else if (kb.matches(data, "tui.select.cancel")) this.done(undefined);
46
+ }
10
47
 
11
- const selected = new Set(items.filter((i) => i.checked).map((i) => i.id));
12
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
13
- const container = new Container();
14
- container.addChild({
15
- render: () => [theme.fg("accent", theme.bold(title)), theme.fg("dim", "space/enter toggle · esc done"), ""],
16
- invalidate() {},
17
- });
18
- const list = new SettingsList(
19
- items.map((i) => ({ id: i.id, label: i.label, currentValue: i.checked ? "on" : "off", values: ["on", "off"] })),
20
- Math.min(items.length + 2, 15),
21
- getSettingsListTheme(),
22
- (id, value) => (value === "on" ? selected.add(id) : selected.delete(id)),
23
- () => done(undefined),
24
- );
25
- container.addChild(list);
48
+ render(width: number): string[] {
49
+ const fg: Style["fg"] = (c, t) => this.style.fg(c, t);
50
+ const bold = (t: string) => this.style.bold(t);
51
+ const n = this.items.length;
52
+ const start = Math.max(0, Math.min(this.cursor - Math.floor(this.maxVisible / 2), n - this.maxVisible));
53
+ const end = Math.min(n, start + this.maxVisible);
54
+ const lines = [fg("accent", bold(this.title)) + fg("muted", ` ${this.selected.size}/${n} selected`), ""];
55
+ for (let i = start; i < end; i++) {
56
+ const item = this.items[i]!;
57
+ const on = this.selected.has(item.id);
58
+ const box = on ? fg("success", "[x]") : fg("dim", "[ ]");
59
+ const row = i === this.cursor ? `${fg("accent", "›")} ${box} ${fg("accent", item.label)}` : ` ${box} ${item.label}`;
60
+ lines.push(truncateToWidth(row, width));
61
+ }
62
+ if (start > 0 || end < n) lines.push(fg("dim", ` (${this.cursor + 1}/${n})`));
63
+ lines.push("", fg("dim", truncateToWidth(`↑↓ move · space select · a all/none · enter ${this.verb} · esc cancel`, width)));
64
+ return lines;
65
+ }
66
+ }
67
+
68
+ /** Returns selected ids (possibly empty), or undefined if cancelled. */
69
+ export async function pickSessions(ctx: ExtensionContext, title: string, verb: string, items: PickItem[]): Promise<string[] | undefined> {
70
+ if (ctx.mode !== "tui") return items.filter((i) => i.checked).map((i) => i.id);
71
+ return ctx.ui.custom<string[] | undefined>((tui, theme, _kb, done) => {
72
+ const list = new MultiSelect(title, verb, items, theme, done);
26
73
  return {
27
- capturesViewportInput: list.capturesViewportInput,
28
- render: (w: number) => container.render(w),
29
- invalidate: () => container.invalidate(),
74
+ capturesViewportInput: true,
75
+ render: (w: number) => list.render(w),
76
+ invalidate: () => list.invalidate(),
30
77
  handleInput(data: string) {
31
- list.handleInput?.(data);
78
+ list.handleInput(data);
32
79
  tui.requestRender();
33
80
  },
34
81
  };
35
82
  });
36
-
37
- if (selected.size === 0) return undefined;
38
- const ok = await ctx.ui.confirm(title, `Sync ${selected.size} session(s)?`);
39
- return ok ? items.filter((i) => selected.has(i.id)).map((i) => i.id) : undefined;
40
83
  }