Skip to content
Merged
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 17 additions & 9 deletions pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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`:

Expand All @@ -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.
137 changes: 80 additions & 57 deletions pi/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os from "node:os";
import path from "node:path";

import type {
ExtensionAPI,
ExtensionContext,
Expand All @@ -20,8 +23,6 @@ import {
REMOTE_WORKSPACE_DISPLAY,
} from "./operations.js";
import {
createLocalBaseline,
remoteWorkspaceHasFiles,
syncWorkspace,
type SyncResult,
uploadInitialWorkspace,
Expand Down Expand Up @@ -82,6 +83,16 @@ function sleep(milliseconds: number): Promise<void> {
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<void> {
const terminalStatuses: ComputerStatus[] = ["deleted", "deleting", "error"];
if (terminalStatuses.includes(computer.status)) {
Expand Down Expand Up @@ -131,6 +142,7 @@ export default function celestoPiExtension(pi: ExtensionAPI): void {
let runtime: RuntimeState | undefined;
let starting: Promise<RuntimeState> | undefined;
let syncing: Promise<SyncResult> | undefined;
let workspaceOperation: Promise<unknown> | undefined;

const persist = (state: RuntimeState): void => {
const {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -208,7 +220,6 @@ export default function celestoPiExtension(pi: ExtensionAPI): void {
} else {
computer = await Computer.create();
owned = true;
created = true;
}

await ensureComputerRunning(computer);
Expand All @@ -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;
}

Expand All @@ -284,7 +270,9 @@ export default function celestoPiExtension(pi: ExtensionAPI): void {
async function performSync(ctx: ExtensionContext): Promise<SyncResult> {
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 {
Expand All @@ -304,9 +292,22 @@ export default function celestoPiExtension(pi: ExtensionAPI): void {
}
}

async function runWorkspaceOperation<T>(operation: () => Promise<T>): Promise<T> {
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<SyncResult> {
if (!syncing) {
syncing = performSync(ctx).finally(() => {
syncing = runWorkspaceOperation(() => performSync(ctx)).finally(() => {
syncing = undefined;
});
}
Expand Down Expand Up @@ -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();
Expand All @@ -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") {
Expand All @@ -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",
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (action === "sync") {
try {
const result = await syncOnce(ctx);
Expand Down Expand Up @@ -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",
);
},
Expand Down
Loading
Loading