diff --git a/README.md b/README.md index 0256d09..04cb49d 100644 --- a/README.md +++ b/README.md @@ -275,14 +275,16 @@ From the project you want to work on, start Pi with Celesto: pi --celesto ``` -Pi copies the project to `$HOME/workspace` and runs its read, write, edit, and -shell tools there. Copy the resulting changes back to your local project from inside -Pi: +Pi starts with an empty `$HOME/workspace` and runs its read, write, edit, and +shell tools there. It does not copy local files automatically. To copy the current +project explicitly, run this inside Pi: ```text -/celesto sync +/celesto push ``` +After pushing, use `/celesto sync` to copy changes explicitly between the remote +workspace and your local project. Pi never syncs files automatically when it exits. Use `/celesto status` to see the active computer or `/celesto keep` to preserve an extension-created computer after Pi exits. See the [Pi extension guide](./pi/README.md) for reuse and file-exclusion options. diff --git a/pi/README.md b/pi/README.md index 0b3002c..4e58ac7 100644 --- a/pi/README.md +++ b/pi/README.md @@ -40,17 +40,25 @@ From the project you want to work on, start Pi: pi --celesto ``` -The extension creates a Celesto computer, copies the project to `$HOME/workspace`, and runs Pi's `read`, `write`, `edit`, and `bash` tools there. +The extension creates an empty `$HOME/workspace` on a Celesto computer and runs Pi's `read`, `write`, `edit`, and `bash` tools there. It does not copy anything from your machine automatically. -## Copy changes back +## Copy a project explicitly -The Celesto workspace is the active copy while Pi is running. Copy changes between it and your local project explicitly: +From inside Pi, copy the current local project to the Celesto workspace: + +```text +/celesto push +``` + +This explicit command replaces the remote workspace. It refuses to copy your filesystem root or home directory. Start Pi from a project directory before running it. + +After pushing, copy changes in either direction explicitly: ```text /celesto sync ``` -The sync command preserves independently changed files under `.celesto-conflicts/` instead of overwriting local work. +The sync command preserves independently changed files under `.celesto-conflicts/` instead of overwriting local work. Pi never syncs files automatically when it exits. ## Check the computer @@ -62,7 +70,7 @@ This shows the computer name, workspace, cleanup behavior, and current sync revi ## Keep the computer -Computers created by the extension are deleted only after a successful final sync. Keep one for later use with: +Computers created by the extension are deleted when Pi exits, without an automatic sync. Run `/celesto sync` first if you want local copies of remote changes. Keep a computer for later use with: ```text /celesto keep @@ -78,11 +86,11 @@ Get a computer name from `celesto computer create` or `celesto computer list`, t pi --celesto --celesto-computer curie ``` -The extension never deletes a computer selected by the caller. If `$HOME/workspace` already contains files, those files remain authoritative until you run `/celesto sync`. A non-empty legacy `/workspace` is moved into `$HOME/workspace` automatically when the home workspace is empty. +The extension never deletes a computer selected by the caller. Existing files in `$HOME/workspace` remain untouched unless you explicitly run `/celesto push` or `/celesto sync`. A non-empty legacy `/workspace` is moved into `$HOME/workspace` automatically when the home workspace is empty. -## Files that are not copied +## Files excluded from explicit transfers -The extension reads `.gitignore` and then applies `.celestoignore` overrides. It also excludes common secrets, dependency directories, build output, files larger than 25 MB, and symbolic links. `.git` remains available so Pi can inspect branches and diffs. +For `/celesto push` and `/celesto sync`, the extension reads `.gitignore` and then applies `.celestoignore` overrides. It also excludes common secrets, dependency directories, build output, files larger than 25 MB, and symbolic links. `.git` remains available so Pi can inspect branches and diffs. Add project-specific patterns to `.celestoignore`: @@ -95,4 +103,4 @@ Model-provider credentials are never copied into the Celesto computer. `CELESTO_ ## Current scope -This first version uses compressed archives and base64 file transfer. It intentionally does not override Pi's `grep`, `find`, or `ls` tools yet. Native workspace transfer and automatic synchronization require future Celesto backend support. +This first version uses compressed archives and base64 file transfer for explicit push and sync commands. It intentionally does not override Pi's `grep`, `find`, or `ls` tools yet. Native workspace transfer requires future Celesto backend support. diff --git a/pi/src/index.ts b/pi/src/index.ts index 565b32c..9f2a4e3 100644 --- a/pi/src/index.ts +++ b/pi/src/index.ts @@ -1,3 +1,6 @@ +import os from "node:os"; +import path from "node:path"; + import type { ExtensionAPI, ExtensionContext, @@ -20,8 +23,6 @@ import { REMOTE_WORKSPACE_DISPLAY, } from "./operations.js"; import { - createLocalBaseline, - remoteWorkspaceHasFiles, syncWorkspace, type SyncResult, uploadInitialWorkspace, @@ -82,6 +83,16 @@ function sleep(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +export function assertSafeLocalRoot(localRoot: string): void { + const resolved = path.resolve(localRoot); + const filesystemRoot = path.parse(resolved).root; + if (resolved !== filesystemRoot && resolved !== path.resolve(os.homedir())) return; + + throw new Error( + `Refusing to copy ${JSON.stringify(resolved)}. Exit Pi, change to a project directory, restart with pi --celesto, then run /celesto push.`, + ); +} + async function ensureComputerRunning(computer: Computer): Promise { const terminalStatuses: ComputerStatus[] = ["deleted", "deleting", "error"]; if (terminalStatuses.includes(computer.status)) { @@ -131,6 +142,7 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { let runtime: RuntimeState | undefined; let starting: Promise | undefined; let syncing: Promise | undefined; + let workspaceOperation: Promise | undefined; const persist = (state: RuntimeState): void => { const { @@ -171,7 +183,6 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { let owned = false; let keep = false; let revision: WorkspaceRevision | undefined; - let created = false; if (selected) { computer = await Computer.get(selected); @@ -186,20 +197,21 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { if (forkNeedsOwnComputer) { computer = await Computer.create(); owned = true; - created = true; } else { + let restored = true; try { computer = await Computer.get(saved.computerId); } catch { computer = await Computer.create(); owned = true; - created = true; + restored = false; } if (["deleted", "deleting", "error"].includes(computer.status)) { computer = await Computer.create(); owned = true; - created = true; - } else if (!created) { + restored = false; + } + if (restored) { owned = saved.owned; keep = saved.keep; revision = saved.revision; @@ -208,7 +220,6 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { } else { computer = await Computer.create(); owned = true; - created = true; } await ensureComputerRunning(computer); @@ -226,39 +237,14 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { }; runtime = next; persist(next); - updateStatus(ctx, "preparing"); + updateStatus(ctx); - if (created || !(await remoteWorkspaceHasFiles(computer, remoteWorkspace))) { - const uploaded = await uploadInitialWorkspace( - computer, - ctx.cwd, - remoteWorkspace, - ); - next.revision = uploaded.revision; - persist(next); - const skipped = - uploaded.scan.skippedLargeFiles + uploaded.scan.skippedSymlinks; - ctx.ui.notify( - `Celesto computer "${computer.name}" is ready at ${REMOTE_WORKSPACE_DISPLAY}.${ - skipped > 0 ? ` Skipped ${skipped} unsafe or oversized files.` : "" - }`, - "info", - ); - } else if (!next.revision) { - next.revision = await createLocalBaseline(ctx.cwd); - persist(next); + if (event.reason !== "reload") { ctx.ui.notify( - `Using existing files in "${computer.name}" as the active workspace. Run /celesto sync to copy changes to this project.`, - "info", - ); - } else if (event.reason !== "reload") { - ctx.ui.notify( - `Reconnected to Celesto computer "${computer.name}" at ${REMOTE_WORKSPACE_DISPLAY}.`, + `Celesto computer "${computer.name}" is ready at ${REMOTE_WORKSPACE_DISPLAY}. No local files were copied. Run /celesto push to copy this project explicitly.`, "info", ); } - - updateStatus(ctx); return next; } @@ -284,7 +270,9 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { async function performSync(ctx: ExtensionContext): Promise { const state = await ensureRuntime(ctx); if (!state.revision) { - state.revision = await createLocalBaseline(state.localRoot); + throw new Error( + "This workspace has no shared revision. Run /celesto push before /celesto sync.", + ); } updateStatus(ctx, "syncing"); try { @@ -304,9 +292,22 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { } } + async function runWorkspaceOperation(operation: () => Promise): Promise { + if (workspaceOperation) { + throw new Error("Another Celesto workspace transfer is already running."); + } + const current = operation(); + workspaceOperation = current; + try { + return await current; + } finally { + if (workspaceOperation === current) workspaceOperation = undefined; + } + } + async function syncOnce(ctx: ExtensionContext): Promise { if (!syncing) { - syncing = performSync(ctx).finally(() => { + syncing = runWorkspaceOperation(() => performSync(ctx)).finally(() => { syncing = undefined; }); } @@ -336,24 +337,11 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { const state = runtime; if (!state || event.reason === "reload") return; - let safeToDelete = false; - try { - const result = await syncOnce(ctx); - safeToDelete = result.conflicts.length === 0; - if (!safeToDelete) { - ctx.ui.notify( - `Final sync found ${result.conflicts.length} conflict(s) for "${state.computerName}". Reopen this Pi session and run /celesto sync; the computer was kept.`, - "warning", - ); - } - } catch (error) { + if (state.owned && !state.keep) { ctx.ui.notify( - `Final sync failed for "${state.computerName}": ${errorMessage(error)} Reopen this Pi session and run /celesto sync; the computer was kept.`, - "error", + `Celesto computer "${state.computerName}" will be deleted. Any unsynced changes will be lost.`, + "warning", ); - } - - if (safeToDelete && state.owned && !state.keep) { try { updateStatus(ctx, "deleting"); await state.computer.delete(); @@ -369,7 +357,7 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { }); pi.registerCommand("celesto", { - description: "Manage the active Celesto computer: status, sync, or keep", + description: "Manage the active Celesto computer: status, push, sync, or keep", handler: async (args, ctx) => { const action = args.trim() || "status"; if (action === "status") { @@ -380,7 +368,7 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { `Computer: ${state.computerName} (${state.computerId})`, `Status: ${state.computer.status}`, `Workspace: ${REMOTE_WORKSPACE_DISPLAY} (${state.remoteWorkspace})`, - `Cleanup: ${state.owned ? (state.keep ? "keep" : "delete after final sync") : "caller-owned; never delete"}`, + `Cleanup: ${state.owned ? (state.keep ? "keep" : "delete on exit without syncing") : "caller-owned; never delete"}`, `Revision: ${state.revision?.id ?? "not synchronized"}`, ].join("\n"), "info", @@ -391,6 +379,41 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { return; } + if (action === "push") { + try { + const state = await ensureRuntime(ctx); + if (state.revision) { + throw new Error( + "This workspace already has a shared revision. Run /celesto sync instead.", + ); + } + assertSafeLocalRoot(state.localRoot); + await runWorkspaceOperation(async () => { + updateStatus(ctx, "uploading"); + const uploaded = await uploadInitialWorkspace( + state.computer, + state.localRoot, + state.remoteWorkspace, + ); + state.revision = uploaded.revision; + persist(state); + const skipped = + uploaded.scan.skippedLargeFiles + uploaded.scan.skippedSymlinks; + ctx.ui.notify( + `Copied this project to "${state.computerName}".${ + skipped > 0 ? ` Skipped ${skipped} unsafe or oversized files.` : "" + }`, + "info", + ); + }); + } catch (error) { + ctx.ui.notify(`Celesto push failed: ${errorMessage(error)}`, "error"); + } finally { + updateStatus(ctx); + } + return; + } + if (action === "sync") { try { const result = await syncOnce(ctx); @@ -430,7 +453,7 @@ export default function celestoPiExtension(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown Celesto action "${action}". Use /celesto status, /celesto sync, or /celesto keep.`, + `Unknown Celesto action "${action}". Use /celesto status, /celesto push, /celesto sync, or /celesto keep.`, "error", ); }, diff --git a/pi/tests/extension.test.ts b/pi/tests/extension.test.ts index a4915bb..e5c1804 100644 --- a/pi/tests/extension.test.ts +++ b/pi/tests/extension.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -10,8 +10,153 @@ import type { SessionStartEvent, ToolDefinition, } from "@earendil-works/pi-coding-agent"; +import { + Computer, + type ComputerExecResponse, + type ComputerExecStreamEvent, + type ExecParams, +} from "@celestoai/sdk"; -import celestoPiExtension from "../src/index.js"; +import celestoPiExtension, { assertSafeLocalRoot } from "../src/index.js"; + +const REMOTE_ROOT = "/home/celesto/workspace"; + +class FakeComputer { + readonly id = "computer-1"; + readonly name = "archimedes"; + readonly status = "running"; + readonly commands: string[] = []; + + async run(command: string): Promise { + this.commands.push(command); + return { + exitCode: 0, + stdout: command.includes('home=$(cd "${HOME:?HOME is not set}"') + ? `${REMOTE_ROOT}\n` + : "", + stderr: "", + }; + } + + async *runStream( + _command: string, + _params?: ExecParams, + ): AsyncGenerator { + yield { type: "exit", exitCode: 0 }; + } +} + +test("push refuses filesystem and home directory roots", () => { + assert.throws( + () => assertSafeLocalRoot(path.parse(process.cwd()).root), + /Refusing to copy/, + ); + assert.throws(() => assertSafeLocalRoot(os.homedir()), /Refusing to copy/); + assert.doesNotThrow(() => + assertSafeLocalRoot(path.join(os.tmpdir(), "celesto-project")), + ); +}); + +test("/celesto push uploads once, persists its revision, and reports skipped files", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "celesto-push-test-")); + const originalGet = Computer.get; + try { + await writeFile(path.join(root, "hello.txt"), "hello\n"); + await symlink("hello.txt", path.join(root, "hello-link.txt")); + + const computer = new FakeComputer(); + Computer.get = async () => computer as unknown as Computer; + + const flags = new Map(); + const commands = new Map< + string, + (args: string, ctx: ExtensionContext) => Promise + >(); + const persisted: unknown[] = []; + const notifications: string[] = []; + const handlers = new Map< + string, + Array<(event: unknown, ctx: ExtensionContext) => unknown> + >(); + const api = { + registerFlag(name: string, options: { default?: boolean | string }) { + flags.set(name, options.default); + }, + getFlag(name: string) { + return flags.get(name); + }, + registerTool() {}, + registerCommand( + name: string, + options: { + handler: (args: string, ctx: ExtensionContext) => Promise; + }, + ) { + commands.set(name, options.handler); + }, + on( + event: string, + handler: (event: unknown, ctx: ExtensionContext) => unknown, + ) { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }, + appendEntry(_customType: string, data: unknown) { + persisted.push(data); + }, + } as unknown as ExtensionAPI; + + celestoPiExtension(api); + flags.set("celesto-computer", computer.name); + const context = { + cwd: root, + sessionManager: { getBranch: () => [] }, + ui: { + theme: { fg: (_color: string, value: string) => value }, + setStatus() {}, + notify(message: string) { + notifications.push(message); + }, + }, + } as unknown as ExtensionContext; + for (const handler of handlers.get("session_start") ?? []) { + await handler( + { type: "session_start", reason: "startup" } satisfies SessionStartEvent, + context, + ); + } + + const push = commands.get("celesto"); + assert(push); + await push("push", context); + + assert( + computer.commands.some((command) => + command.includes(`mv '${REMOTE_ROOT}.celesto-staging-`), + ), + ); + const saved = persisted.at(-1) as { revision?: { files?: object } }; + assert(saved.revision?.files && "hello.txt" in saved.revision.files); + assert( + notifications.some((message) => + message.includes('Copied this project to "archimedes". Skipped 1'), + ), + ); + + const commandCount = computer.commands.length; + await push("push", context); + assert.equal(computer.commands.length, commandCount); + assert( + notifications.some((message) => + message.includes("already has a shared revision"), + ), + ); + } finally { + Computer.get = originalGet; + await rm(root, { recursive: true, force: true }); + } +}); test("local tools use the session cwd without --celesto", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "celesto-extension-test-"));