@singh-gur/pi-session-sync 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gurakhshish Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # pi-session-sync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Start on your laptop. Pick it up on your desktop. Keep coding.**
4
+
5
+ Your [pi](https://pi.dev) sessions, on every machine. Push a conversation from one machine, pull it on another, and carry on exactly where you left off: same history, same `/tree`, with paths rewritten for the machine you're on. Everything travels through a private git repo you already own. No server, no account, no new service.
6
+
7
+ [Install](#get-started) · [Configure](#configure) · [How it works](#how-it-works) · [npm](https://www.npmjs.com/package/@singh-gur/pi-session-sync)
8
+
9
+ ![The /session-sync-push picklist listing four sessions under /home/me/dev with the current one selected, followed by "session-sync: pushed 1, unchanged 0".](https://raw.githubusercontent.com/singh-gur/pi-session-sync/main/assets/hero.png)
10
+
11
+ *Illustration of the extension's picklist with synthetic sessions. Colors and spacing depend on your terminal and pi theme.*
12
+
13
+ > [!WARNING]
14
+ > **Your sync repo must be private.** Sessions contain your prompts, code, tool output (file contents, command output) and possibly secrets such as API keys or tokens. They are not encrypted or redacted.
15
+
16
+ ## Why you'll like it
17
+
18
+ - **Pick exactly what travels.** `/session-sync-push` and `/session-sync-pull` open a picklist with the current session pre-selected. Nothing moves without you choosing it.
19
+ - **Paths that follow you.** `/Users/me/code/api` on your Mac becomes `/home/me/dev/api` on your Linux box. Named roots turn machine paths into portable placeholders, so `/resume` finds the session in the right project.
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
+ - **Your files stay the source of truth.** Pull never deletes local sessions, and the active session only reloads when the agent is idle.
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 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
+
25
+ ## Get started
26
+
27
+ ```sh
28
+ pi install npm:@singh-gur/pi-session-sync
29
+ ```
30
+
31
+ 1. Create an **empty, private** git repo for your sessions.
32
+ 2. Add the [config file](#configure) on each machine.
33
+ 3. Start pi (or run `/reload`), then:
34
+
35
+ ```text
36
+ /session-sync-push # on the machine you worked on
37
+ /session-sync-pull # on the machine you're moving to
38
+ ```
39
+
40
+ Requires **Node.js >= 24.16**, **pi 1.x** (tested with 1.5.2), and `git` on your `PATH` with access to your sync repo. Install the package on every machine you want to sync.
41
+
42
+ <details>
43
+ <summary>Try without installing, install from GitHub, pin, or update</summary>
44
+
45
+ ```sh
46
+ # Try it for the current run only
47
+ pi -e npm:@singh-gur/pi-session-sync
48
+
49
+ # Install from GitHub
50
+ pi install git:github.com/singh-gur/pi-session-sync
51
+
52
+ # Install from a local checkout
53
+ pi install /path/to/pi-session-sync
54
+
55
+ # Update installed pi packages
56
+ pi update --extensions
57
+ ```
58
+
59
+ To pin a release, append `@<version>`, for example `npm:@singh-gur/pi-session-sync@0.1.0`. Pinned versions are skipped by package updates.
60
+
61
+ </details>
62
+
63
+ ## Configure
64
+
65
+ Create `~/.pi/agent/git-session-sync.json` on each machine. It is machine-local and never synced.
66
+
67
+ ```json
68
+ {
69
+ "repoUrl": "git@github.com:me/pi-sessions.git",
70
+ "branch": "main",
71
+ "roots": { "dev": "/home/me/dev", "home": "/home/me" },
72
+ "auto": { "pullOnStart": false, "pushOnShutdown": false }
73
+ }
74
+ ```
75
+
76
+ | Key | Required | What it does |
77
+ | --- | --- | --- |
78
+ | `repoUrl` | yes | Any URL `git` can clone. Use an empty private repo. |
79
+ | `branch` | no | Branch to sync. Defaults to `main`. |
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 | When pi starts, pull new or changed sessions for the folder pi was started in, in the background. Defaults to `false`. |
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.
101
+
102
+ ### Same project, different paths
103
+
104
+ Give a root the **same name** on every machine and point it at that machine's folder:
105
+
106
+ | Machine | Config | Session cwd |
107
+ | --- | --- | --- |
108
+ | MacBook | `"dev": "/Users/me/code"` | `/Users/me/code/api` |
109
+ | In the repo | | `{{PISYNC:dev}}/api` |
110
+ | Linux desktop | `"dev": "/home/me/dev"` | `/home/me/dev/api` |
111
+
112
+ Every root path in a session (cwd, messages, tool calls and results) is replaced at path boundaries, with the longest matching root winning. Sessions that use a root name this machine doesn't define are skipped on pull, with a warning.
113
+
114
+ ## Commands
115
+
116
+ | Command | What it does |
117
+ | --- | --- |
118
+ | `/session-sync-push` | Pick sessions to push. Only the current session is pre-selected. |
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. |
120
+
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).
122
+
123
+ ## How it works
124
+
125
+ - **Merging.** Sessions are merged by entry id and ordered by timestamp, so the newest entry becomes the active leaf. If the same entry id has different content on each side, your local version is kept and the conflict is reported.
126
+ - **Local first.** Your session files are the source of truth. The clone at `~/.pi/agent/git-session-sync/repo` is a disposable cache, reset to the remote before every operation. Concurrent pushes from other machines are merged and retried automatically.
127
+ - **Safe writes.** Session files are written atomically, never outside pi's sessions directory, and never deleted.
128
+ - **Repo layout.** `sessions/<root>/<project>/<session-file>.jsonl`, readable and diffable like any other repo.
129
+
130
+ ## Limitations
131
+
132
+ - POSIX paths only (no Windows).
133
+ - Only pi's default session directory is supported (no custom `sessionDir`).
134
+ - Sessions with a different session format version are skipped.
135
+ - The repo grows without bound (sessions can include base64 images).
136
+ - Path rewriting also changes coincidental matches of a root path inside message text.
137
+ - No encryption or redaction; see the warning above.
138
+
139
+ ## Development
140
+
141
+ ```sh
142
+ just install # pnpm install --frozen-lockfile
143
+ just check # typecheck + tests + pack dry run
144
+ just try # run pi with the extension loaded
145
+ ```
146
+
147
+ Release:
148
+
149
+ ```sh
150
+ just check
151
+ just bump patch # or minor / major: bumps version, tags, pushes
152
+ just publish # pnpm publish to npm
153
+ ```
154
+
155
+ Found a problem? [Open an issue](https://github.com/singh-gur/pi-session-sync/issues). Never paste session contents that may include secrets.
156
+
157
+ [MIT license](https://github.com/singh-gur/pi-session-sync/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@singh-gur/pi-session-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
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
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/singh-gur/pi-session-sync.git"
8
+ },
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi",
12
+ "pi-extension",
13
+ "session",
14
+ "sync",
15
+ "git",
16
+ "multi-machine",
17
+ "backup",
18
+ "sessions"
19
+ ],
20
+ "author": "singh-gur",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "bugs": {
24
+ "url": "https://github.com/singh-gur/pi-session-sync/issues"
25
+ },
26
+ "homepage": "https://github.com/singh-gur/pi-session-sync#readme",
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ],
31
+ "image": "https://raw.githubusercontent.com/singh-gur/pi-session-sync/main/assets/hero.png"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*"
36
+ },
37
+ "devDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*",
40
+ "@types/node": "^26.6.6",
41
+ "typescript": "^7.0.2"
42
+ },
43
+ "files": [
44
+ "src"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "engines": {
50
+ "node": ">=24.16.0"
51
+ },
52
+ "scripts": {
53
+ "test": "node --test 'test/**/*.test.ts'",
54
+ "typecheck": "tsc --noEmit",
55
+ "pack:check": "pnpm pack --dry-run"
56
+ }
6
57
  }
package/src/config.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { rootFor } from "./layout.ts";
3
+
4
+ export type AutoFlags = { pullOnStart: boolean; pushOnShutdown: boolean };
5
+ export type SyncConfig = {
6
+ repoUrl: string;
7
+ branch: string;
8
+ roots: Record<string, string>;
9
+ /** overrides: "<root>" or "<root>/<rel/path>" -> partial flags; longest matching key wins. */
10
+ auto: AutoFlags & { overrides: Record<string, Partial<AutoFlags>> };
11
+ };
12
+
13
+ const FLAGS = ["pullOnStart", "pushOnShutdown"] as const;
14
+
15
+ const ROOT_NAME = /^[a-z0-9_-]+$/;
16
+
17
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
18
+
19
+ export function parseConfig(raw: unknown): SyncConfig {
20
+ if (!isObject(raw)) throw new Error("config must be a JSON object");
21
+ if (typeof raw.repoUrl !== "string" || !raw.repoUrl.trim()) throw new Error("config.repoUrl is required");
22
+ if (raw.branch !== undefined && (typeof raw.branch !== "string" || !raw.branch.trim()))
23
+ throw new Error("config.branch must be a non-empty string");
24
+ if (!isObject(raw.roots) || Object.keys(raw.roots).length === 0)
25
+ throw new Error("config.roots must be a non-empty object of name -> absolute path");
26
+
27
+ const roots: Record<string, string> = {};
28
+ for (const [name, path] of Object.entries(raw.roots)) {
29
+ if (!ROOT_NAME.test(name)) throw new Error(`config.roots: invalid root name "${name}" (use a-z, 0-9, _ and -)`);
30
+ if (typeof path !== "string" || !path.startsWith("/"))
31
+ throw new Error(`config.roots.${name} must be an absolute path`);
32
+ const stripped = path.replace(/\/+$/, "");
33
+ if (!stripped) throw new Error(`config.roots.${name}: "/" is not allowed as a root`);
34
+ roots[name] = stripped;
35
+ }
36
+
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
+ }
56
+ return {
57
+ repoUrl: raw.repoUrl,
58
+ branch: (raw.branch as string | undefined) ?? "main",
59
+ roots,
60
+ auto: { pullOnStart: auto.pullOnStart === true, pushOnShutdown: auto.pushOnShutdown === true, overrides },
61
+ };
62
+ }
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
+
75
+ export function loadConfig(file: string): SyncConfig | undefined {
76
+ if (!existsSync(file)) return undefined;
77
+ try {
78
+ return parseConfig(JSON.parse(readFileSync(file, "utf8")));
79
+ } catch (e) {
80
+ throw new Error(`${file}: ${(e as Error).message}`);
81
+ }
82
+ }
package/src/git.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { SyncConfig } from "./config.ts";
4
+
5
+ export type Exec = (
6
+ cmd: string,
7
+ args: string[],
8
+ opts: { cwd?: string; timeout?: number },
9
+ ) => Promise<{ stdout: string; stderr: string; code: number }>;
10
+
11
+ const TIMEOUT = 60_000;
12
+
13
+ async function git(exec: Exec, cwd: string, args: string[], allowFail = false) {
14
+ const r = await exec("git", args, { cwd, timeout: TIMEOUT });
15
+ if (r.code !== 0 && !allowFail) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.trim() || r.stdout.trim()}`);
16
+ return r;
17
+ }
18
+
19
+ export async function ensureClone(exec: Exec, cfg: SyncConfig, cloneDir: string) {
20
+ if (existsSync(join(cloneDir, ".git"))) return;
21
+ const parent = dirname(cloneDir);
22
+ mkdirSync(parent, { recursive: true });
23
+ const r = await git(exec, parent, ["clone", "--branch", cfg.branch, cfg.repoUrl, cloneDir], true);
24
+ if (r.code === 0) return;
25
+ // Empty remote (or branch not created yet): plain clone, then create the branch locally.
26
+ await git(exec, parent, ["clone", cfg.repoUrl, cloneDir]);
27
+ await git(exec, cloneDir, ["checkout", "-B", cfg.branch]);
28
+ }
29
+
30
+ export async function syncToRemote(exec: Exec, cfg: SyncConfig, cloneDir: string) {
31
+ await git(exec, cloneDir, ["fetch", "origin"]);
32
+ const has = await git(exec, cloneDir, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${cfg.branch}`], true);
33
+ if (has.code !== 0) return;
34
+ await git(exec, cloneDir, ["checkout", "-B", cfg.branch, `origin/${cfg.branch}`]);
35
+ await git(exec, cloneDir, ["reset", "--hard", `origin/${cfg.branch}`]);
36
+ await git(exec, cloneDir, ["clean", "-fd"]);
37
+ }
38
+
39
+ /** rebuild() writes files into the clone and returns how many changed. Retries on push rejection. */
40
+ export async function commitAndPush(
41
+ exec: Exec,
42
+ cfg: SyncConfig,
43
+ cloneDir: string,
44
+ message: string,
45
+ rebuild: () => Promise<number>,
46
+ ): Promise<{ pushed: number }> {
47
+ let lastErr = "";
48
+ for (let attempt = 0; attempt < 3; attempt++) {
49
+ await syncToRemote(exec, cfg, cloneDir);
50
+ const n = await rebuild();
51
+ if (n === 0) return { pushed: 0 };
52
+ await git(exec, cloneDir, ["add", "-A", "sessions"]);
53
+ await git(exec, cloneDir, ["commit", "-q", "-m", message]);
54
+ const p = await git(exec, cloneDir, ["push", "origin", `HEAD:${cfg.branch}`], true);
55
+ if (p.code === 0) return { pushed: n };
56
+ lastErr = p.stderr.trim();
57
+ }
58
+ throw new Error(`git push failed after 3 attempts: ${lastErr}`);
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { CURRENT_SESSION_VERSION, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
6
+ import { autoFor, loadConfig, type SyncConfig } from "./config.ts";
7
+ import { commitAndPush, ensureClone, type Exec, syncToRemote } from "./git.ts";
8
+ import { localSessionDirFor, repoPathFor } from "./layout.ts";
9
+ import { mergeSessions } from "./merge.ts";
10
+ import { pickSessions } from "./picklist.ts";
11
+ import { rewriteFileToLocal, rewriteFileToPortable } from "./rewrite.ts";
12
+
13
+ const EXAMPLE = `{
14
+ "repoUrl": "git@github.com:me/pi-sessions.git",
15
+ "branch": "main",
16
+ "roots": { "dev": "/home/me/dev" },
17
+ "auto": { "pullOnStart": false, "pushOnShutdown": false }
18
+ }`;
19
+
20
+ const configFile = () => join(getAgentDir(), "git-session-sync.json");
21
+ const cloneDir = () => join(getAgentDir(), "git-session-sync", "repo");
22
+ const sessionsRoot = () => join(getAgentDir(), "sessions");
23
+
24
+ type Summary = { synced: number; added: number; unchanged: number; conflicts: number; skipped: string[] };
25
+ const newSummary = (): Summary => ({ synced: 0, added: 0, unchanged: 0, conflicts: 0, skipped: [] });
26
+
27
+ function header(text: string): { version?: unknown; cwd?: unknown } | undefined {
28
+ try {
29
+ const h = JSON.parse(text.split("\n", 1)[0] ?? "");
30
+ return h?.type === "session" ? h : undefined;
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ const readIf = (f: string) => (existsSync(f) ? readFileSync(f, "utf8") : undefined);
37
+
38
+ function writeAtomic(file: string, text: string) {
39
+ mkdirSync(dirname(file), { recursive: true });
40
+ const tmp = `${file}.tmp-pss`;
41
+ writeFileSync(tmp, text);
42
+ renameSync(tmp, file);
43
+ }
44
+
45
+ /** Load config and check preconditions; notifies and returns undefined when sync cannot run. */
46
+ function getConfig(ctx: ExtensionContext, quietIfMissing = false): SyncConfig | undefined {
47
+ let cfg: SyncConfig | undefined;
48
+ try {
49
+ cfg = loadConfig(configFile());
50
+ } catch (e) {
51
+ ctx.ui.notify(`session-sync: ${(e as Error).message}`, "error");
52
+ return undefined;
53
+ }
54
+ if (!cfg) {
55
+ if (!quietIfMissing) ctx.ui.notify(`session-sync: create ${configFile()} like:\n${EXAMPLE}`, "warning");
56
+ return undefined;
57
+ }
58
+ if (resolve(ctx.sessionManager.getSessionDir()) !== resolve(localSessionDirFor(resolve(ctx.cwd), getAgentDir()))) {
59
+ ctx.ui.notify("session-sync: custom sessionDir is unsupported", "error");
60
+ return undefined;
61
+ }
62
+ return cfg;
63
+ }
64
+
65
+ /** Push local session files (merged into the repo copy). */
66
+ async function pushSessions(exec: Exec, cfg: SyncConfig, paths: string[]): Promise<Summary> {
67
+ const clone = cloneDir();
68
+ await ensureClone(exec, cfg, clone);
69
+ let s = newSummary();
70
+ await commitAndPush(exec, cfg, clone, `push ${paths.length} session(s) from ${hostname()}`, async () => {
71
+ s = newSummary();
72
+ for (const path of paths) {
73
+ const text = readIf(path);
74
+ const h = text && header(text);
75
+ if (!h) {
76
+ s.skipped.push(`${basename(path)}: unreadable`);
77
+ continue;
78
+ }
79
+ if (h.version !== CURRENT_SESSION_VERSION) {
80
+ s.skipped.push(`${basename(path)}: session version ${h.version}`);
81
+ continue;
82
+ }
83
+ const rel = typeof h.cwd === "string" ? repoPathFor(h.cwd, basename(path), cfg.roots) : undefined;
84
+ if (!rel) {
85
+ s.skipped.push(`${basename(path)}: cwd not under a configured root`);
86
+ continue;
87
+ }
88
+ const repoFile = join(clone, rel);
89
+ const repoText = readIf(repoFile);
90
+ const m = mergeSessions(rewriteFileToPortable(text, cfg.roots), repoText);
91
+ s.conflicts += m.conflicts;
92
+ if (m.text === repoText) s.unchanged++;
93
+ else {
94
+ writeAtomic(repoFile, m.text);
95
+ s.synced++;
96
+ }
97
+ }
98
+ return s.synced;
99
+ });
100
+ return s;
101
+ }
102
+
103
+ type PullCandidate = { repoFile: string; target: string; isNew: boolean; label: string };
104
+
105
+ function walkJsonl(dir: string): string[] {
106
+ if (!existsSync(dir)) return [];
107
+ return readdirSync(dir, { withFileTypes: true, recursive: true })
108
+ .filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
109
+ .map((d) => join(d.parentPath, d.name));
110
+ }
111
+
112
+ /** Localize a repo file; returns the target path and localized text, or a skip reason. */
113
+ function localize(cfg: SyncConfig, repoFile: string): { target: string; text: string } | { skip: string } {
114
+ const name = basename(repoFile);
115
+ const { text, unknown } = rewriteFileToLocal(readFileSync(repoFile, "utf8"), cfg.roots);
116
+ if (unknown.length) return { skip: `${name}: unknown root(s) ${unknown.join(", ")}` };
117
+ const h = header(text);
118
+ if (!h || typeof h.cwd !== "string" || !h.cwd.startsWith("/")) return { skip: `${name}: invalid header` };
119
+ if (h.version !== CURRENT_SESSION_VERSION) return { skip: `${name}: session version ${h.version}` };
120
+ const target = resolve(localSessionDirFor(h.cwd, getAgentDir()), name);
121
+ // Trust boundary: repo content must never write outside the sessions dir.
122
+ if (!target.startsWith(resolve(sessionsRoot()) + sep)) return { skip: `${name}: target outside sessions dir` };
123
+ return { target, text };
124
+ }
125
+
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[]> {
128
+ const clone = cloneDir();
129
+ await ensureClone(exec, cfg, clone);
130
+ await syncToRemote(exec, cfg, clone);
131
+ const out: PullCandidate[] = [];
132
+ for (const repoFile of walkJsonl(join(clone, only))) {
133
+ const l = localize(cfg, repoFile);
134
+ if ("skip" in l) {
135
+ s.skipped.push(l.skip);
136
+ continue;
137
+ }
138
+ const local = readIf(l.target);
139
+ if (local !== undefined && !mergeSessions(local, l.text).changed) continue;
140
+ const cwd = String(header(l.text)?.cwd);
141
+ out.push({ repoFile, target: l.target, isNew: local === undefined, label: `${local === undefined ? "[new]" : "[changed]"} ${cwd} · ${basename(repoFile)}` });
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function applyPull(cfg: SyncConfig, c: PullCandidate, s: Summary) {
147
+ const l = localize(cfg, c.repoFile);
148
+ if ("skip" in l) return void s.skipped.push(l.skip);
149
+ const m = mergeSessions(readIf(c.target), l.text); // re-read: local may have changed since the scan
150
+ s.conflicts += m.conflicts;
151
+ if (!m.changed) return void s.unchanged++;
152
+ writeAtomic(c.target, m.text);
153
+ s.synced++;
154
+ s.added += m.added;
155
+ }
156
+
157
+ function report(ctx: ExtensionContext, verb: string, s: Summary) {
158
+ const parts = [`${verb} ${s.synced}`, `unchanged ${s.unchanged}`];
159
+ if (s.added) parts.push(`new entries ${s.added}`);
160
+ if (s.conflicts) parts.push(`id conflicts ${s.conflicts} (kept local)`);
161
+ if (s.skipped.length) parts.push(`skipped ${s.skipped.length}:\n ${s.skipped.join("\n ")}`);
162
+ ctx.ui.notify(`session-sync: ${parts.join(", ")}`, s.skipped.length ? "warning" : "info");
163
+ }
164
+
165
+ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e));
166
+
167
+ export default function (pi: ExtensionAPI) {
168
+ const exec: Exec = (cmd, args, opts) => pi.exec(cmd, args, opts);
169
+
170
+ pi.registerCommand("session-sync-push", {
171
+ description: "Push selected pi sessions to the sync git repo",
172
+ handler: async (_args, ctx: ExtensionCommandContext) => {
173
+ const cfg = getConfig(ctx);
174
+ if (!cfg) return;
175
+ const current = ctx.sessionManager.getSessionFile();
176
+ const infos = (await SessionManager.listAll())
177
+ .filter((i) => repoPathFor(i.cwd, basename(i.path), cfg.roots))
178
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime());
179
+ if (!infos.length) return ctx.ui.notify("session-sync: no sessions under configured roots", "info");
180
+ const picked = await pickSessions(
181
+ ctx,
182
+ "Push sessions",
183
+ "push",
184
+ infos.map((i) => ({
185
+ id: i.path,
186
+ label: `${(i.name || i.firstMessage || "(empty)").replace(/\s+/g, " ").slice(0, 60)} · ${i.cwd} · ${i.modified.toLocaleString()}`,
187
+ checked: i.path === current,
188
+ })),
189
+ );
190
+ if (!picked) return ctx.ui.notify("session-sync: cancelled", "info");
191
+ if (!picked.length) return ctx.ui.notify("session-sync: nothing selected", "info");
192
+ ctx.ui.setStatus("session-sync", "pushing sessions...");
193
+ try {
194
+ report(ctx, "pushed", await pushSessions(exec, cfg, picked));
195
+ } catch (e) {
196
+ ctx.ui.notify(`session-sync push failed: ${errMsg(e)}`, "error");
197
+ } finally {
198
+ ctx.ui.setStatus("session-sync", undefined);
199
+ }
200
+ },
201
+ });
202
+
203
+ pi.registerCommand("session-sync-pull", {
204
+ description: "Pull selected pi sessions from the sync git repo",
205
+ handler: async (_args, ctx: ExtensionCommandContext) => {
206
+ const cfg = getConfig(ctx);
207
+ if (!cfg) return;
208
+ const current = ctx.sessionManager.getSessionFile();
209
+ const s = newSummary();
210
+ let currentUpdated = false;
211
+ ctx.ui.setStatus("session-sync", "pulling sessions...");
212
+ try {
213
+ const candidates = await scanPull(exec, cfg, s);
214
+ ctx.ui.setStatus("session-sync", undefined);
215
+ if (!candidates.length) return report(ctx, "pulled", s);
216
+ const picked = await pickSessions(
217
+ ctx,
218
+ "Pull sessions",
219
+ "pull",
220
+ candidates.map((c) => ({ id: c.target, label: c.label, checked: c.target === current })),
221
+ );
222
+ if (!picked) return ctx.ui.notify("session-sync: cancelled", "info");
223
+ if (!picked.length) return ctx.ui.notify("session-sync: nothing selected", "info");
224
+ for (const c of candidates.filter((c) => picked.includes(c.target))) {
225
+ if (c.target === current && !ctx.isIdle()) {
226
+ s.skipped.push(`${basename(c.target)}: agent busy (current session)`);
227
+ continue;
228
+ }
229
+ const before = s.synced;
230
+ applyPull(cfg, c, s);
231
+ if (c.target === current && s.synced > before) currentUpdated = true;
232
+ }
233
+ report(ctx, "pulled", s);
234
+ } catch (e) {
235
+ return ctx.ui.notify(`session-sync pull failed: ${errMsg(e)}`, "error");
236
+ } finally {
237
+ ctx.ui.setStatus("session-sync", undefined);
238
+ }
239
+ // Reload the active session from disk; ctx is stale afterwards, so this is the last use.
240
+ if (currentUpdated && current) await ctx.switchSession(current);
241
+ },
242
+ });
243
+
244
+ pi.on("session_start", async (event, ctx) => {
245
+ if (event.reason !== "startup") return;
246
+ const cfg = getConfig(ctx, true);
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;
251
+ const current = ctx.sessionManager.getSessionFile();
252
+ // Fire and forget: never block startup on the network.
253
+ void (async () => {
254
+ const s = newSummary();
255
+ for (const c of await scanPull(exec, cfg, s, dirname(rel))) if (c.target !== current) applyPull(cfg, c, s);
256
+ if (s.synced || s.skipped.length) report(ctx, "auto-pulled", s);
257
+ })().catch((e) => ctx.ui.notify(`session-sync auto-pull failed: ${errMsg(e)}`, "warning"));
258
+ });
259
+
260
+ pi.on("session_shutdown", async (event, ctx) => {
261
+ if (event.reason !== "quit") return;
262
+ const cfg = getConfig(ctx, true);
263
+ const current = ctx.sessionManager.getSessionFile();
264
+ if (!cfg || !current || !existsSync(current)) return;
265
+ const h = header(readFileSync(current, "utf8"));
266
+ if (typeof h?.cwd !== "string" || !autoFor(cfg, h.cwd).pushOnShutdown) return;
267
+ try {
268
+ await pushSessions(exec, cfg, [current]);
269
+ } catch (e) {
270
+ ctx.ui.notify(`session-sync auto-push failed: ${errMsg(e)}`, "warning");
271
+ }
272
+ });
273
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { join } from "node:path";
2
+ import type { Roots } from "./rewrite.ts";
3
+
4
+ export const encodeRel = (rel: string) => (rel ? rel.replace(/[/\\:]/g, "-") : "_root_");
5
+
6
+ /** Longest configured root containing cwd (equal or prefix + "/"). */
7
+ export function rootFor(cwd: string, roots: Roots): { name: string; rel: string } | undefined {
8
+ let best: { name: string; rel: string; len: number } | undefined;
9
+ for (const [name, path] of Object.entries(roots)) {
10
+ if ((cwd === path || cwd.startsWith(`${path}/`)) && (!best || path.length > best.len))
11
+ best = { name, rel: cwd.slice(path.length + 1), len: path.length };
12
+ }
13
+ return best && { name: best.name, rel: best.rel };
14
+ }
15
+
16
+ export function repoPathFor(localCwd: string, fileName: string, roots: Roots): string | undefined {
17
+ const r = rootFor(localCwd, roots);
18
+ return r && `sessions/${r.name}/${encodeRel(r.rel)}/${fileName}`;
19
+ }
20
+
21
+ /** Mirror of pi's getDefaultSessionDirPath (session-manager.js:291-294), which is not exported. */
22
+ export const localSessionDirFor = (cwd: string, agentDir: string) =>
23
+ join(agentDir, "sessions", `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
package/src/merge.ts ADDED
@@ -0,0 +1,65 @@
1
+ type Line = Record<string, unknown>;
2
+
3
+ function parse(text: string | undefined): { header: Line | undefined; entries: Line[] } {
4
+ const entries: Line[] = [];
5
+ let header: Line | undefined;
6
+ for (const raw of (text ?? "").split("\n")) {
7
+ if (!raw.trim()) continue;
8
+ const obj = JSON.parse(raw) as Line;
9
+ if (obj.type === "session" && !header) header = obj;
10
+ else entries.push(obj);
11
+ }
12
+ return { header, entries };
13
+ }
14
+
15
+ /**
16
+ * Merge two versions of one session JSONL file. Local wins on header and id conflicts.
17
+ * Entries are ordered by timestamp so the newest entry is the last line (pi's leaf, session-manager.js:733-742).
18
+ */
19
+ export function mergeSessions(
20
+ localText: string | undefined,
21
+ remoteText: string | undefined,
22
+ ): { text: string; added: number; conflicts: number; changed: boolean } {
23
+ const local = parse(localText);
24
+ const remote = parse(remoteText);
25
+ if (local.header && remote.header && local.header.id !== remote.header.id)
26
+ throw new Error(`session header id mismatch: ${local.header.id} vs ${remote.header.id}`);
27
+
28
+ const header = local.header ?? remote.header;
29
+ const byId = new Map<unknown, string>();
30
+ const merged: Line[] = [];
31
+ const noId: Line[] = [];
32
+ let added = 0;
33
+ let conflicts = 0;
34
+
35
+ for (const e of local.entries) {
36
+ if (e.id === undefined) noId.push(e);
37
+ else if (!byId.has(e.id)) {
38
+ byId.set(e.id, JSON.stringify(e));
39
+ merged.push(e);
40
+ }
41
+ }
42
+ for (const e of remote.entries) {
43
+ if (e.id === undefined) {
44
+ if (!localText) noId.push(e);
45
+ continue;
46
+ }
47
+ const existing = byId.get(e.id);
48
+ if (existing === undefined) {
49
+ byId.set(e.id, JSON.stringify(e));
50
+ merged.push(e);
51
+ added++;
52
+ } else if (existing !== JSON.stringify(e)) conflicts++;
53
+ }
54
+
55
+ // Array.prototype.sort is stable: ties keep local-before-remote input order.
56
+ merged.sort((a, b) => {
57
+ const ta = String(a.timestamp ?? "");
58
+ const tb = String(b.timestamp ?? "");
59
+ return ta < tb ? -1 : ta > tb ? 1 : 0;
60
+ });
61
+
62
+ const lines = [...(header ? [header] : []), ...merged, ...noId].map((l) => JSON.stringify(l));
63
+ const text = lines.length ? `${lines.join("\n")}\n` : "";
64
+ return { text, added, conflicts, changed: text !== localText };
65
+ }
@@ -0,0 +1,83 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, getKeybindings, getSelectPageIndex, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ export type PickItem = { id: string; label: string; checked: boolean };
5
+ type Style = Pick<Theme, "fg" | "bold">;
6
+
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
+ }
47
+
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);
73
+ return {
74
+ capturesViewportInput: true,
75
+ render: (w: number) => list.render(w),
76
+ invalidate: () => list.invalidate(),
77
+ handleInput(data: string) {
78
+ list.handleInput(data);
79
+ tui.requestRender();
80
+ },
81
+ };
82
+ });
83
+ }
package/src/rewrite.ts ADDED
@@ -0,0 +1,68 @@
1
+ // shortcut: POSIX paths only, upgrade when Windows support is needed.
2
+
3
+ export type Roots = Record<string, string>;
4
+
5
+ export const TOKEN = (name: string) => `{{PISYNC:${name}}}`;
6
+ const TOKEN_RE = /\{\{PISYNC:([a-z0-9_-]+)\}\}/g;
7
+
8
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
+
10
+ // Root path only matches at path boundaries: not glued to a preceding path/word char, and followed by "/" or a non-path char.
11
+ function rootRegexes(roots: Roots): [RegExp, string][] {
12
+ return Object.entries(roots)
13
+ .sort((a, b) => b[1].length - a[1].length)
14
+ .map(([name, path]) => [new RegExp(`(?<![A-Za-z0-9._/-])${escapeRe(path)}(?![A-Za-z0-9._-])`, "g"), TOKEN(name)]);
15
+ }
16
+
17
+ export function toPortable(s: string, roots: Roots): string {
18
+ for (const [re, token] of rootRegexes(roots)) s = s.replace(re, token);
19
+ return s;
20
+ }
21
+
22
+ export function toLocal(s: string, roots: Roots): { value: string; unknown: string[] } {
23
+ const unknown = new Set<string>();
24
+ const value = s.replace(TOKEN_RE, (m, name: string) => {
25
+ const path = Object.hasOwn(roots, name) ? roots[name] : undefined;
26
+ if (path !== undefined) return path;
27
+ unknown.add(name);
28
+ return m;
29
+ });
30
+ return { value, unknown: [...unknown] };
31
+ }
32
+
33
+ function walk(v: unknown, fn: (s: string) => string): unknown {
34
+ if (typeof v === "string") return fn(v);
35
+ if (Array.isArray(v)) return v.map((x) => walk(x, fn));
36
+ if (v && typeof v === "object") {
37
+ const out: Record<string, unknown> = {};
38
+ for (const [k, x] of Object.entries(v)) out[k] = walk(x, fn);
39
+ return out;
40
+ }
41
+ return v;
42
+ }
43
+
44
+ export function rewriteLine(line: string, fn: (s: string) => string): string {
45
+ if (!line.trim()) return line;
46
+ return JSON.stringify(walk(JSON.parse(line), fn));
47
+ }
48
+
49
+ const mapLines = (text: string, fn: (s: string) => string) =>
50
+ text
51
+ .split("\n")
52
+ .map((l) => rewriteLine(l, fn))
53
+ .join("\n");
54
+
55
+ export function rewriteFileToPortable(text: string, roots: Roots): string {
56
+ const rs = rootRegexes(roots);
57
+ return mapLines(text, (s) => rs.reduce((acc, [re, token]) => acc.replace(re, token), s));
58
+ }
59
+
60
+ export function rewriteFileToLocal(text: string, roots: Roots): { text: string; unknown: string[] } {
61
+ const unknown = new Set<string>();
62
+ const out = mapLines(text, (s) => {
63
+ const r = toLocal(s, roots);
64
+ for (const u of r.unknown) unknown.add(u);
65
+ return r.value;
66
+ });
67
+ return { text: out, unknown: [...unknown] };
68
+ }