diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 07a4b79af..23f5f16ed 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,24 @@ # changes +## 2026-09-10 - Copy official `~/.pi` state once instead of moving it on every start + +### What changed + +- `packages/coding-agent/src/legacy-senpi-dir-migration.ts`: the official upstream pi directories (`~/.pi/agent`, `~/.pi/mom`, `/.pi`) are now copied into the branded layout once, gated by a `.migrated-from-pi` marker in the destination. Entries already present are never overwritten. Pre-rename leftovers nested inside the fork's own config directory (`/.pi/agent`, `/.pi/mom`, `//.pi`) keep the existing move behaviour. +- `packages/coding-agent/test/senpi-migration.test.ts`: covers the copy-once contract and the marker short-circuit on re-run. + +### Why + +- `renameSync` drained a real upstream pi install: every branded start (`senpi`, `omo`) emptied `~/.pi/agent` into `~/.omo/agent`, so pi and a branded fork could not coexist on one machine. `brand-dir-migration.ts` already documents the opposite rule for `~/.senpi` ("COPIED once - never moved - because the same machine may keep running the engine standalone"); this aligns the `.pi` path with it. + +### Why an extension could not handle it + +- Startup migrations run in `runMigrations` before any extension is loaded. + +### Expected merge conflict zones + +- LOW: `legacy-senpi-dir-migration.ts` is fork-only; `migrations.ts` orchestration is unchanged. + ## 2026-09-09 - Forward shared-host policy to extension loading ### What changed diff --git a/packages/coding-agent/src/legacy-senpi-dir-migration.ts b/packages/coding-agent/src/legacy-senpi-dir-migration.ts index d1fc8eb2e..16c8835fa 100644 --- a/packages/coding-agent/src/legacy-senpi-dir-migration.ts +++ b/packages/coding-agent/src/legacy-senpi-dir-migration.ts @@ -1,9 +1,16 @@ import os from "node:os"; import chalk from "chalk"; -import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync } from "fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, writeFileSync } from "fs"; import { dirname, isAbsolute, join, relative, resolve } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "./config.ts"; +/** + * Written into a branded directory after the official `.pi` layout was copied into it, so the + * copy happens once. The official directories are never moved: the same machine may keep running + * upstream pi standalone, and a move would silently empty that install on every start. + */ +export const OFFICIAL_PI_MIGRATION_MARKER = ".migrated-from-pi"; + function pathsPointToSameLocation(leftPath: string, rightPath: string): boolean { try { return realpathSync(leftPath) === realpathSync(rightPath); @@ -55,6 +62,50 @@ function migratePathPreservingExisting(oldPath: string, newPath: string, label: } } +/** + * One-time copy-forward of an official `.pi` directory. Entries already present in the + * destination are left untouched, and the marker makes later starts skip the directory + * entirely, so state the user keeps adding to upstream pi is never pulled in again. + */ +function copyPathPreservingExisting(oldPath: string, newPath: string, label: string): void { + if (!existsSync(oldPath)) return; + if (existsSync(newPath) && pathsPointToSameLocation(oldPath, newPath)) return; + if (existsSync(join(newPath, OFFICIAL_PI_MIGRATION_MARKER))) return; + + let entries: string[]; + try { + entries = readdirSync(oldPath); + } catch { + return; + } + + try { + mkdirSync(newPath, { recursive: true }); + } catch { + return; + } + + let copiedAny = false; + for (const entry of entries) { + const source = join(oldPath, entry); + const target = join(newPath, entry); + if (existsSync(target)) continue; + try { + cpSync(source, target, { recursive: true, errorOnExist: false }); + copiedAny = true; + } catch {} + } + + try { + writeFileSync(join(newPath, OFFICIAL_PI_MIGRATION_MARKER), `${oldPath}\n`); + } catch {} + + if (copiedAny) { + console.log(chalk.green(`Copied ${label} ${oldPath} → ${newPath}`)); + console.log(chalk.dim("The original directory is untouched; the two installs keep separate state from now on.")); + } +} + export function migrateLegacySenpiDirs(cwd: string): void { if (CONFIG_DIR_NAME === ".pi") return; @@ -64,20 +115,29 @@ export function migrateLegacySenpiDirs(cwd: string): void { const projectNewDir = join(cwd, CONFIG_DIR_NAME); const shouldMigrateHomeConfig = isWithinOrSamePath(globalNewAgentDir, join(homeDir, CONFIG_DIR_NAME)); - const moves: Array = [ + // Official upstream pi directories: copied once, never moved. + const copies: Array = [ [join(cwd, ".pi"), projectNewDir, "project config directory"], + ]; + // Pre-rename leftovers nested inside this fork's own config directory: nobody else reads them. + const moves: Array = [ [join(cwd, CONFIG_DIR_NAME, ".pi"), projectNewDir, "nested project config directory"], ]; if (shouldMigrateHomeConfig) { - moves.unshift( + copies.unshift( [join(homeDir, ".pi", "agent"), globalNewAgentDir, "global agent directory"], - [join(homeDir, CONFIG_DIR_NAME, ".pi", "agent"), globalNewAgentDir, "nested global agent directory"], [join(homeDir, ".pi", "mom"), globalNewMomDir, "global mom directory"], + ); + moves.unshift( + [join(homeDir, CONFIG_DIR_NAME, ".pi", "agent"), globalNewAgentDir, "nested global agent directory"], [join(homeDir, CONFIG_DIR_NAME, ".pi", "mom"), globalNewMomDir, "nested global mom directory"], ); } + for (const [oldPath, newPath, label] of copies) { + copyPathPreservingExisting(oldPath, newPath, label); + } for (const [oldPath, newPath, label] of moves) { migratePathPreservingExisting(oldPath, newPath, label); } diff --git a/packages/coding-agent/test/senpi-migration.test.ts b/packages/coding-agent/test/senpi-migration.test.ts index e313cc64b..a90ce3001 100644 --- a/packages/coding-agent/test/senpi-migration.test.ts +++ b/packages/coding-agent/test/senpi-migration.test.ts @@ -3,6 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ENV_AGENT_DIR } from "../src/config.ts"; +import { OFFICIAL_PI_MIGRATION_MARKER } from "../src/legacy-senpi-dir-migration.ts"; import { runMigrations } from "../src/migrations.ts"; describe("senpi migration", () => { @@ -14,33 +15,14 @@ describe("senpi migration", () => { } }); - it("moves legacy .pi directories into the .senpi layout when the new paths do not exist", () => { - // given - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "senpi-migration-test-")); - tempDirs.push(rootDir); - const fakeHome = path.join(rootDir, "home"); - const cwd = path.join(rootDir, "project"); - const oldAgentDir = path.join(fakeHome, ".pi", "agent"); - const oldMomDir = path.join(fakeHome, ".pi", "mom"); - const oldProjectDir = path.join(cwd, ".pi"); - fs.mkdirSync(oldAgentDir, { recursive: true }); - fs.mkdirSync(oldMomDir, { recursive: true }); - fs.mkdirSync(oldProjectDir, { recursive: true }); - fs.writeFileSync(path.join(oldAgentDir, "settings.json"), "{}\n", "utf-8"); - fs.writeFileSync(path.join(oldMomDir, "auth.json"), "{}\n", "utf-8"); - fs.writeFileSync(path.join(oldProjectDir, "settings.json"), "{}\n", "utf-8"); - - const newAgentDir = path.join(fakeHome, ".senpi", "agent"); + function withFakeEnv(fakeHome: string, agentDir: string, run: () => void): void { const previousAgentDir = process.env[ENV_AGENT_DIR]; const previousHome = process.env.HOME; - process.env[ENV_AGENT_DIR] = newAgentDir; + process.env[ENV_AGENT_DIR] = agentDir; process.env.HOME = fakeHome; - try { - // when - runMigrations(cwd); + run(); } finally { - // then if (previousAgentDir === undefined) { delete process.env[ENV_AGENT_DIR]; } else { @@ -52,13 +34,67 @@ describe("senpi migration", () => { process.env.HOME = previousHome; } } + } + + it("copies official .pi directories into the .senpi layout and leaves the originals in place", () => { + // given + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "senpi-migration-test-")); + tempDirs.push(rootDir); + const fakeHome = path.join(rootDir, "home"); + const cwd = path.join(rootDir, "project"); + const oldAgentDir = path.join(fakeHome, ".pi", "agent"); + const oldMomDir = path.join(fakeHome, ".pi", "mom"); + const oldProjectDir = path.join(cwd, ".pi"); + fs.mkdirSync(path.join(oldAgentDir, "extensions", "custom"), { recursive: true }); + fs.mkdirSync(oldMomDir, { recursive: true }); + fs.mkdirSync(oldProjectDir, { recursive: true }); + fs.writeFileSync(path.join(oldAgentDir, "settings.json"), "{}\n", "utf-8"); + fs.writeFileSync(path.join(oldAgentDir, "extensions", "custom", "index.ts"), "export {}\n", "utf-8"); + fs.writeFileSync(path.join(oldMomDir, "auth.json"), "{}\n", "utf-8"); + fs.writeFileSync(path.join(oldProjectDir, "settings.json"), "{}\n", "utf-8"); + + const newAgentDir = path.join(fakeHome, ".senpi", "agent"); + + // when + withFakeEnv(fakeHome, newAgentDir, () => runMigrations(cwd)); - expect(fs.existsSync(path.join(fakeHome, ".pi", "agent"))).toBe(false); - expect(fs.existsSync(path.join(fakeHome, ".pi", "mom"))).toBe(false); - expect(fs.existsSync(path.join(cwd, ".pi"))).toBe(false); - expect(fs.existsSync(path.join(fakeHome, ".senpi", "agent", "settings.json"))).toBe(true); + // then + expect(fs.existsSync(path.join(oldAgentDir, "settings.json"))).toBe(true); + expect(fs.existsSync(path.join(oldAgentDir, "extensions", "custom", "index.ts"))).toBe(true); + expect(fs.existsSync(path.join(oldMomDir, "auth.json"))).toBe(true); + expect(fs.existsSync(path.join(oldProjectDir, "settings.json"))).toBe(true); + expect(fs.existsSync(path.join(newAgentDir, "settings.json"))).toBe(true); + expect(fs.existsSync(path.join(newAgentDir, "extensions", "custom", "index.ts"))).toBe(true); expect(fs.existsSync(path.join(fakeHome, ".senpi", "mom", "auth.json"))).toBe(true); expect(fs.existsSync(path.join(cwd, ".senpi", "settings.json"))).toBe(true); + expect(fs.readFileSync(path.join(newAgentDir, OFFICIAL_PI_MIGRATION_MARKER), "utf-8")).toBe(`${oldAgentDir}\n`); + expect(fs.existsSync(path.join(fakeHome, ".senpi", "mom", OFFICIAL_PI_MIGRATION_MARKER))).toBe(true); + expect(fs.existsSync(path.join(cwd, ".senpi", OFFICIAL_PI_MIGRATION_MARKER))).toBe(true); + }); + + it("does not copy official .pi directories again once the marker exists", () => { + // given + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "senpi-migration-rerun-test-")); + tempDirs.push(rootDir); + const fakeHome = path.join(rootDir, "home"); + const cwd = path.join(rootDir, "project"); + const oldAgentDir = path.join(fakeHome, ".pi", "agent"); + const newAgentDir = path.join(fakeHome, ".senpi", "agent"); + fs.mkdirSync(oldAgentDir, { recursive: true }); + fs.mkdirSync(newAgentDir, { recursive: true }); + fs.writeFileSync(path.join(oldAgentDir, "settings.json"), '{"source":"pi"}\n', "utf-8"); + fs.writeFileSync(path.join(newAgentDir, "settings.json"), '{"source":"current"}\n', "utf-8"); + withFakeEnv(fakeHome, newAgentDir, () => runMigrations(cwd)); + fs.writeFileSync(path.join(oldAgentDir, "models.json"), '{"providers":{}}\n', "utf-8"); + + // when + withFakeEnv(fakeHome, newAgentDir, () => runMigrations(cwd)); + + // then + expect(fs.readFileSync(path.join(newAgentDir, "settings.json"), "utf-8")).toBe('{"source":"current"}\n'); + expect(fs.existsSync(path.join(newAgentDir, "models.json"))).toBe(false); + expect(fs.existsSync(path.join(oldAgentDir, "models.json"))).toBe(true); + expect(fs.existsSync(path.join(oldAgentDir, "settings.json"))).toBe(true); }); it("moves missing nested legacy agent files without overwriting current files", () => {