@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 +21 -3
- package/package.json +1 -1
- package/src/config.ts +36 -2
- package/src/index.ts +17 -9
- package/src/picklist.ts +72 -29
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 |
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
264
|
+
if (!cfg || !current || !existsSync(current)) return;
|
|
257
265
|
const h = header(readFileSync(current, "utf8"));
|
|
258
|
-
if (typeof h?.cwd !== "string" || !
|
|
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 {
|
|
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
|
-
/**
|
|
8
|
-
export
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
(
|
|
23
|
-
(
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
28
|
-
render: (w: number) =>
|
|
29
|
-
invalidate: () =>
|
|
74
|
+
capturesViewportInput: true,
|
|
75
|
+
render: (w: number) => list.render(w),
|
|
76
|
+
invalidate: () => list.invalidate(),
|
|
30
77
|
handleInput(data: string) {
|
|
31
|
-
list.handleInput
|
|
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
|
}
|