Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -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`, `<cwd>/.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 (`<config>/.pi/agent`, `<config>/.pi/mom`, `<cwd>/<config>/.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
Expand Down
68 changes: 64 additions & 4 deletions packages/coding-agent/src/legacy-senpi-dir-migration.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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<readonly [string, string, string]> = [
// Official upstream pi directories: copied once, never moved.
const copies: Array<readonly [string, string, string]> = [
[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<readonly [string, string, string]> = [
[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);
}
Expand Down
88 changes: 62 additions & 26 deletions packages/coding-agent/test/senpi-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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 {
Expand All @@ -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", () => {
Expand Down