Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8bd38fb236 chore(release): cut 0.15.0-beta.1 2026-11-01 06:19:33 +01:00
Mohamed Boudra 7e5ad53491 docs(changelog): prepare 0.15.0-beta.1 2026-11-01 06:11:09 +01:00
paseo-ai[bot] a529578cd1 fix: update lockfile signatures and Nix hash [skip ci]
(cherry picked from commit b2666a0aec)
2026-11-01 06:11:09 +01:00
31 changed files with 160 additions and 1532 deletions
-26
View File
@@ -135,32 +135,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag
shell: bash
run: |
@@ -120,7 +120,6 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -166,6 +165,18 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -2872,8 +2883,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -1,63 +0,0 @@
import { createReadStream } from "node:fs";
import { appendFile } from "node:fs/promises";
import path from "node:path";
import { createInterface } from "node:readline";
import type { SessionStore, SessionStoreEntry } from "@anthropic-ai/claude-agent-sdk";
const APPEND_CHUNK_LENGTH = 1 << 20;
export function projectFolderSessionStore(projectFolder: string): SessionStore {
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
return {
async load({ sessionId }) {
const input = createReadStream(transcriptPath(sessionId), { encoding: "utf8" });
const lines = createInterface({ input, crlfDelay: Infinity });
const entries: SessionStoreEntry[] = [];
try {
for await (const line of lines) {
const entry = parseTranscriptEntry(line);
if (entry) entries.push(entry);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
} finally {
lines.close();
input.destroy();
}
return entries;
},
async append({ sessionId }, entries) {
let chunk = "";
for (const entry of entries) {
chunk += `${JSON.stringify(entry)}\n`;
if (chunk.length >= APPEND_CHUNK_LENGTH) {
await appendFile(transcriptPath(sessionId), chunk, "utf8");
chunk = "";
}
}
await appendFile(transcriptPath(sessionId), chunk, "utf8");
},
};
}
function parseTranscriptEntry(line: string): SessionStoreEntry | null {
if (!line.trim()) return null;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
// Skip a line that is not JSON, as the SDK's own transcript reader does.
return null;
}
return isSessionStoreEntry(parsed) ? parsed : null;
}
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as { type?: unknown }).type === "string"
);
}
@@ -1,43 +0,0 @@
import path from "node:path";
import { parentPort, workerData } from "node:worker_threads";
import { forkSession } from "@anthropic-ai/claude-agent-sdk";
import { projectFolderSessionStore } from "./rewind-session-store.js";
export interface ClaudeForkRequest {
sessionId: string;
upToMessageId: string;
transcriptPath: string;
}
export type ClaudeForkReply =
| { kind: "forked"; sessionId: string }
| {
kind: "failed";
message: string;
stack: string | undefined;
properties: Record<string, unknown>;
};
const port = parentPort!;
const request: ClaudeForkRequest = workerData;
try {
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
// provider can point Claude Code at another one. Forking through a store rooted at the
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
const fork = await forkSession(request.sessionId, {
upToMessageId: request.upToMessageId,
sessionStore: projectFolderSessionStore(path.dirname(request.transcriptPath)),
});
port.postMessage({ kind: "forked", sessionId: fork.sessionId } satisfies ClaudeForkReply);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
const failure = { kind: "failed", message: error.message, stack: error.stack } as const;
try {
port.postMessage({ ...failure, properties: { ...error } } satisfies ClaudeForkReply);
} catch (cloneError) {
if (!(cloneError instanceof DOMException) || cloneError.name !== "DataCloneError") {
throw cloneError;
}
port.postMessage({ ...failure, properties: {} } satisfies ClaudeForkReply);
}
}
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { subscribe, unsubscribe } from "node:diagnostics_channel";
import {
mkdirSync,
mkdtempSync,
@@ -11,11 +10,9 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Worker } from "node:worker_threads";
import { afterEach, describe, expect, test } from "vitest";
import { forkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
import {
findSteeredMessageParent,
revertClaudeConversation,
@@ -23,7 +20,6 @@ import {
revertClaudeFiles,
realClaudeRewindSdk,
} from "./rewind.js";
import { projectFolderSessionStore } from "./rewind-session-store.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
describe("Claude rewind", () => {
@@ -282,196 +278,8 @@ describe("Claude rewind against the Claude SDK", () => {
"tool output",
]);
});
test("writes the fork beside the original and leaves the original untouched", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-fork-"));
const projectDir = path.join(tempRoot, "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const firstUser = randomUUID();
const firstReply = randomUUID();
const secondUser = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
{ type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" },
{ type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" },
]);
const original = readFileSync(transcriptPath, "utf8");
const fork = await realClaudeRewindSdk.forkSession(sessionId, {
upToMessageId: secondUser,
transcriptPath,
});
const forkPath = path.join(projectDir, `${fork.sessionId}.jsonl`);
expect(readdirSync(projectDir).sort()).toEqual(
[`${sessionId}.jsonl`, `${fork.sessionId}.jsonl`].sort(),
);
expect(readMessageTexts(forkPath)).toEqual(["first", "first reply", "second"]);
const forkSessionIds = readFileSync(forkPath, "utf8")
.trim()
.split("\n")
.map((line) => (JSON.parse(line) as { sessionId: string }).sessionId);
expect(forkSessionIds).toEqual([
fork.sessionId,
fork.sessionId,
fork.sessionId,
fork.sessionId,
]);
expect(readFileSync(transcriptPath, "utf8")).toBe(original);
});
test("rejects with the SDK's own error when the transcript is missing", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-missing-"));
const sessionId = randomUUID();
await expect(
realClaudeRewindSdk.forkSession(sessionId, {
upToMessageId: randomUUID(),
transcriptPath: path.join(tempRoot, `${sessionId}.jsonl`),
}),
).rejects.toThrow(new Error(`Session ${sessionId} not found`));
});
test("rejects with the name and message of the error the SDK raises", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-invalid-"));
const sessionId = randomUUID();
const sdkError = await forkSession(sessionId, { upToMessageId: "not-a-uuid" }).then(
() => null,
(error: Error) => error,
);
expect(sdkError).toBeInstanceOf(Error);
await expect(
realClaudeRewindSdk.forkSession(sessionId, {
upToMessageId: "not-a-uuid",
transcriptPath: path.join(tempRoot, `${sessionId}.jsonl`),
}),
).rejects.toMatchObject({ name: sdkError?.name, message: sdkError?.message });
});
test("keeps a filesystem error's code when the fork fails in the worker", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-eisdir-"));
const sessionId = randomUUID();
const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
mkdirSync(transcriptPath);
await expect(
realClaudeRewindSdk.forkSession(sessionId, { upToMessageId: randomUUID(), transcriptPath }),
).rejects.toMatchObject({ name: "Error", code: "EISDIR" });
});
test("forks one conversation at a time", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-queue-"));
const projectDir = path.join(tempRoot, "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const forks = [randomUUID(), randomUUID()].map((sessionId) => {
const user = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
{ type: "user", uuid: user, parentUuid: null, text: "first" },
{ type: "assistant", uuid: randomUUID(), parentUuid: user, text: "first reply" },
]);
return { sessionId, upToMessageId: user, transcriptPath };
});
const events: string[] = [];
let started = 0;
function recordWorker(message: unknown): void {
if (typeof message !== "object" || message === null || !("worker" in message)) return;
if (!(message.worker instanceof Worker)) return;
const fork = ++started;
events.push(`start ${fork}`);
message.worker.once("message", () => events.push(`reply ${fork}`));
}
subscribe("worker_threads", recordWorker);
try {
await Promise.all(
forks.map(({ sessionId, upToMessageId, transcriptPath }) =>
realClaudeRewindSdk.forkSession(sessionId, { upToMessageId, transcriptPath }),
),
);
} finally {
unsubscribe("worker_threads", recordWorker);
}
expect(events).toEqual(["start 1", "reply 1", "start 2", "reply 2"]);
});
test("forks a long transcript without blocking the daemon's event loop", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-long-"));
const projectDir = path.join(tempRoot, "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
const turns = 43_000;
const lastUser = writeLongTranscript(transcriptPath, sessionId, turns);
const { result: fork, blockingMs } = await measureMainThreadWork(
"claude fork main-thread work",
() => realClaudeRewindSdk.forkSession(sessionId, { upToMessageId: lastUser, transcriptPath }),
);
expect(blockingMs).toBeLessThan(50);
const forkLines = readFileSync(path.join(projectDir, `${fork.sessionId}.jsonl`), "utf8")
.trim()
.split("\n");
expect(forkLines).toHaveLength(turns * 2);
expect(JSON.parse(forkLines[turns * 2 - 2]!).forkedFrom).toEqual({
sessionId,
messageUuid: lastUser,
});
});
});
describe("Claude project folder session store", () => {
let root: string | null = null;
afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
root = null;
});
test("appends entries after existing ones, byte for byte, and loads them back", async () => {
root = mkdtempSync(path.join(tmpdir(), "claude-rewind-store-"));
const store = projectFolderSessionStore(root);
const key = { projectKey: "-repo", sessionId: randomUUID() };
const transcriptPath = path.join(root, `${key.sessionId}.jsonl`);
const existing = { type: "user", uuid: randomUUID(), message: { content: "already there" } };
writeFileSync(transcriptPath, `${JSON.stringify(existing)}\n`);
const entries = Array.from({ length: 4_000 }, (_, index) => ({
type: "assistant",
uuid: randomUUID(),
message: { content: `reply ${index} ✓ ${"x".repeat(1_000)}` },
}));
await store.append(key, entries);
expect(readFileSync(transcriptPath, "utf8")).toBe(
[existing, ...entries].map((entry) => `${JSON.stringify(entry)}\n`).join(""),
);
expect(await store.load(key)).toEqual([existing, ...entries]);
});
});
function writeLongTranscript(transcriptPath: string, sessionId: string, turns: number): string {
const toolOutput = 'line 1: "quoted" output\twith\\escapes\n'.repeat(4);
const lines: TranscriptLine[] = [];
let parentUuid: string | null = null;
let lastUser = "";
for (let turn = 0; turn < turns; turn++) {
lastUser = randomUUID();
const reply = randomUUID();
lines.push({ type: "user", uuid: lastUser, parentUuid, text: `question ${turn}` });
lines.push({ type: "assistant", uuid: reply, parentUuid: lastUser, text: toolOutput });
parentUuid = reply;
}
writeTranscript(transcriptPath, sessionId, lines);
return lastUser;
}
test.runIf(process.platform === "linux")(
"releases the transcript descriptor after an early steer match",
async () => {
@@ -1,10 +1,13 @@
import { createReadStream } from "node:fs";
import { appendFile, readFile } from "node:fs/promises";
import path from "node:path";
import { createInterface } from "node:readline";
import { Worker } from "node:worker_threads";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import pLimit from "p-limit";
import { resolveWorkerEntrypoint } from "../../../../utils/worker-entrypoint.js";
import type { ClaudeForkReply, ClaudeForkRequest } from "./rewind-worker.js";
import {
forkSession as claudeForkSession,
type Query,
type SessionStore,
type SessionStoreEntry,
} from "@anthropic-ai/claude-agent-sdk";
export interface ClaudeRewindSdk {
forkSession(
@@ -13,42 +16,60 @@ export interface ClaudeRewindSdk {
): Promise<{ sessionId: string }>;
}
const source = import.meta.url.endsWith(".ts");
const workerUrl = new URL(source ? "./rewind-worker.ts" : "./rewind-worker.js", import.meta.url);
const rewindWorker = resolveWorkerEntrypoint(workerUrl);
const forks = pLimit(1);
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
// provider can point Claude Code at another one. Forking through a store rooted at the
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
export const realClaudeRewindSdk: ClaudeRewindSdk = {
forkSession: (sessionId, options) =>
forks(() =>
forkInWorker({
sessionId,
upToMessageId: options.upToMessageId,
transcriptPath: options.transcriptPath,
}),
),
claudeForkSession(sessionId, {
upToMessageId: options.upToMessageId,
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
}),
};
function forkInWorker(request: ClaudeForkRequest): Promise<{ sessionId: string }> {
return new Promise((resolve, reject) => {
const worker = new Worker(rewindWorker.entrypoint, {
eval: rewindWorker.eval,
execArgv: [],
workerData: request,
});
worker.once("message", (reply: ClaudeForkReply) => {
void worker.terminate();
if (reply.kind === "forked") {
resolve({ sessionId: reply.sessionId });
return;
function projectFolderSessionStore(projectFolder: string): SessionStore {
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
return {
async load({ sessionId }) {
let text: string;
try {
text = await readFile(transcriptPath(sessionId), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
const error = new Error(reply.message);
error.stack = reply.stack;
reject(Object.assign(error, reply.properties));
});
worker.once("error", reject);
worker.once("exit", (code) => reject(new Error(`Claude rewind worker exited (code ${code})`)));
});
return parseTranscriptEntries(text);
},
async append({ sessionId }, entries) {
const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
await appendFile(transcriptPath(sessionId), lines, "utf8");
},
};
}
function parseTranscriptEntries(text: string): SessionStoreEntry[] {
const entries: SessionStoreEntry[] = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
// Skip a line that is not JSON, as the SDK's own transcript reader does.
continue;
}
if (isSessionStoreEntry(parsed)) entries.push(parsed);
}
return entries;
}
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as { type?: unknown }).type === "string"
);
}
/**
@@ -5,13 +5,11 @@ import {
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises";
@@ -25,7 +23,6 @@ import {
PiRpcAgentSession,
transformPiModels,
} from "./agent.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { FakePi } from "./test-utils/fake-pi.js";
import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js";
@@ -2722,7 +2719,6 @@ describe("PiRpcAgentClient", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
const cwd = path.join(root, "workspace");
const sessionsDir = path.join(root, "sessions");
mkdirSync(cwd, { recursive: true });
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
writeFileSync(
@@ -3536,320 +3532,6 @@ describe("PiRpcAgentClient", () => {
});
});
describe("PiRpcAgentClient history resume", () => {
const BUILTIN_MCP_COMMAND = {
name: "mcp",
source: "extension",
sourceInfo: { path: "builtin:mcp" },
} as const;
const PASEO_MCP_SERVERS = {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
} as const;
interface ArchivedPiSession {
handle: {
provider: string;
sessionId: string;
nativeHandle: string;
metadata: { cwd: string; model?: string };
};
sessionFile: string;
header: Record<string, unknown>;
body: string;
originalBytes: Buffer;
}
function createDeletedFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
rmSync(folder, { recursive: true, force: true });
return folder;
}
function createFileWhereFolderWas(): string {
const folder = createDeletedFolder();
writeFileSync(folder, "not a directory");
onTestFinished(() => rmSync(folder, { force: true }));
return folder;
}
function createExistingFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
return folder;
}
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
};
const body = [
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
message: { role: "user", content: "Où est le café ☕ ?" },
}),
JSON.stringify({
type: "message",
id: "entry-2",
parentId: "entry-1",
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
}),
]
.map((line) => `${line}\n`)
.join("");
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
return {
handle: {
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: sessionFile,
metadata: { cwd, ...metadata },
},
sessionFile,
header,
body,
originalBytes: readFileSync(sessionFile),
};
}
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
const content = readUtf8File(sessionFile);
const headerEnd = content.indexOf("\n");
return {
header: JSON.parse(content.slice(0, headerEnd)),
body: content.slice(headerEnd + 1),
};
}
function resumeArchived(
client: PiRpcAgentClient,
archived: ArchivedPiSession,
purpose: "history" | "interactive",
overrides: Partial<AgentSessionConfig> = {},
): Promise<AgentSession> {
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
}
function writeFakePiProcess(): {
command: [string, ...string[]];
readLaunches: () => Array<{ cwd: string; session: string | null }>;
} {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const scriptPath = path.join(dir, "fake-pi.cjs");
const launchesPath = path.join(dir, "launches.jsonl");
writeFileSync(
scriptPath,
`
const fs = require("node:fs");
const readline = require("node:readline");
const [launchesPath, ...piArgs] = process.argv.slice(2);
const sessionFlag = piArgs.indexOf("--session");
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
if (sessionFile) {
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
if (header.cwd && !fs.existsSync(header.cwd)) {
fs.writeSync(
2,
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
);
process.exit(1);
}
}
const models = [
{ provider: "openrouter", id: "a" },
{ provider: "openrouter", id: "b" },
];
const state = {
sessionId: "pi-session-1",
sessionFile,
model: models[0],
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
};
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
function handle(request) {
switch (request.type) {
case "get_state":
return state;
case "get_commands":
return { commands };
case "get_available_models":
return { models };
case "set_model":
fs.appendFileSync(
sessionFile,
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
);
state.model = models.find((model) => model.id === request.modelId);
return state.model;
default:
return {};
}
}
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
process.stdout.write(
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
);
});
`,
);
return {
command: [process.execPath, scriptPath, launchesPath],
readLaunches: () =>
readUtf8File(launchesPath)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
};
}
test.each([
["is gone", createDeletedFolder],
["is now a file", createFileWhereFolderWas],
])(
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
async (_folderState, createFolder) => {
const archived = writeArchivedSession(createFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toHaveLength(1);
const launch = pi.recordedLaunches[0]!;
expect(launch.cwd).toBe(homedir());
expect(launch.session).not.toBe(archived.sessionFile);
expect(readSessionCopy(launch.session!)).toEqual({
header: { ...archived.header, cwd: homedir() },
body: archived.body,
});
},
);
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
const sessionCopy = pi.recordedLaunches[0]!.session!;
expect(existsSync(sessionCopy)).toBe(true);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
await session.getRuntimeInfo();
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
expect(session.describePersistence()).toMatchObject({
nativeHandle: archived.sessionFile,
metadata: { cwd: storedCwd },
});
});
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
});
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
"Pi RPC process exited with code 1",
);
expect(pi.recordedLaunches).toHaveLength(1);
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
});
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueCommands([BUILTIN_MCP_COMMAND]);
const session = await resumeArchived(createClient(pi), archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
expect(session.capabilities.supportsMcpServers).toBe(true);
});
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
`Stored session working directory does not exist: ${storedCwd}`,
);
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
const storedCwd = createExistingFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
const fakePiProcess = writeFakePiProcess();
const logger = pino({ level: "silent" });
const client = new PiRpcAgentClient({
logger,
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
});
const session = await resumeArchived(client, archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
const launches = fakePiProcess.readLaunches();
const home = realpathSync.native(homedir());
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const sessionCopy = launches[1]!.session!;
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, join, resolve as resolvePath } from "node:path";
import { join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import stripAnsi from "strip-ansi";
import { z } from "zod";
@@ -22,7 +22,6 @@ import {
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -76,13 +75,8 @@ import {
piExtensionRuntimeBridge,
type PiExtensionEventOutput,
} from "./extensions/index.js";
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
import { revertPiConversation } from "./rewind.js";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
import type {
PiAgentSessionEvent,
@@ -251,7 +245,6 @@ interface PiRpcAgentSessionOptions {
initialState: PiSessionState;
capabilities: AgentCapabilityFlags;
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
extensionTimeoutMs?: number;
logger: Logger;
@@ -499,14 +492,13 @@ function buildResumeConfig(
function buildResumeStartInput(input: {
resumeConfig: PiResumeConfig;
processCwd: string;
sessionFile: string;
launchContext: AgentLaunchContext | undefined;
mcpConfigFile: PiTempFile | null;
paseoExtension: PiTempFile | null;
}): PiStartSessionInput {
return {
cwd: input.processCwd,
cwd: input.resumeConfig.cwd,
env: input.launchContext?.env,
session: input.sessionFile,
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
@@ -626,19 +618,6 @@ function createPiMcpConfigFile(
};
}
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
const cleanup = () => rmSync(dir, { recursive: true, force: true });
const filePath = join(dir, basename(sessionFile));
try {
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
} catch (error) {
cleanup();
throw error;
}
return { path: filePath, cleanup };
}
function createPiPaseoExtensionFile({
systemPrompt,
mcpServers = {},
@@ -1253,7 +1232,6 @@ export class PiRpcAgentSession implements AgentSession {
this.capabilities = options.capabilities;
this.provider = PI_PROVIDER;
this.currentModeId = options.currentModeId ?? null;
this.persistedSessionFile = options.persistedSessionFile;
this.cleanup = options.cleanup;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
@@ -1284,7 +1262,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly runtimeSession: PiRuntimeSession;
private readonly config: AgentSessionConfig;
private readonly persistedSessionFile?: string;
private readonly cleanup?: () => void;
private readonly extensionTimeoutMs: number;
@@ -1519,7 +1496,7 @@ export class PiRpcAgentSession implements AgentSession {
return {
provider: this.provider,
sessionId: this.state.sessionId,
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
nativeHandle: this.state.sessionFile,
metadata: {
cwd: this.config.cwd,
...(this.config.model ? { model: this.config.model } : {}),
@@ -2668,7 +2645,6 @@ export class PiRpcAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2677,7 +2653,6 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
const providerOptions = PiProviderOptionsSchema.parse(
resumeConfig.config.providerOptions ?? {},
@@ -2688,7 +2663,7 @@ export class PiRpcAgentClient implements AgentClient {
...launchContext?.env,
};
const mcp = await this.prepareMcpInjection(
processCwd,
resumeConfig.cwd,
resumeConfig.config.mcpServers,
mcpEnv,
runtime,
@@ -2701,29 +2676,20 @@ export class PiRpcAgentClient implements AgentClient {
),
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
});
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
const cleanupTempFiles = () => {
for (const tempFile of tempFiles) tempFile.cleanup();
};
let sessionCopy: PiTempFile | null = null;
let runtimeSession: PiRuntimeSession;
try {
if (processCwd !== resumeConfig.cwd) {
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
tempFiles.push(sessionCopy);
}
runtimeSession = await runtime.startSession(
buildResumeStartInput({
resumeConfig,
processCwd,
sessionFile: sessionCopy?.path ?? sessionFile,
sessionFile,
launchContext,
mcpConfigFile,
paseoExtension,
}),
);
} catch (error) {
cleanupTempFiles();
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
throw error;
}
try {
@@ -2736,15 +2702,15 @@ export class PiRpcAgentClient implements AgentClient {
},
initialState,
capabilities: capabilitiesForSession(mcp !== null),
persistedSessionFile: sessionCopy ? sessionFile : undefined,
cleanup: cleanupTempFiles,
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger,
usagePollScheduler: this.usagePollScheduler,
});
} catch (error) {
await runtimeSession.close().catch(() => undefined);
cleanupTempFiles();
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
throw error;
}
}
@@ -1,13 +1,9 @@
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { expect, test } from "vitest";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
async function writeSession(root: string, lines: unknown[]): Promise<string> {
const sessionsDir = path.join(root, "sessions", "project");
@@ -220,97 +216,3 @@ test("Pi import config preserves thinking before a later model in large sessions
thinkingOptionId: "low",
});
});
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: path.join(root, "removed-worktree"),
};
const body = Buffer.from(
Array.from(
{ length: 2_000 },
(_, index) =>
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
).join(""),
);
expect(body.length).toBeGreaterThan(64 * 1024);
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
const copy = await readFile(targetFile);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
});
test("a Pi session copy refuses a file without a session header", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
});
const COPY_BODY = [
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join("");
const COPY_HEADER = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/removed-worktree",
};
async function copySessionContent(content: string): Promise<Buffer> {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, content);
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
return readFile(targetFile);
}
test.each([
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
["blank lines before the header", "\n\r\n", COPY_HEADER],
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
});
test("a Pi session copy reports a missing session file", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "missing.jsonl");
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
});
@@ -1,8 +1,7 @@
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type {
ImportableProviderSession,
@@ -19,7 +18,6 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
// Rank all discovered files cheaply, then parse only a bounded recent window.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
@@ -102,59 +100,6 @@ export async function listPiImportableSessions(
);
}
export async function copyPiSessionFileWithCwd(input: {
sourceFile: string;
targetFile: string;
cwd: string;
}): Promise<void> {
const found = findSessionHeaderLine(
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
);
if (!found) {
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
}
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
mode: 0o600,
});
await pipeline(
createReadStream(input.sourceFile, { start: found.bodyStart }),
createWriteStream(input.targetFile, { flags: "a" }),
);
}
function findSessionHeaderLine(
scanned: Buffer,
): { header: Record<string, unknown>; bodyStart: number } | null {
let lineStart = 0;
while (lineStart < scanned.length) {
const newline = scanned.indexOf(0x0a, lineStart);
const bodyStart = newline === -1 ? scanned.length : newline + 1;
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
if (line) {
const header = parseJsonRecord(line);
return header?.type === "session" ? { header, bodyStart } : null;
}
lineStart = bodyStart;
}
return null;
}
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
const handle = await open(filePath, "r").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Pi session file is missing: ${filePath}`);
}
throw error;
});
try {
const buffer = Buffer.alloc(maxBytes);
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close().catch(() => undefined);
}
}
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
const descriptor = await readPiSessionDescriptor(filePath);
if (!descriptor) return {};
@@ -1,5 +1,3 @@
import { existsSync, readFileSync } from "node:fs";
import type {
PiRuntime,
PiRuntimeLaunch,
@@ -77,12 +75,6 @@ export class FakePi implements PiRuntime {
const session = new FakePiSession(launch, this.removedModels);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
if (missingSessionCwd !== null) {
session.getStateError = new Error(
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
);
}
this.sessions.push(session);
return session;
}
@@ -109,27 +101,6 @@ export class FakePi implements PiRuntime {
}
}
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
if (!sessionFile || !existsSync(sessionFile)) {
return null;
}
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
let header: unknown;
try {
header = JSON.parse(headerLine);
} catch {
return null;
}
if (typeof header !== "object" || header === null || !("cwd" in header)) {
return null;
}
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
return null;
}
return cwd;
}
export class FakePiSession implements PiRuntimeSession {
readonly environment: Record<string, string>;
readonly prompts: Array<{ message: string; imageCount: number }> = [];
@@ -1,26 +0,0 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import type { AgentResumePurpose } from "../agent-sdk-types.js";
export async function resolveResumeProcessCwd(
cwd: string,
purpose: AgentResumePurpose | undefined,
): Promise<string> {
if (purpose !== "history" || (await isDirectory(cwd))) {
return cwd;
}
return homedir();
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const standalonePort = await findFreePort();
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
@@ -1,7 +1,8 @@
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import pLimit from "p-limit";
import { ZodError } from "zod";
import { resolveWorkerEntrypoint } from "../../utils/worker-entrypoint.js";
import type * as build from "./compiler-build.js";
import type { PluginManifest } from "./manifest.js";
import type { CompilerReply } from "./compiler-worker.js";
@@ -36,7 +37,9 @@ const workerUrl = new URL(
);
// Resolve the development loader once at module initialization, never during a
// compile request. Packaged workers execute ordinary JavaScript without tsx.
const compilerWorker = resolveWorkerEntrypoint(workerUrl);
const workerEntrypoint = source
? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`
: workerUrl;
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
@@ -71,10 +74,7 @@ class PluginCompiler {
}
private startWorker(): Worker {
const worker = new Worker(compilerWorker.entrypoint, {
eval: compilerWorker.eval,
execArgv: [],
});
const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] });
this.worker = worker;
// Keep error/exit ownership while idle too. The next caller gets a fresh
// isolate after a crash; author build commands are never retried.
@@ -61,24 +61,10 @@ describe("managed Git plugin sources", () => {
roots.push(home);
const sources = new ManagedPluginSources(home);
// Own the failure response instead of relying on a closed port and OS connect timing.
const server = createServer((_request, response) => {
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("clone rejected for oauth2:super-secret\n");
const failure = sources.prepareInstall({
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
try {
const failure = sources.prepareInstall({
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
});
await expect(failure).rejects.toThrow("500");
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
});
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
+31 -246
View File
@@ -1,9 +1,7 @@
import { type EventEmitter, once } from "node:events";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import net from "node:net";
import type { Duplex } from "node:stream";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
@@ -334,39 +332,48 @@ describe("service proxy subsystem shape", () => {
});
});
interface ProxiedServiceFixture {
interface ForwardedFixture {
upstreamPort: number;
daemonPort: number;
daemon: http.Server;
hostname: string;
close(): Promise<void>;
}
/**
* Runs a real workspace service behind a real daemon listener that serves the
* service proxy for both HTTP and upgrades.
* Runs a real workspace service behind a real daemon listener. The upstream
* echoes the headers it received so tests can assert what actually crossed the
* proxy, not what a helper returned.
*/
async function startProxiedService(
upstream: http.Server,
options: { upstreamHost?: string; logger?: pino.Logger } = {},
): Promise<ProxiedServiceFixture> {
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
// Upgraded sockets keep the server alive; close() alone would hang.
const sockets = new Set<net.Socket>();
function trackConnections(server: http.Server) {
server.on("connection", (socket: net.Socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
}
trackConnections(upstream);
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
const upgradeSockets: net.Socket[] = [];
upstream.on("upgrade", (req, socket) => {
upgradeSockets.push(socket);
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve);
});
const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
const serviceProxy = createServiceProxySubsystem({ logger });
const route = serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
@@ -383,51 +390,24 @@ async function startProxiedService(
res.status(404).send("404 Not Found");
});
const daemon = http.createServer(app);
trackConnections(daemon);
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return {
upstreamPort,
daemonPort,
daemon,
hostname: route.hostname,
async close() {
for (const socket of sockets) socket.destroy();
// Upgraded sockets keep the server alive; close() alone would hang.
for (const socket of upgradeSockets) socket.destroy();
daemon.closeAllConnections();
await new Promise<void>((resolve) => daemon.close(() => resolve()));
upstream.closeAllConnections();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
},
};
}
/**
* The upstream echoes the headers it received so tests can assert what
* actually crossed the proxy, not what a helper returned.
*/
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
upstream.on("upgrade", (req, socket) => {
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
return startProxiedService(upstream, { upstreamHost });
}
function upgradeThroughProxy(
port: number,
host: string,
@@ -671,198 +651,3 @@ describe("service proxy forwarded headers", () => {
}
});
});
function closed(emitter: EventEmitter): Promise<void> {
return new Promise((resolve) => {
emitter.once("close", () => resolve());
});
}
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
function requestThroughProxy(
fixture: ProxiedServiceFixture,
requestPath: string,
): http.ClientRequest {
return http.get({
hostname: "127.0.0.1",
port: fixture.daemonPort,
path: requestPath,
headers: { host: fixture.hostname },
});
}
const STRAY_WARNING_GRACE_MS = 100;
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
const upstream = http.createServer();
upstream.on("upgrade", (_req, socket) => {
socket.on("error", () => socket.destroy());
socket.on("end", () => socket.end());
socket.write(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
);
onUpgrade(socket);
});
return upstream;
}
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
const lines = [
"GET /ws HTTP/1.1",
`Host: ${fixture.hostname}`,
"Upgrade: websocket",
"Connection: Upgrade",
"",
"",
];
socket.write(lines.join("\r\n"));
});
let raw = "";
socket.on("data", (chunk: Buffer) => {
raw += chunk.toString();
if (raw.includes("\r\n\r\n")) resolve(socket);
});
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
socket.on("error", reject);
});
}
describe("service proxy connection teardown", () => {
it("closes the upstream connection when a client leaves an event stream", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write("data: open\n\n");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const request = requestThroughProxy(fixture, "/events");
const [response] = (await once(request, "response")) as [http.IncomingMessage];
await once(response, "data");
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the event stream");
} finally {
await fixture.close();
}
}, 5000);
it("aborts the upstream request when a client leaves before the response starts", async () => {
const warnings: unknown[] = [];
const proxyLogger = pino(
{ level: "warn" },
{ write: (line: string) => warnings.push(JSON.parse(line)) },
);
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
try {
const request = requestThroughProxy(fixture, "/slow");
request.on("error", () => undefined);
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
expect(warnings).toEqual([]);
} finally {
await fixture.close();
}
}, 5000);
it("lets a complete response finish without resetting the service", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.write("first;");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
const res = await upstreamResponse.promise;
const upstreamClosed = closed(res);
res.end("second");
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
await within(upstreamClosed, 1000, "the service response never closed");
expect(res.writableFinished).toBe(true);
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
const daemonSide = Promise.withResolvers<Duplex>();
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
(await daemonSide.promise).destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
client.destroy();
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the client disconnects", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
client.destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
} finally {
await fixture.close();
}
}, 5000);
it("closes the client WebSocket when the service closes it", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const clientClosed = closed(client);
(await upstreamSocket.promise).destroy();
await within(clientClosed, 1000, "the client WebSocket is still open");
} finally {
await fixture.close();
}
}, 5000);
});
@@ -342,14 +342,7 @@ function proxyHttpRequest({
proxyRes.pipe(res, { end: true });
},
);
let clientClosed = false;
res.on("close", () => {
if (res.writableFinished) return;
clientClosed = true;
proxyReq.destroy();
});
proxyReq.on("error", (err) => {
if (clientClosed) return;
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable",
@@ -406,9 +399,6 @@ function proxyUpgradeRequest({
socket.on("error", () => {
targetSocket.destroy();
});
socket.on("close", () => {
targetSocket.destroy();
});
}
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
@@ -95,13 +95,7 @@ it("accepts terminalId and token reports through the route into the tracker", as
command: process.execPath,
args: [
"-e",
`
const fs = require("node:fs");
const envPath = ${JSON.stringify(envPath)};
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
fs.renameSync(envPath + ".tmp", envPath);
setInterval(() => {}, 1000);
`,
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
],
});
await waitForCondition(() => existsSync(envPath), 10000);
@@ -410,12 +410,7 @@ describe("createTerminal", () => {
cwd: realpathSync(tmpdir()),
cols: 500,
command: process.execPath,
// Keep the PTY alive until the screen assertion; exit disposes its parser.
args: [
"-e",
"process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)",
nastyPrompt,
],
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
}),
);
@@ -1,6 +0,0 @@
import { parentPort, workerData } from "node:worker_threads";
import { expandTilde } from "../path.js";
const port = parentPort!;
const input: string = workerData;
port.postMessage(expandTilde(input));
@@ -1,34 +0,0 @@
import { once } from "node:events";
import { Worker } from "node:worker_threads";
import { describe, expect, test } from "vitest";
import { expandTilde } from "./path.js";
import { resolveWorkerEntrypoint } from "./worker-entrypoint.js";
describe("worker entrypoint", () => {
test("starts a compiled worker from its own URL", () => {
const entry = resolveWorkerEntrypoint(
new URL("file:///app/dist/server/server/plugins/compiler-worker.js"),
);
expect({ ...entry, entrypoint: String(entry.entrypoint) }).toEqual({
entrypoint: "file:///app/dist/server/server/plugins/compiler-worker.js",
eval: false,
});
});
test("runs a TypeScript worker, with its .js imports, through tsx", async () => {
const entry = resolveWorkerEntrypoint(
new URL("./test-fixtures/expand-tilde-worker.ts", import.meta.url),
);
const worker = new Worker(entry.entrypoint, {
eval: entry.eval,
execArgv: [],
workerData: "~/project",
});
const [expanded] = await once(worker, "message");
expect(expanded).toBe(expandTilde("~/project"));
});
});
@@ -1,16 +0,0 @@
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
export interface WorkerEntrypoint {
entrypoint: string | URL;
eval: boolean;
}
export function resolveWorkerEntrypoint(workerUrl: URL): WorkerEntrypoint {
if (!workerUrl.pathname.endsWith(".ts")) return { entrypoint: workerUrl, eval: false };
const tsxUrl = pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href;
return {
entrypoint: `import(${JSON.stringify(tsxUrl)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });`,
eval: true,
};
}
+1 -7
View File
@@ -186,13 +186,7 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
// A section's top-level directory and its category name the same thing, so
// only directories below it become collapsible groups.
if (
stripTopDirectory &&
directories[0] &&
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
) {
directories.shift();
}
if (stripTopDirectory) directories.shift();
let current = nodes;
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
@@ -2,25 +2,6 @@ import { describe, expect, it } from "vitest";
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
describe("plugin documentation", () => {
it.each(["en", "zh-CN"] as const)(
"nests local file copying under Git worktrees in %s",
(locale) => {
const workspaces = buildDocsNavTree(getDocs(locale)).find(
(node) => node.type === "category" && node.label === "Workspaces",
);
expect(workspaces).toMatchObject({
children: expect.arrayContaining([
expect.objectContaining({
type: "group",
href: "/docs/worktrees",
children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })],
}),
]),
});
expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined();
},
);
it("groups current guides by task without version navigation", () => {
const plugins = buildDocsNavTree(getDocs("en")).find(
(node) => node.type === "category" && node.label === "Plugins",
+1 -1
View File
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
"%s makes schedule run retention discoverable",
async (file) => {
const markdown = await readFile(path.join(docsRoot, file), "utf8");
@@ -84,7 +84,16 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
## Copy local files with .worktreeinclude
Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees:
```gitignore
.env
config/*.local.json
```
Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation.
The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere.
## paseo.json
@@ -82,7 +82,16 @@ paseo workspace create \
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree:
```gitignore
.env
config/*.local.json
```
模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。
内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。
## paseo.json {#paseojson}
-61
View File
@@ -1,61 +0,0 @@
---
title: .worktreeinclude
description: Copy gitignored local files into new Paseo worktrees.
nav: .worktreeinclude
order: 12
category: Workspaces
---
# .worktreeinclude
Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start.
## Copy your local configuration
For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`:
```gitignore
.env
config/*.local.json
```
When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git.
Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from.
## Pattern syntax
Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match:
```gitignore
# Local environment and configuration
.env
config/*.local.json
!config/production.local.json
```
A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded.
## When files are copied
The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run.
- Without `.worktreeinclude`, the plugin copies nothing.
- Matching files must exist in the source checkout.
- Existing destination files are skipped, preserving their contents.
- Copies happen during creation, with no ongoing synchronization between checkouts.
- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files.
## Run commands during setup
Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets:
```json
{
"worktree": {
"setup": "npm ci"
}
}
```
Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs.
@@ -1,61 +0,0 @@
---
title: .worktreeinclude
description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。
nav: .worktreeinclude
order: 12
category: Workspaces
---
# .worktreeinclude
在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。
## 复制本地配置 {#copy-your-local-configuration}
如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`:
```gitignore
.env
config/*.local.json
```
通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。
提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。
## 模式语法 {#pattern-syntax}
使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项:
```gitignore
# Local environment and configuration
.env
config/*.local.json
!config/production.local.json
```
模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。
## 文件复制时机 {#when-files-are-copied}
内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。
- 没有 `.worktreeinclude` 时,插件不会复制任何文件。
- 匹配的文件必须存在于源检出中。
- 目标中已存在的文件会被跳过,保留原有内容。
- 复制发生在创建期间,之后不会在检出之间持续同步。
- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。
## 在 setup 期间运行命令 {#run-commands-during-setup}
使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥:
```json
{
"worktree": {
"setup": "npm ci"
}
}
```
将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。
+1 -51
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -679,53 +679,3 @@ test("macOS jobs use latest runners while preserving release architectures", ()
// GitHub has no standard macos-latest-intel alias; use the newest Intel image.
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
});
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
assert.ok(match, "the release workflow repairs the pinned signing tool");
const script = match[1].replace(/^ /gm, "");
const original = [
"return await importCerts(keychainFile, certPaths, cscPasswords);",
"async function importCerts(keychainFile, paths, keyPasswords) {",
'["import", paths[i], "-P", password]',
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
"}",
].join("\n");
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
try {
const packageDirectory = join(directory, "node_modules/app-builder-lib");
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
const manifest = join(packageDirectory, "package.json");
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
for (const [version, source, succeeds] of [
["26.8.1", original, true],
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
["26.18.0", original, false],
]) {
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
writeFileSync(sourcePath, source);
const result = spawnSync(process.execPath, ["-e", script], {
cwd: directory,
encoding: "utf8",
});
const actual = readFileSync(sourcePath, "utf8");
if (succeeds) {
assert.equal(result.status, 0, result.stderr);
assert.ok(
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
);
assert.ok(
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
);
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
} else {
assert.notEqual(result.status, 0);
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
}
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+2 -40
View File
@@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { cp, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
@@ -10,12 +9,6 @@ import { promisify } from "node:util";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bridgeModule = "packages/server/dist/server/server/agent/providers/opencode/bridge.js";
const claudeDist = "packages/server/dist/server/server/agent/providers/claude";
const workerThreadEntrypoints = [
"packages/server/dist/server/server/plugins/compiler-worker.js",
`${claudeDist}/rewind-worker.js`,
`${claudeDist}/rewind-session-store.js`,
];
// Mirrors nix/package.nix's installPhase: copy every traced path into $out.
async function installTracedDaemon(outRoot) {
@@ -24,14 +17,12 @@ async function installTracedDaemon(outRoot) {
[path.join(repoRoot, "scripts/trace-daemon.mjs")],
{ cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 },
);
const files = stdout.split("\n").filter(Boolean);
for (const file of files) {
for (const file of stdout.split("\n").filter(Boolean)) {
await cp(path.join(repoRoot, file), path.join(outRoot, file), {
recursive: true,
verbatimSymlinks: true,
});
}
return files;
}
test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode version", async () => {
@@ -53,32 +44,3 @@ test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode
await rm(outRoot, { recursive: true, force: true });
}
});
test("traced daemon closure ships the worker-thread entrypoints and what they load", async () => {
const outRoot = await mkdtemp(path.join(os.tmpdir(), "trace-daemon-dist-"));
try {
const files = await installTracedDaemon(outRoot);
for (const file of workerThreadEntrypoints) {
assert.ok(files.includes(file), `${file} is missing from the traced closure`);
}
const { realClaudeRewindSdk } = await import(
pathToFileURL(path.join(outRoot, claudeDist, "rewind.js")).href
);
const sessionId = randomUUID();
const user = randomUUID();
const transcriptPath = path.join(outRoot, `${sessionId}.jsonl`);
await writeFile(
transcriptPath,
`${JSON.stringify({ type: "user", uuid: user, parentUuid: null, sessionId, message: { role: "user", content: "first" } })}\n`,
);
const fork = await realClaudeRewindSdk.forkSession(sessionId, {
upToMessageId: user,
transcriptPath,
});
const forked = await readFile(path.join(outRoot, `${fork.sessionId}.jsonl`), "utf8");
assert.equal(JSON.parse(forked.split("\n")[0]).forkedFrom.messageUuid, user);
} finally {
await rm(outRoot, { recursive: true, force: true });
}
});