Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andGeorge 59e63fbb5e test(muse): exercise child history replay without a live setup turn
Seed a historical child after closing an empty session so the replay
regression reaches resume even when live subagent projection is broken.
Both paths now fail independently with the reported negotiation error.

Co-authored-by: George <90179633+georgepullen@users.noreply.github.com>
2026-10-28 11:41:11 +00:00
George 51e844b540 test(plugins): launch fake muse through the provider command override 2026-10-28 11:38:22 +00:00
George 367a9d908b test(plugins): cover muse subsession negotiation through PluginRuntime 2026-10-28 11:38:22 +00:00
George eef309ec06 docs(plugins): require session.subsession negotiation before opening a child 2026-10-28 11:38:22 +00:00
George d75f60ebcc test(muse-provider): reach the subsession gate with a child-bearing fixture 2026-10-28 11:38:22 +00:00
George 6a4c05ce82 fix(muse-provider): negotiate session.subsession and gate child projection 2026-10-28 11:38:21 +00:00
39 changed files with 284 additions and 1562 deletions
-1
View File
@@ -11,7 +11,6 @@ Voice settings now let you configure speech providers and download local models
### Added
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
-6
View File
@@ -447,12 +447,6 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
ownership checks prefer the marker-verified worktree root over the legacy
path shape: a plugin can place a worktree several directories below the default worktrees root.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
-18
View File
@@ -255,24 +255,6 @@ describe("lifecycle hook SDK types", () => {
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
// @ts-expect-error Branch checkout has no branch-off base.
request.baseRef;
} else {
const base: string = request.baseRef;
}
const branch: string = request.branch;
// @ts-expect-error Repository identity is read-only.
request.repoRoot = "/other";
// @ts-expect-error Source checkout identity is read-only.
request.sourceCheckout = "/other";
if (request.kind === "checkout-change-request") {
const number: number = request.changeRequestNumber;
}
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
});
server.addHook("worktree.create.before", async () => {});
server.addHook("worktree.create.after", async ({ worktree }) => {
const path: string = worktree.path;
const branch: string = worktree.branch;
-1
View File
@@ -21,7 +21,6 @@ export type {
PluginHookContext,
PluginHookWorkspace,
PluginHookWorktree,
PluginWorktreeCreateRequest,
PluginHookAgent,
PluginSessionOpenRequest,
PluginTurnOutcome,
-36
View File
@@ -27,40 +27,6 @@ export interface PluginHookWorktree {
sourceCheckout: string;
}
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
export type PluginWorktreeCreateRequest = {
readonly repoRoot: string;
readonly sourceCheckout: string;
branch: string;
slug: string;
path: string;
} & (
| { kind: "branch-off"; baseRef: string }
| { kind: "checkout-branch" }
| {
kind: "checkout-change-request";
baseRef: string;
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
baseRef: string;
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
);
export interface PluginHookAgent {
id: string;
workspaceId: string | null;
@@ -115,7 +81,6 @@ export interface PluginHookEvents {
}
export interface PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
@@ -175,7 +140,6 @@ export const pluginHookRegistry = {
"workspace.create.before": { waits: true },
"workspace.create.after": { waits: false },
"workspace.archive.after": { waits: false },
"worktree.create.before": { waits: true },
"worktree.create.after": { waits: true },
"agent.create.before": { waits: true },
"agent.create.after": { waits: false },
@@ -60,7 +60,7 @@ export interface AttemptFirstAgentBranchAutoNameResult {
}
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
lifecycle?: Pick<PluginLifecycle, "wait" | "before">;
lifecycle?: Pick<PluginLifecycle, "wait">;
workspaceGitService: WorkspaceGitService;
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
}
@@ -463,7 +463,7 @@ ${options.hooks ?? ""}
test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-"));
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const pluginDirectory = await createProviderPlugin(root);
const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true });
@@ -507,7 +507,7 @@ test("a clean daemon shutdown leaves a completed plugin-provider agent without a
test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-"));
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const pluginDirectory = await createProviderPlugin(root);
const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true });
@@ -556,7 +556,7 @@ test("reloading a plugin leaves a completed plugin-provider agent without an err
test("a provider that never acknowledges session.close does not hold the daemon open", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-"));
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false });
const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true });
@@ -752,36 +752,9 @@ test.each([false, true])(
],
{ cwd: repository, stdio: "pipe" },
);
execFileSync("git", ["checkout", "-b", "plugin-base"], { cwd: repository, stdio: "pipe" });
await writeFile(path.join(repository, "from-plugin-base.txt"), "base");
execFileSync("git", ["add", "."], { cwd: repository, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"plugin base",
],
{ cwd: repository, stdio: "pipe" },
);
execFileSync("git", ["checkout", "main"], { cwd: repository, stdio: "pipe" });
const orderFile = path.join(root, "order.jsonl");
const pluginDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
hooks: `
server.addHook("worktree.create.before", async ({ request }) => {
const { appendFile } = await import("node:fs/promises");
await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.before", request }) + "\\n");
return { ...request, path: ${JSON.stringify(path.join(root, "elsewhere"))} + "/" + request.slug,
branch: request.branch === "accept-hook" ? "plugin-branch" : request.branch,
baseRef: request.branch === "accept-hook" ? "plugin-base" : request.baseRef };
});
server.addHook("worktree.create.after", async ({ worktree }) => {
const { appendFile, writeFile } = await import("node:fs/promises");
await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n");
@@ -805,11 +778,8 @@ test.each([false, true])(
return request;
});`,
});
let daemon = await createTestPaseoDaemon({
paseoHomeRoot: path.join(root, "state"),
cleanup: false,
});
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -835,23 +805,6 @@ test.each([false, true])(
});
expect(created.error).toBeNull();
const cwd = created.workspace!.workspaceDirectory;
expect(created.workspace!.gitRuntime?.currentBranch).toBe("plugin-branch");
const persisted = JSON.parse(
await readFile(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
);
expect(persisted).toContainEqual(
expect.objectContaining({
workspaceId: created.workspace!.id,
branch: "plugin-branch",
baseBranch: "refs/heads/plugin-base",
worktreeRoot: cwd,
}),
);
expect(cwd).toBe(path.join(await realpath(root), "elsewhere", "accept-hook"));
expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base");
expect(
execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim(),
).toBe("plugin-branch");
expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready");
const firstAgent = withFirstAgentContext
? created.agent!
@@ -882,40 +835,18 @@ test.each([false, true])(
.split("\n")
.map((line) => JSON.parse(line));
expect(records.map((record) => record.hook)).toEqual([
"worktree.create.before",
"worktree.create.after",
"workspace.create.after",
"agent.create.before",
]);
expect({
...records[1].worktree,
sourceCheckout: await realpath(records[1].worktree.sourceCheckout),
...records[0].worktree,
sourceCheckout: await realpath(records[0].worktree.sourceCheckout),
}).toEqual({
path: cwd,
branch: "plugin-branch",
branch: "accept-hook",
sourceCheckout: await realpath(repository),
});
expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain(
created.workspace!.id,
);
await client.archiveWorkspace(created.workspace!.id);
await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
const hookRecordsBeforeRestore = await readFile(orderFile, "utf8");
await client.close();
await daemon.close();
daemon = await createTestPaseoDaemon({
paseoHomeRoot: path.join(root, "state"),
cleanup: false,
pluginsEnabled: true,
plugins: {
"shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true },
},
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.restoreWorkspace(created.workspace!.id);
expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base");
expect(await readFile(orderFile, "utf8")).toBe(hookRecordsBeforeRestore);
const beforeFailure = await client.fetchWorkspaces();
const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repository,
@@ -951,176 +882,12 @@ test.each([false, true])(
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
await rm(root, { recursive: true, force: true });
}
},
60_000,
);
test("worktree before hook rejects unsafe destinations with plugin attribution before creating branches", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-before-invalid-"));
const repository = path.join(root, "repository");
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repository, stdio: "pipe" },
);
const occupied = path.join(root, "occupied");
await mkdir(occupied);
const destinations = [
occupied,
"relative/path",
repository,
path.join(repository, ".git", "worktree"),
root,
path.parse(root).root,
(await import("node:os")).homedir(),
];
const pluginDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
hooks: `
server.addHook("worktree.create.before", async ({ request }) => {
if (request.branch === "throws") throw new Error("creation denied");
if (request.branch === "readonly") return { ...request, repoRoot: "/different-repository" };
return { ...request, path: ${JSON.stringify(destinations)}[Number(request.branch.slice(4))] };
});`,
});
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pluginDirectory);
for (const branchName of [
...destinations.map((_, index) => `bad-${index}`),
"throws",
"readonly",
]) {
const created = await client.createWorkspace({
source: { kind: "worktree", cwd: repository, branchName, worktreeSlug: branchName },
});
expect(created.workspace).toBeNull();
expect(created.error).toContain(
"Plugin shutdown-provider-plugin worktree.create.before failed:",
);
expect(
execFileSync("git", ["branch", "--format=%(refname:short)"], {
cwd: repository,
encoding: "utf8",
}).trim(),
).toBe("main");
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repository,
encoding: "utf8",
}).match(/^worktree /gm),
).toHaveLength(1);
}
expect((await client.fetchWorkspaces()).entries).toEqual([]);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("the placement example creates a sibling worktree, opens its first agent there, lists and archives it", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-placement-example-"));
const repository = path.join(root, "repository");
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repository, stdio: "pipe" },
);
const providerDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
reportOpen: true,
});
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(providerDirectory);
const installed = await client.installDirectoryPlugin(
fileURLToPath(new URL("../../../../../plugin-examples/worktree-placement", import.meta.url)),
);
expect(installed.status).toBe("running");
const created = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
branchName: "feature/x",
worktreeSlug: "feature-x",
},
});
expect(created.error).toBeNull();
const cwd = path.join(await realpath(root), "repository.feature-x");
expect(created.workspace!.workspaceDirectory).toBe(cwd);
expect((await stat(cwd)).isDirectory()).toBe(true);
expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain(
created.workspace!.id,
);
const worktrees = await client.getPaseoWorktreeList({ cwd: repository });
expect(worktrees.error).toBeNull();
expect(worktrees.worktrees).toContainEqual(
expect.objectContaining({ worktreePath: cwd, branchName: "feature/x" }),
);
const collision = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
branchName: "feature/x",
worktreeSlug: "another-slug",
},
});
expect(collision.workspace).toBeNull();
expect(collision.error).toContain("Plugin worktree-placement worktree.create.before failed:");
expect(collision.error).toContain("already exists");
const agent = await client.createAgent({
provider: PROVIDER_ID,
cwd,
workspaceId: created.workspace!.id,
});
expect(agent.cwd).toBe(cwd);
const logs = await client.getPluginLogs("shutdown-provider-plugin");
const opened = logs
.filter((entry) => entry.message.startsWith('{"hook":"provider.open"'))
.map((entry) => JSON.parse(entry.message));
expect(opened.map((entry) => entry.config.cwd)).toContain(cwd);
await client.archiveWorkspace(created.workspace!.id);
await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("worktree includes reach the first agent without copying tracked or unignored files", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-include-"));
const repository = path.join(root, "repository");
@@ -1304,6 +1071,6 @@ test("worktree includes reach the first agent without copying tracked or unignor
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
await rm(root, { recursive: true, force: true });
}
}, 60_000);
@@ -1,7 +1,7 @@
import { expect, test } from "vitest";
import { createPaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { validateBeforeResult, PluginHookHandlers } from "./index.js";
import { PluginHookHandlers } from "./index.js";
const paseo = createPaseoApi(
new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }),
@@ -239,17 +239,3 @@ test("a waiting after hook propagates failures and stops later handlers", async
).rejects.toThrow("cannot initialize worktree");
expect(received).toEqual(["/worktree"]);
});
test("checkout-branch hooks reject a branch-off baseRef", async () => {
const request = {
kind: "checkout-branch" as const,
repoRoot: "/repo",
sourceCheckout: "/repo",
branch: "feature",
slug: "feature",
path: "/work/feature",
};
await expect(
validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }),
).rejects.toThrow(/baseRef/);
});
@@ -1,4 +1,3 @@
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import {
createLifecycleRegistration,
pluginHookRegistry,
@@ -38,56 +37,7 @@ export type PluginBeforeResults = PluginWireBeforeRequests & {
"agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
};
const worktreeRequestFields = {
repoRoot: z.string(),
sourceCheckout: z.string(),
branch: z.string().min(1),
slug: z.string().refine((value) => validateBranchSlug(value).valid, "Invalid worktree slug"),
path: z.string().min(1),
};
const changeRequestFields = {
headRef: z.string().min(1),
headRepositoryOwner: z.string().optional(),
checkoutRefs: z
.array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() }))
.optional(),
pushRemoteUrl: z.string().optional(),
trackOriginHead: z.boolean().optional(),
};
const worktreeRequestSchema = z.discriminatedUnion("kind", [
z
.object({ ...worktreeRequestFields, kind: z.literal("branch-off"), baseRef: z.string().min(1) })
.strict(),
z
.object({
...worktreeRequestFields,
kind: z.literal("checkout-branch"),
})
.strict(),
z
.object({
...worktreeRequestFields,
...changeRequestFields,
kind: z.literal("checkout-change-request"),
baseRef: z.string().min(1),
forge: z.string(),
changeRequestNumber: z.number().int().positive(),
headRepository: z.string().optional(),
})
.strict(),
z
.object({
...worktreeRequestFields,
...changeRequestFields,
kind: z.literal("checkout-github-pr"),
baseRef: z.string().min(1),
githubPrNumber: z.number().int().positive(),
})
.strict(),
]);
const beforeSchemas = {
"worktree.create.before": worktreeRequestSchema,
"agent.create.before": CreateAgentRequestMessageSchema.pick({
config: true,
env: true,
@@ -212,11 +162,11 @@ export function publishAgentStream(
}
}
export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name,
input: PluginWireBeforeRequests[Name],
output: unknown,
): Promise<PluginWireBeforeRequests[Name]> {
): PluginWireBeforeRequests[Name] {
const result = validateBeforeRequest(name, output);
if (name === "agent.open.before") {
const previous = beforeSchemas["agent.open.before"].parse(input);
@@ -241,13 +191,6 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
throw new Error("agent.create.before hooks cannot change the workspace directory");
}
}
if (name === "worktree.create.before") {
const previous = beforeSchemas["worktree.create.before"].parse(input);
const next = beforeSchemas["worktree.create.before"].parse(result);
if (previous.repoRoot !== next.repoRoot || previous.sourceCheckout !== next.sourceCheckout) {
throw new Error("worktree.create.before hooks cannot change repoRoot or sourceCheckout");
}
}
return result;
}
@@ -301,7 +244,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
{ request: structuredClone(request) },
{ paseo, signal: controller.signal },
);
if (result !== undefined) request = await validateBeforeResult(hookName, request, result);
if (result !== undefined) request = validateBeforeResult(hookName, request, result);
}
return request;
}
@@ -340,10 +283,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
if (result === undefined) continue;
const local = sessionOpenLocalResultSchema.parse(result);
const metadata = local.tools.map(serializeAgentTool);
request = await validateBeforeResult("agent.open.before", request, {
...local,
tools: metadata,
});
request = validateBeforeResult("agent.open.before", request, { ...local, tools: metadata });
tools = local.tools;
}
context.signal.throwIfAborted();
@@ -358,6 +358,125 @@ register(${JSON.stringify(guardUrl)});`;
await adapters.shutdown();
await runtime.stopAll();
});
// The muse provider runs against a fake MSP host (injected through the
// provider's command override), but everything above it is real: the plugin
// subprocess, PluginRuntime's capability enforcement, and the
// PluginAgentClientRegistry path an agent actually uses. On a muse build
// that does not negotiate `session.subsession`, the daemon fails the whole
// provider connection when the child is projected, so both tests fail
// before the fix.
async function startMuseClient(extraEnv: Record<string, string>) {
const fakeMuse = fileURLToPath(
new URL("../../../../../plugins/muse-provider/test/fake-muse.mjs", import.meta.url),
);
const directory = fileURLToPath(
new URL("../../../../../plugins/muse-provider/", import.meta.url),
);
const runtime = createTestRuntime({}, undefined, "0.11.0");
await runtime.startPlugin("muse-provider", directory);
const [metadata] = runtime.getProviderRegistrations("muse-provider");
expect(metadata).toBeDefined();
const adapters = new PluginAgentClientRegistry(pino({ level: "silent" }));
adapters.replace([
{
...adaptRuntimeProvider(runtime, "muse-provider", metadata!),
command: metadata!.command,
},
]);
const client = adapters.definitions()[metadata!.id]!.createClient(pino({ level: "silent" }), {
command: { mode: "replace", argv: [process.execPath, fakeMuse] },
env: { MUSE_TEST_SCENARIO: "subagent", MUSE_TEST_CHILD: "tool", ...extraEnv },
});
return { runtime, adapters, client };
}
function hasSettledTurn(events: readonly AgentStreamEvent[]): boolean {
return events.some((event) => event.type === "turn_completed" || event.type === "turn_failed");
}
// Asserting on the settled turn (rather than polling only for completion)
// puts the provider's failure — e.g. "Provider parent session did not
// negotiate session.subsession" — in the test output.
async function expectTurnCompleted(events: readonly AgentStreamEvent[]): Promise<void> {
await expect.poll(() => hasSettledTurn(events)).toBe(true);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(hasCompletedAgentTurn(events)).toBe(true);
}
function hasSubagentTimeline(events: readonly AgentStreamEvent[]): boolean {
return events.some(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "assistant_message" &&
event.event.item.text.includes("SUBAGENT_PHASE0_OK"),
);
}
it("projects a muse subagent through PluginRuntime without failing the connection", async () => {
const { runtime, adapters, client } = await startMuseClient({});
try {
const events: AgentStreamEvent[] = [];
const session = await client.createSession({ provider: "muse", cwd: process.cwd() });
session.subscribe((event) => events.push(event));
await session.startTurn("dispatch a subagent", { clientMessageId: "muse-live" });
await expectTurnCompleted(events);
expect(hasSubagentTimeline(events)).toBe(true);
// The connection survives: a fresh session on the same provider opens
// and completes a turn instead of hitting a dead cached connection.
const second = await client.createSession({ provider: "muse", cwd: process.cwd() });
const secondEvents: AgentStreamEvent[] = [];
second.subscribe((event) => secondEvents.push(event));
await second.startTurn("again", { clientMessageId: "muse-live-2" });
await expectTurnCompleted(secondEvents);
await second.close();
await session.close();
} finally {
await adapters.shutdown();
await runtime.stopAll();
}
});
it("replays a muse subagent from history on resume without failing the connection", async () => {
const historyDirectory = await mkdtemp(path.join(tmpdir(), "paseo-muse-history-"));
temporaryDirectories.push(historyDirectory);
const { runtime, adapters, client } = await startMuseClient({
MUSE_TEST_HISTORY: path.join(historyDirectory, "native-history.ndjson"),
});
try {
const first = await client.createSession({ provider: "muse", cwd: process.cwd() });
const persistence = first.describePersistence();
expect(persistence).not.toBeNull();
await first.close();
// Seed old history without running a live subagent first, so this test
// reaches resume even when live child projection is broken.
await writeFile(
path.join(historyDirectory, "native-history.ndjson"),
JSON.stringify({
itemId: "historical-subagent",
revision: 1,
kind: "toolCall",
status: "completed",
tool: "subagent_spawn",
childSessionId: "fixture-child",
}) + "\n",
);
const resumed = await client.resumeSession(persistence!, { cwd: process.cwd() });
const resumedEvents: AgentStreamEvent[] = [];
resumed.subscribe((event) => resumedEvents.push(event));
// History replayed during resume is exposed through streamHistory.
const replayed: AgentStreamEvent[] = [];
for await (const event of resumed.streamHistory()) replayed.push(event);
expect(hasSubagentTimeline(replayed)).toBe(true);
await resumed.startTurn("continue", { clientMessageId: "muse-resumed" });
await expectTurnCompleted(resumedEvents);
await resumed.close();
} finally {
await adapters.shutdown();
await runtime.stopAll();
}
});
it("runs a provider connection through the real plugin subprocess boundary", async () => {
const directory = await createPlugin(
@@ -1,4 +1,3 @@
import { validateWorktreePath } from "../../utils/worktree-path.js";
import type { PluginHookPayload } from "@getpaseo/plugin/server";
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
@@ -583,14 +582,7 @@ export class PluginRuntime {
name,
input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request,
});
request = await validateBeforeResult(name, request, output);
if (name === "worktree.create.before") {
const worktree = request as PluginWireBeforeRequests["worktree.create.before"];
worktree.path = await validateWorktreePath({
candidate: worktree.path,
repoPath: worktree.repoRoot,
});
}
request = validateBeforeResult(name, request, output);
if (name === "agent.open.before") {
const opened = request as SessionOpenWireRequest;
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
@@ -5991,9 +5991,6 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t
projectId,
cwd: worktree.worktreePath,
kind: "worktree",
worktreeRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
isPaseoOwnedWorktree: true,
displayName: "worktree-kind-archive",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
@@ -4,7 +4,6 @@ import {
mkdirSync,
mkdtempSync,
realpathSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
@@ -477,43 +476,3 @@ test("restore rejects a branch checked out elsewhere without inventing another b
}).trim(),
).toBe("* feature");
});
test("restores a user-created worktree at its recorded path outside Paseo's root", async () => {
const { tempDir, repoDir } = createGitRepository();
const worktreeRoot = join(realpathSync(tempDir), "external-worktree");
execFileSync("git", ["worktree", "add", "-b", "external-feature", worktreeRoot], {
cwd: repoDir,
stdio: "pipe",
});
const workspace = createWorkspace({
cwd: worktreeRoot,
worktreeRoot,
mainRepoRoot: repoDir,
branch: "external-feature",
baseBranch: "main",
isPaseoOwnedWorktree: false,
});
execFileSync("git", ["worktree", "remove", worktreeRoot], { cwd: repoDir, stdio: "pipe" });
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome: join(tempDir, "paseo-home"),
getWorkspace: async () => workspace,
getProject: async () => createProject({ rootPath: repoDir }),
isDirectory: async (target) => existsSync(target) && statSync(target).isDirectory(),
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "restore",
});
expect(readFileSync(join(worktreeRoot, "README.md"), "utf8").trim()).toBe("initial");
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: worktreeRoot,
encoding: "utf8",
}).trim(),
).toBe("external-feature");
expect(unarchived).toEqual([workspace.workspaceId]);
});
@@ -166,7 +166,6 @@ export function createWorkspaceRecoveryService(deps: {
}
let previousWorktreePath = workspace.worktreeRoot;
let hasRecordedWorktreePath = Boolean(previousWorktreePath);
if (!previousWorktreePath) {
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
// lack durable backing placement; remove filesystem discovery after 2027-01-17.
@@ -177,7 +176,6 @@ export function createWorkspaceRecoveryService(deps: {
previousWorktreePath = ownership.allowed
? (ownership.worktreePath ?? workspace.cwd)
: workspace.cwd;
hasRecordedWorktreePath = ownership.allowed;
}
let recreatedWorktreePath: string;
@@ -189,7 +187,6 @@ export function createWorkspaceRecoveryService(deps: {
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
...(hasRecordedWorktreePath ? { worktreePath: previousWorktreePath } : {}),
});
recreatedWorktreePath = result.worktreePath;
} catch (error) {
@@ -7,9 +7,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import type { ForgeService } from "../services/forge-service.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
import { getPaseoWorktreeMetadataPath } from "../utils/worktree-metadata.js";
import { archiveCommand } from "./worktree/commands.js";
import { createWorktree, listPaseoWorktrees, type WorktreeConfig } from "../utils/worktree.js";
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
@@ -192,54 +190,6 @@ function assertArchiveResult(
}
describe("archiveByScope", () => {
test.each(["workspace", "worktree"] as const)(
"%s archive preserves a restored external worktree despite its marker",
async (kind) => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const recordedPath = path.join(tempDir, "external");
execFileSync("git", ["worktree", "add", "-b", "external", recordedPath], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["worktree", "remove", recordedPath], { cwd: repoDir, stdio: "pipe" });
const restored = await createWorktree({
cwd: repoDir,
paseoHome,
worktreeSlug: "external",
worktreePath: recordedPath,
source: { kind: "restore", branchName: "external", baseRef: "main" },
});
expect(existsSync(await getPaseoWorktreeMetadataPath(restored.worktreePath))).toBe(true);
const workspaceId = "external-workspace";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: restored.worktreePath,
kind: "worktree",
worktreeRoot: restored.worktreePath,
mainRepoRoot: repoDir,
isPaseoOwnedWorktree: false,
},
],
}),
{
scope:
kind === "workspace"
? { kind, workspaceId }
: { kind, targetPath: restored.worktreePath },
requestId: "archive-external",
},
);
expect(result.removedDirectory).toBe(false);
expect(existsSync(restored.worktreePath)).toBe(true);
expect(result.archivedWorkspaceIds).toEqual([workspaceId]);
},
);
test("workspace scope archives the record and removes the directory on last reference", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
@@ -270,120 +220,6 @@ describe("archiveByScope", () => {
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("workspace scope archives the record but leaves a plugin-placed directory whose marker is gone", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createWorktree({
cwd: repoDir,
worktreeSlug: "unproven-workspace",
source: { kind: "branch-off", baseBranch: "main", branchName: "unproven-workspace" },
paseoHome,
worktreePath: path.join(tempDir, "placed-worktree"),
});
// Remove the ownership marker: no proof remains for this path.
rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true });
const workspaceId = "ws-unproven";
const logs: Array<{ targetPath: string; msg: string }> = [];
const dependencies = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
dependencies.sessionLogger = pino(
{ level: "warn" },
{
write(message) {
logs.push(JSON.parse(message));
},
},
);
const result = await archiveByScope(dependencies, {
scope: { kind: "workspace", workspaceId },
requestId: "req-unproven-workspace",
});
assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: false });
expect(result.directoryLeftBehind?.path).toBe(worktree.worktreePath);
expect(result.directoryLeftBehind?.reason).toContain("no ownership proof");
expect(existsSync(worktree.worktreePath)).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({
targetPath: worktree.worktreePath,
msg: expect.stringContaining("no ownership proof"),
}),
);
});
test("archives a plugin-placed worktree by its discovered directory name", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = path.join(tempDir, "sibling-feature");
await createWorktree({
cwd: repoDir,
paseoHome,
worktreePath,
worktreeSlug: "original-slug",
source: { kind: "branch-off", branchName: "feature", baseBranch: "main" },
});
const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] });
const result = await archiveCommand(
{
...dependencies,
workspaceGitService: {
...dependencies.workspaceGitService,
listWorktrees: (cwd) => listPaseoWorktrees({ cwd, paseoHome }),
},
},
{
repoRoot: repoDir,
worktreeSlug: "sibling-feature",
scope: "worktree",
requestId: "slug-archive",
},
);
expect(result).toEqual({ ok: true, removedAgents: [] });
expect(existsSync(worktreePath)).toBe(false);
});
test("workspace scope deletes a plugin-placed directory on registry proof even with the marker gone", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createWorktree({
cwd: repoDir,
worktreeSlug: "registry-proven-workspace",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "registry-proven-workspace",
},
paseoHome,
worktreePath: path.join(tempDir, "placed-worktree"),
});
rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true });
const workspaceId = "ws-registry-proven";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: worktree.worktreePath,
kind: "worktree",
isPaseoOwnedWorktree: true,
worktreeRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
},
],
}),
{ scope: { kind: "workspace", workspaceId }, requestId: "req-registry-proven-workspace" },
);
assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: true });
expect(result.directoryLeftBehind).toBeUndefined();
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => {
const { tempDir, repoDir } = createGitRepo();
writeFileSync(
@@ -69,10 +69,6 @@ export interface ArchiveResult {
archivedAgentIds: string[];
archivedWorkspaceIds: string[];
removedDirectory: boolean;
// Set when the record archived but its worktree directory was left on
// disk because no ownership proof held for it. The directory is never
// deleted in that case; the path and reason surface here and in the log.
directoryLeftBehind?: { path: string; reason: string };
}
export interface ArchiveByScopeRequest {
@@ -96,15 +92,6 @@ export async function requireActiveWorkspaceForArchive(
interface BackingDirectory {
path: string;
isPaseoOwnedWorktree: boolean;
// True when the directory backs a worktree workspace (or a worktree-scope
// archive), so a failed ownership proof means a Paseo worktree directory
// is being left behind rather than a user's local checkout being ignored.
expectsPaseoOwnership: boolean;
// True only when this backing came from a workspace registry record
// matched to this path (the record is itself an ownership proof, passed
// through to deletePaseoWorktree). Disk-derived backings stay false:
// they must prove ownership on disk.
registryProven: boolean;
mainRepoRoot: string | null;
paseoWorktreesRoot: string | null;
}
@@ -154,7 +141,6 @@ async function archiveByScopeWithPriority(
}
let removedDirectory = false;
let directoryLeftBehind: ArchiveResult["directoryLeftBehind"];
try {
if (targetWorkspaceIds.length > 0) {
@@ -182,21 +168,18 @@ async function archiveByScopeWithPriority(
}
if (target.backing !== null) {
const removal = await maybeRemoveDirectory(
removedDirectory = await maybeRemoveDirectory(
dependencies,
request,
target,
archivedWorkspaceIds,
);
removedDirectory = removal.removedDirectory;
directoryLeftBehind = removal.directoryLeftBehind;
}
return {
archivedAgentIds: Array.from(archivedAgents),
archivedWorkspaceIds,
removedDirectory,
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
};
} finally {
if (targetWorkspaceIds.length > 0) {
@@ -243,16 +226,14 @@ async function resolveArchiveTarget(
}),
)
).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null);
const recordedId = await dependencies.findWorkspaceIdForCwd(backing.path);
const recorded = recordedId ? await dependencies.getWorkspace?.(recordedId) : null;
const record =
targetWorkspaces.find((workspace) => workspace.kind === "worktree") ??
(recorded && matchesBackingDirectory(recorded.worktreeRoot ?? recorded.cwd)
? recorded
: null) ??
targetWorkspaces[0];
const persistedMainRepoRoot = targetWorkspaces.find(
(workspace) => workspace.mainRepoRoot,
)?.mainRepoRoot;
return {
backing: record ? await resolveWorkspaceBackingDirectory(record, dependencies) : backing,
backing: {
...backing,
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
},
teardownTargets:
targetWorkspaces.length > 0
? targetWorkspaces.map((workspace) => ({
@@ -290,22 +271,10 @@ async function resolveWorkspaceBackingDirectory(
workspace: ActiveWorkspaceRef,
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
): Promise<BackingDirectory> {
if (workspace.isPaseoOwnedWorktree === false) {
return {
path: resolve(workspace.worktreeRoot ?? workspace.cwd),
isPaseoOwnedWorktree: false,
expectsPaseoOwnership: false,
registryProven: false,
mainRepoRoot: workspace.mainRepoRoot ?? null,
paseoWorktreesRoot: null,
};
}
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) {
return {
path: resolve(workspace.worktreeRoot),
isPaseoOwnedWorktree: true,
expectsPaseoOwnership: true,
registryProven: true,
mainRepoRoot: workspace.mainRepoRoot,
paseoWorktreesRoot: null,
};
@@ -314,8 +283,6 @@ async function resolveWorkspaceBackingDirectory(
return {
path: resolve(workspace.cwd),
isPaseoOwnedWorktree: false,
expectsPaseoOwnership: false,
registryProven: false,
mainRepoRoot: workspace.mainRepoRoot ?? null,
paseoWorktreesRoot: null,
};
@@ -342,8 +309,6 @@ async function resolveBackingDirectory(
return {
path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd),
isPaseoOwnedWorktree: ownership.allowed,
expectsPaseoOwnership: true,
registryProven: false,
mainRepoRoot: ownership.repoRoot ?? null,
paseoWorktreesRoot: ownership.worktreeRoot ?? null,
};
@@ -387,28 +352,10 @@ async function maybeRemoveDirectory(
request: Pick<ArchiveByScopeRequest, "requestId">,
target: ArchiveTarget,
archivedWorkspaceIds: string[],
): Promise<{
removedDirectory: boolean;
directoryLeftBehind?: { path: string; reason: string };
}> {
): Promise<boolean> {
const backing = target.backing;
if (!backing?.isPaseoOwnedWorktree) {
// Missing ownership proof for a worktree-backed directory: the record
// still archives, but the directory is left in place and surfaced.
// Never delete an unproven directory.
if (backing?.expectsPaseoOwnership) {
const reason =
"no ownership proof (not under the Paseo worktrees root, no paseo/worktree.json marker, no registry record)";
dependencies.sessionLogger?.warn(
{ targetPath: backing.path, requestId: request.requestId },
`Worktree directory left in place during archive: ${reason}`,
);
return {
removedDirectory: false,
directoryLeftBehind: { path: backing.path, reason },
};
}
return { removedDirectory: false };
return false;
}
const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds);
@@ -435,7 +382,7 @@ async function maybeRemoveDirectory(
{ err: error, targetPath: backing.path, requestId: request.requestId },
"Worktree teardown failed during archive; workspace already archived",
);
return { removedDirectory: false };
return false;
}
throw error;
}
@@ -449,7 +396,7 @@ async function maybeRemoveDirectory(
dependencies,
))
) {
return { removedDirectory: false };
return false;
}
try {
@@ -460,17 +407,16 @@ async function maybeRemoveDirectory(
worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
registryProven: backing.registryProven,
});
dependencies.workspaceGitService.invalidateWorktrees();
dependencies.github.invalidate({ cwd: backing.path });
return { removedDirectory: true };
return true;
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath: backing.path, requestId: request.requestId },
"Worktree disk removal failed during archive; workspace already archived",
);
return { removedDirectory: false };
return false;
}
}
@@ -24,7 +24,6 @@ import {
getPaseoWorktreeMetadataPath,
readPaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
import { validateBeforeRequest, validateBeforeResult } from "./plugins/lifecycle/index.js";
import { UnknownBranchError } from "../utils/worktree.js";
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
import { isPlatform } from "../test-utils/platform.js";
@@ -420,21 +419,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("keeps default placement suffixes when the default destination already exists", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const first = await createCoreWorktree(
{ cwd: repoDir, paseoHome, branchName: "first", worktreeSlug: "same" },
createCoreDeps(),
);
const second = await createCoreWorktree(
{ cwd: repoDir, paseoHome, branchName: "second", worktreeSlug: "same" },
createCoreDeps(),
);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath + "-1");
expect(existsSync(first.worktree.worktreePath)).toBe(true);
});
test("creates branch-off worktrees from origin main without tracking origin main", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginMain();
cleanupPaths.push(tempDir);
@@ -504,42 +488,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("round trips every resolved change-request field through an unchanged before-hook result", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const requests: unknown[] = [];
const result = await createCoreWorktree(
{ cwd: repoDir, githubPrNumber: 123, refName: "feature/review-pr", paseoHome },
{
...deps,
lifecycle: {
before: async (name, request) => {
requests.push(request);
return validateBeforeResult(name, validateBeforeRequest(name, request), request);
},
},
},
);
expect(requests).toEqual([
{
kind: "checkout-change-request",
forge: "github",
changeRequestNumber: 123,
headRef: "feature/review-pr",
baseRef: "main",
branch: "feature/review-pr",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }],
trackOriginHead: true,
repoRoot: repoDir,
sourceCheckout: repoDir,
slug: "feature-review-pr",
path: result.worktree.worktreePath,
},
]);
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("uses the PR head ref as the default slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
+2 -84
View File
@@ -1,11 +1,8 @@
import type { PluginWorktreeCreateRequest } from "@getpaseo/plugin/server";
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
import { createNameId } from "mnemonic-id";
import type { ForgeService } from "../services/forge-service.js";
import {
createWorktree,
resolveDefaultWorktreePath,
slugify,
validateBranchSlug,
type CreatedWorktree,
@@ -18,7 +15,7 @@ import {
} from "./resolve-worktree-creation-intent.js";
import type { ChangeRequestCheckoutSource, FirstAgentContext } from "@getpaseo/protocol/messages";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { runGitCommand, runWithGitCommandPriority } from "../utils/run-git-command.js";
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
export interface CreateWorktreeCoreInput {
cwd: string;
@@ -34,7 +31,6 @@ export interface CreateWorktreeCoreInput {
}
export interface CreateWorktreeCoreDeps {
lifecycle?: Pick<PluginLifecycle, "before">;
github: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
@@ -94,7 +90,7 @@ async function createWorktreeCoreWithPriority(
}
const forge = await resolveForge(repoRoot, deps, intentInput);
let intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
forge: forge.forge,
forgeService: forge.service,
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
@@ -118,33 +114,10 @@ async function createWorktreeCoreWithPriority(
}
}
const defaultPath = await resolveDefaultWorktreePath({
cwd: repoRoot,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
worktreeSlug: normalizedSlug,
});
const { stdout: sourceRoot } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd: input.cwd,
});
const sourceCheckout = sourceRoot.trim();
const request = describeCreationRequest(intent, {
repoRoot,
sourceCheckout,
slug: normalizedSlug,
path: defaultPath,
});
const transformed = deps.lifecycle
? await deps.lifecycle.before("worktree.create.before", request)
: request;
normalizedSlug = validateWorktreeSlug(transformed.slug);
intent = creationIntentFromRequest(transformed, intent);
return {
worktree: await createWorktree({
cwd: repoRoot,
worktreeSlug: normalizedSlug,
worktreePath: transformed.path,
source: intent,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
@@ -155,61 +128,6 @@ async function createWorktreeCoreWithPriority(
};
}
function describeCreationRequest(
intent: WorktreeCreationIntent,
placement: Pick<PluginWorktreeCreateRequest, "repoRoot" | "sourceCheckout" | "slug" | "path">,
): PluginWorktreeCreateRequest {
if (intent.kind === "branch-off")
return {
...placement,
kind: intent.kind,
branch: intent.branchName,
baseRef: intent.baseBranch,
};
if (intent.kind === "checkout-branch")
return { ...placement, kind: intent.kind, branch: intent.branchName };
const { localBranchName, baseRefName, ...facts } = intent;
return {
...placement,
...facts,
branch: localBranchName ?? intent.headRef,
baseRef: baseRefName,
};
}
function creationIntentFromRequest(
request: PluginWorktreeCreateRequest,
original: WorktreeCreationIntent,
): WorktreeCreationIntent {
if (request.kind === "branch-off") {
return { kind: request.kind, branchName: request.branch, baseBranch: request.baseRef };
}
if (request.kind === "checkout-branch") {
return {
kind: request.kind,
branchName: request.branch,
};
}
const {
repoRoot: _repoRoot,
sourceCheckout: _checkout,
slug: _slug,
path: _path,
branch,
baseRef,
...facts
} = request;
const keepLocalBranchName =
branch !== request.headRef ||
((original.kind === "checkout-change-request" || original.kind === "checkout-github-pr") &&
original.localBranchName !== undefined);
return {
...facts,
...(keepLocalBranchName ? { localBranchName: branch } : {}),
baseRefName: baseRef,
};
}
async function resolveForge(
repoRoot: string,
deps: CreateWorktreeCoreDeps,
@@ -1,6 +1,6 @@
import { basename } from "node:path";
import { join } from "node:path";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import {
archiveByScope,
resolveWorkspaceIdAtPath,
@@ -208,18 +208,10 @@ async function resolveWorktreeSlugPath(
repoRoot: string,
worktreeSlug: string,
): Promise<string> {
// Worktrees can live anywhere (legacy root or wherever
// a plugin placed them), so a slug resolves against the
// discovered worktree list for the repo, never a guessed path.
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
const match = worktrees.find((entry) => basename(entry.path) === worktreeSlug);
if (!match) {
const found = worktrees.map((entry) => basename(entry.path));
throw new Error(
found.length > 0
? `Paseo worktree not found for slug ${worktreeSlug}: found ${found.join(", ")}`
: `Paseo worktree not found for slug ${worktreeSlug}`,
);
}
return match.path;
const worktreesRoot = await getPaseoWorktreesRoot(
repoRoot,
dependencies.paseoHome,
dependencies.paseoWorktreesBaseRoot,
);
return join(worktreesRoot, worktreeSlug);
}
+1 -11
View File
@@ -45,8 +45,6 @@ import { unquoteGitPath } from "./git-quoted-path.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
branchNameFromRef,
findPaseoWorktreeRootForCwd,
hasPaseoWorktreeMetadata,
getPaseoWorktreeChangeRequestHintForBranch,
type PaseoWorktreeMetadata,
readPaseoWorktreeMetadata,
@@ -1128,17 +1126,9 @@ async function getMainRepoRootFromCommonDir(
},
);
const worktrees = parseWorktreeList(worktreeOut);
const paseoPaths = new Set(
(
await Promise.all(
worktrees.map(async (wt) => ((await hasPaseoWorktreeMetadata(wt.path)) ? wt.path : null)),
)
).filter((p) => p !== null),
);
const nonBareNonPaseo = worktrees.filter(
(wt) =>
!wt.isBare &&
!paseoPaths.has(wt.path) &&
!isPaseoWorktreePath(wt.path, {
paseoHome: context?.paseoHome,
worktreesRoot: context?.worktreesRoot,
@@ -1267,7 +1257,7 @@ async function getPaseoWorktreeForCwd(
options: PaseoWorktreeLookupOptions = {},
): Promise<PaseoWorktreeForCwd> {
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
if (!/[\\/]worktrees[\\/]/.test(cwd) && (await findPaseoWorktreeRootForCwd(cwd)) === null) {
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
return { isPaseoOwnedWorktree: false };
}
+1 -34
View File
@@ -2,7 +2,7 @@ import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import pLimit from "p-limit";
import { dirname, isAbsolute, join, resolve } from "path";
import { isAbsolute, join, resolve } from "path";
import { z } from "zod";
const MAX_WORKTREE_METADATA_BYTES = 64 * 1024;
@@ -329,39 +329,6 @@ export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
});
}
export async function hasPaseoWorktreeMetadata(worktreeRoot: string): Promise<boolean> {
try {
return (await readPaseoWorktreeMetadata(worktreeRoot)) !== null;
} catch {
return false;
}
}
/**
* Filesystem-only search for a Paseo worktree root at or above cwd: the
* first directory containing a .git entry wins, and it only counts when
* its git admin dir holds the paseo/worktree.json marker. Lets callers
* cheaply rule out plain directories before running git ownership checks.
*/
export async function findPaseoWorktreeRootForCwd(cwd: string): Promise<string | null> {
let current = resolve(cwd);
for (;;) {
if (
await stat(join(current, ".git")).then(
() => true,
() => false,
)
) {
return (await hasPaseoWorktreeMetadata(current)) ? current : null;
}
const parent = dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
export async function readPaseoWorktreeMetadata(
worktreeRoot: string,
): Promise<PaseoWorktreeMetadata | null> {
@@ -1,88 +0,0 @@
import { realpath, lstat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
import { getRelativePathInsideRoot } from "./path.js";
async function normalizeAbsolute(path: string): Promise<string> {
try {
return await realpath(path);
} catch {
return resolve(path);
}
}
// Resolve a path that does not exist yet (the normal case for a worktree
// destination): realpath the nearest existing ancestor, then append the
// missing tail. A plain resolve() would let a symlinked parent smuggle
// the destination past the denylist — e.g. <repo>/alias -> <repo>/.git.
async function normalizeCandidatePath(path: string): Promise<string> {
let current = resolve(path);
const missing: string[] = [];
while (!(await pathExists(current))) {
missing.unshift(basename(current));
const parent = dirname(current);
if (parent === current) {
break;
}
current = parent;
}
const resolvedAncestor = await normalizeAbsolute(current);
return missing.length > 0 ? join(resolvedAncestor, ...missing) : resolvedAncestor;
}
async function pathExists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
/**
* Validate a worktree destination. Throws
* with the reason on any violation; returns the normalized absolute path.
*/
export async function validateWorktreePath(options: {
candidate: string;
repoPath: string;
}): Promise<string> {
const { candidate, repoPath } = options;
if (!isAbsolute(candidate)) {
throw new Error(
`Worktree hook returned a relative path '${candidate}'; it must be an absolute path`,
);
}
const normalized = await normalizeCandidatePath(candidate);
const normalizedRepoPath = await normalizeAbsolute(repoPath);
if (normalized === parse(normalized).root) {
throw new Error("Worktree hook returned the filesystem root, which is not allowed");
}
if (normalized === (await normalizeAbsolute(homedir()))) {
throw new Error("Worktree hook returned the user's home directory, which is not allowed");
}
if (normalized === normalizedRepoPath) {
throw new Error("Worktree hook returned the repository root itself, which is not allowed");
}
if (
[normalized, resolve(candidate)].some((path) =>
path
.split(sep)
.some((part) => (process.platform === "win32" ? part.toLowerCase() : part) === ".git"),
)
) {
throw new Error(`Worktree hook returned a path inside a .git directory: ${normalized}`);
}
const relativeToCandidate = getRelativePathInsideRoot(normalized, normalizedRepoPath);
if (relativeToCandidate !== null && relativeToCandidate !== "") {
throw new Error(`Worktree hook returned an ancestor of the repository: ${normalized}`);
}
if ((await pathExists(normalized)) || (await pathExists(candidate))) {
throw new Error(
`Worktree hook returned a path that already exists: ${normalized}. Paseo creates the returned path.`,
);
}
return normalized;
}
@@ -1,6 +1,5 @@
// POSIX-only: git worktree and teardown shell fixtures
/* eslint-disable max-nested-callbacks */
import { validateWorktreePath } from "./worktree-path.js";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createWorktree as createWorktreePrimitive,
@@ -160,7 +159,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) });
await expect(
isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }),
).resolves.toMatchObject({ allowed: true, worktreePath: result.worktreePath });
).resolves.toMatchObject({ allowed: false });
const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot });
expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath);
@@ -1381,200 +1380,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
describe("worktrees at explicit paths", () => {
let tempDir: string;
let repoDir: string;
let paseoHome: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-explicit-test-")));
repoDir = join(tempDir, "test-repo");
paseoHome = join(tempDir, "paseo-home");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "hello\n");
execFileSync("git", ["add", "."], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("places, owns, lists, and deletes a worktree outside the legacy root", async () => {
// Sibling layout: <repo-parent>/wt-<slug>
const result = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "sibling-one",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/sibling" },
paseoHome,
worktreePath: join(tempDir, "wt-sibling-one"),
});
expect(result.worktreePath).toBe(join(tempDir, "wt-sibling-one"));
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
expect(existsSync(await getPaseoWorktreeMetadataPath(result.worktreePath))).toBe(true);
const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(ownership.worktreePath).toBe(result.worktreePath);
// A subdirectory of the worktree resolves to the worktree root.
mkdirSync(join(result.worktreePath, "sub"), { recursive: true });
const nested = await isPaseoOwnedWorktreeCwd(join(result.worktreePath, "sub"), {
paseoHome,
});
expect(nested.worktreePath).toBe(result.worktreePath);
const listed = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
expect(listed.map((entry) => entry.path)).toContain(result.worktreePath);
await deletePaseoWorktree({ cwd: repoDir, worktreePath: result.worktreePath, paseoHome });
expect(existsSync(result.worktreePath)).toBe(false);
});
it("removes branches fetched by the source plan when git worktree add fails", async () => {
// A branch that exists only on the remote: resolving a
// checkout-branch plan fetches it into a local branch. A script
// failure must not leave that branch behind, or the next attempt
// would uniquify to a different branch name.
const remoteDir = join(tempDir, "remote.git");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { cwd: tempDir });
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
execFileSync("git", ["push", "origin", "main"], { cwd: repoDir });
execFileSync("git", ["branch", "feature/remote-check"], { cwd: repoDir });
execFileSync("git", ["push", "origin", "feature/remote-check"], { cwd: repoDir });
execFileSync("git", ["branch", "-D", "feature/remote-check"], { cwd: repoDir });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
mkdirSync(join(tempDir, "occupied"));
writeFileSync(join(tempDir, "occupied", "keep"), "keep");
await expect(
createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "remote-check",
source: { kind: "checkout-branch", branchName: "feature/remote-check" },
paseoHome,
worktreePath: join(tempDir, "occupied"),
}),
).rejects.toThrow();
const localBranches = execFileSync("git", ["branch", "--list", "feature/remote-check"], {
cwd: repoDir,
encoding: "utf8",
});
expect(localBranches.trim()).toBe("");
});
it("deletes only the registered destination nested beneath the legacy root", async () => {
const parent = join(paseoHome, "worktrees", "acme", "widgets");
const worktreePath = join(parent, "topic");
const siblingPath = join(parent, "sibling");
await createWorktreePrimitive({
cwd: repoDir,
paseoHome,
worktreeSlug: "topic",
worktreePath,
source: { kind: "branch-off", branchName: "topic", baseBranch: "main" },
});
await createWorktreePrimitive({
cwd: repoDir,
paseoHome,
worktreeSlug: "sibling",
worktreePath: siblingPath,
source: { kind: "branch-off", branchName: "sibling", baseBranch: "main" },
});
mkdirSync(join(worktreePath, "nested"));
expect(
await isPaseoOwnedWorktreeCwd(join(worktreePath, "nested"), { paseoHome }),
).toMatchObject({ allowed: true, worktreePath });
await deletePaseoWorktree({ cwd: repoDir, paseoHome, worktreePath, registryProven: true });
expect(existsSync(worktreePath)).toBe(false);
expect(readFileSync(join(siblingPath, "file.txt"), "utf8")).toBe("hello\n");
});
it("rejects a destination under a symlink into .git", async () => {
const alias = join(tempDir, "git-alias");
symlinkSync(join(repoDir, ".git"), alias, "dir");
await expect(
validateWorktreePath({ candidate: join(alias, "new", "worktree"), repoPath: repoDir }),
).rejects.toThrow("inside a .git directory");
expect(existsSync(join(repoDir, ".git", "new"))).toBe(false);
});
it("uses an explicit worktreePath verbatim", async () => {
const explicitPath = join(tempDir, "explicit-wt");
const result = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "explicit-one",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/explicit" },
paseoHome,
worktreePath: explicitPath,
});
expect(result.worktreePath).toBe(explicitPath);
expect(existsSync(join(explicitPath, "file.txt"))).toBe(true);
});
it("restore recreates at the recorded path", async () => {
const created = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "restorable",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/restorable" },
paseoHome,
worktreePath: join(tempDir, "wt-sibling-one"),
});
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
expect(existsSync(created.worktreePath)).toBe(false);
const restored = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "restorable",
source: { kind: "restore", branchName: "feature/restorable", baseRef: "main" },
paseoHome,
worktreePath: created.worktreePath,
});
expect(restored.worktreePath).toBe(created.worktreePath);
expect(existsSync(join(restored.worktreePath, "file.txt"))).toBe(true);
});
it("does not treat a copied marker in a non-worktree directory as ownership", async () => {
const created = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "marker-source",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/marker" },
paseoHome,
worktreePath: join(tempDir, "wt-sibling-one"),
});
const metadata = await readPaseoWorktreeMetadata(created.worktreePath);
expect(metadata).not.toBeNull();
// A plain directory with a .git file pointing at a copied admin dir
// that holds a paseo/worktree.json. Git does not list it as a
// worktree, so the copied marker must not prove ownership.
const fakeDir = join(tempDir, "fake-worktree");
const fakeAdmin = join(tempDir, "fake-admin");
mkdirSync(join(fakeAdmin, "paseo"), { recursive: true });
mkdirSync(fakeDir, { recursive: true });
writeFileSync(join(fakeAdmin, "paseo", "worktree.json"), JSON.stringify(metadata));
writeFileSync(join(fakeDir, ".git"), `gitdir: ${fakeAdmin}\n`);
const ownership = await isPaseoOwnedWorktreeCwd(fakeDir, { paseoHome });
expect(ownership.allowed).toBe(false);
await expect(
deletePaseoWorktree({ cwd: repoDir, worktreePath: fakeDir, paseoHome }),
).rejects.toThrow("Refusing to delete non-Paseo worktree");
expect(existsSync(fakeDir)).toBe(true);
});
});
describe("paseo worktree manager", () => {
let tempDir: string;
let repoDir: string;
@@ -302,22 +302,6 @@ describe("paseo worktree manager", () => {
expect(existsSync(created.worktreePath)).toBe(false);
});
it("deletes a worktree when Git reports its source through native and long path spellings", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "identity-delete",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "identity-delete",
paseoHome,
});
await deletePaseoWorktree({
cwd: realpathSync.native(repoDir),
worktreePath: created.worktreePath,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(false);
});
it("is idempotent: deleting an already-absent worktree succeeds", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "idempotent-delete-branch",
+75 -228
View File
@@ -24,8 +24,6 @@ export {
import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import {
createPaseoWorktreeChangeRequestHint,
hasPaseoWorktreeMetadata,
findPaseoWorktreeRootForCwd,
normalizeBaseRefName,
isQualifiedRef,
type PaseoWorktreeChangeRequestHint,
@@ -220,7 +218,6 @@ export interface CreateWorktreeOptions {
source: WorktreeSource;
paseoHome?: string;
worktreesRoot?: string;
worktreePath?: string;
}
export class BranchAlreadyCheckedOutError extends Error {
@@ -967,24 +964,6 @@ export async function isPaseoOwnedWorktreeCwd(
}
}
// Worktrees placed at plugin-chosen paths are
// proven by the marker createWorktree writes unconditionally: a
// paseo/worktree.json in the worktree's git admin dir, with the path
// still registered in the repo's `git worktree list`. The marker alone
// is not proof — it lives in a git admin dir a user can copy into a
// hand-made directory that git does not know about.
if (options?.knownGitCommonDir !== null) {
const metadataProof = await resolveMetadataOwnedWorktree(cwd, repoRoot);
if (metadataProof) {
return {
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreeRoot: metadataProof.worktreeRoot,
worktreePath: metadataProof.worktreePath,
};
}
}
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd);
@@ -992,51 +971,32 @@ export async function isPaseoOwnedWorktreeCwd(
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
// path shape alone is sufficient proof of ownership, even when git has already
// forgotten about the worktree.
if (relativePath !== null) {
const parts = relativePath.split(sep).filter((part) => part.length > 0);
if (parts.length >= 2) {
const worktreesRoot = join(worktreesBaseRoot, parts[0]);
return {
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreeRoot: worktreesRoot,
worktreePath: join(worktreesRoot, parts[1]),
};
}
if (relativePath === null) {
return {
allowed: false,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreePath: resolvedCwd,
};
}
const parts = relativePath.split(sep).filter((part) => part.length > 0);
if (parts.length < 2) {
return {
allowed: false,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreePath: resolvedCwd,
};
}
const worktreesRoot = join(worktreesBaseRoot, parts[0]);
return {
allowed: false,
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreePath: resolvedCwd,
worktreeRoot: worktreesRoot,
worktreePath: join(worktreesRoot, parts[1]),
};
}
async function resolveMetadataOwnedWorktree(
cwd: string,
repoRoot: string | undefined,
): Promise<{ worktreePath: string; worktreeRoot: string } | null> {
try {
const root = await findPaseoWorktreeRootForCwd(cwd);
if (!root) return null;
const candidate = await normalizePathForOwnership(root);
if (repoRoot !== undefined && candidate === (await normalizePathForOwnership(repoRoot)))
return null;
const { stdout: listOutput } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd: candidate,
envOverlay: READ_ONLY_GIT_ENV,
});
for (const entry of parseWorktreeList(listOutput)) {
if ((await normalizePathForOwnership(entry.path)) === candidate) {
return { worktreePath: candidate, worktreeRoot: dirname(candidate) };
}
}
return null;
} catch {
return null;
}
}
type ParsedPaseoWorktreeInfo = Omit<PaseoWorktreeInfo, "createdAt">;
function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
@@ -1103,17 +1063,12 @@ export async function listPaseoWorktrees({
envOverlay: READ_ONLY_GIT_ENV,
});
const mainCheckoutPath = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd));
const roots = await ownershipPathVariants(projectWorktreesRoot);
const worktrees: PaseoWorktreeInfo[] = [];
// Inspect foreign paths sequentially; a large worktree list must not consume the FS pool.
for (const entry of parseWorktreeList(stdout)) {
const path = await normalizePathForOwnership(entry.path);
if (
!roots.some((root) => getRelativePathInsideRoot(root, path) !== null) &&
!(path !== mainCheckoutPath && (await hasPaseoWorktreeMetadata(path)))
)
continue;
if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue;
worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) });
}
return worktrees;
@@ -1127,30 +1082,6 @@ export interface DeletePaseoWorktreeOptions {
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
// The caller has a workspace registry record backing worktreePath (the
// record's cwd is this path). Registry membership is an ownership proof
// in its own right: it covers worktrees whose git admin dir is already
// gone, where the marker proof can no longer be verified. Only pass
// this when the record was matched against the path by the caller.
registryProven?: boolean;
}
// Project scoping for delete: a proof for some other repo's worktree must
// not make this repo's delete remove it. Only refuses on a positive
// mismatch — when git has forgotten either side, the ownership proof the
// caller already established stands.
async function assertWorktreeBelongsToRepo(cwd: string, worktreePath: string): Promise<void> {
let callerRepoRoot: string;
let targetRepoRoot: string;
try {
callerRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd));
targetRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(worktreePath));
} catch {
return;
}
if ((await getOwnershipRelativePath(callerRepoRoot, targetRepoRoot)) !== "") {
throw new Error("Refusing to delete non-Paseo worktree");
}
}
export async function deletePaseoWorktree({
@@ -1161,7 +1092,6 @@ export async function deletePaseoWorktree({
worktreesRoot,
paseoHome,
worktreesBaseRoot,
registryProven,
}: DeletePaseoWorktreeOptions): Promise<void> {
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
@@ -1180,25 +1110,20 @@ export async function deletePaseoWorktree({
}
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = await normalizePathForOwnership(requestedPath);
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
});
// Deletion is gated on the ownership proof set (legacy shape, the
// paseo/worktree.json marker verified against `git worktree list`, or a
// caller-asserted registry record), not on living under the per-project
// hashed root: plugin-placed worktrees live anywhere.
let resolvedWorktree: string;
if (registryProven) {
resolvedWorktree = await normalizePathForOwnership(requestedPath);
} else if (ownership.allowed && ownership.worktreePath) {
resolvedWorktree = ownership.worktreePath;
} else {
throw new Error("Refusing to delete non-Paseo worktree");
}
const resolvedWorktree =
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
if (cwd) {
await assertWorktreeBelongsToRepo(cwd, resolvedWorktree);
const relativeWorktreePath = await getOwnershipRelativePath(
resolvedWorktreesRoot,
resolvedWorktree,
);
if (relativeWorktreePath === null || relativeWorktreePath === "") {
throw new Error("Refusing to delete non-Paseo worktree");
}
if (await pathExists(resolvedWorktree)) {
@@ -1297,67 +1222,6 @@ async function removeDirectoryWithRetries(path: string): Promise<void> {
}
}
async function listLocalBranchNames(cwd: string): Promise<Set<string>> {
const { stdout } = await runGitCommand(
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
{ cwd },
);
return new Set(
stdout
.split("\n")
.map((name) => name.trim())
.filter((name) => name.length > 0),
);
}
// The one local branch a source plan can create while resolving: a
// checkout-branch plan fetches refs/heads/<source.branchName>, and a
// change-request/PR plan fetches refs/heads/<localBranchName> (which is
// the plan's branchName). branch-off creates no local branch. Only this
// ref may be rolled back when creation fails — never any other
// branch that appeared in the meantime; other creators own those.
function planCreatedLocalBranch(
source: WorktreeSource,
sourcePlan: WorktreeSourcePlan,
): string | null {
switch (source.kind) {
case "checkout-branch":
return source.branchName;
case "checkout-change-request":
case "checkout-github-pr":
return sourcePlan.branchName;
default:
return null;
}
}
async function readLocalBranchTip(cwd: string, branchName: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(
["rev-parse", "--verify", `refs/heads/${branchName}^{commit}`],
{ cwd },
);
const tip = stdout.trim();
return tip.length > 0 ? tip : null;
} catch {
return null;
}
}
/** Resolve the default placement once, before presenting it to creation hooks. */
export async function resolveDefaultWorktreePath(
options: Pick<CreateWorktreeOptions, "cwd" | "paseoHome" | "worktreesRoot" | "worktreeSlug">,
): Promise<string> {
const original = join(
await getPaseoWorktreesRoot(options.cwd, options.paseoHome, options.worktreesRoot),
options.worktreeSlug,
);
let candidate = original;
let suffix = 1;
while (await pathExists(candidate)) candidate = `${original}-${suffix++}`;
return candidate;
}
/**
* Create a git worktree with proper naming conventions
*/
@@ -1367,73 +1231,56 @@ export const createWorktree = async ({
worktreeSlug,
paseoHome,
worktreesRoot,
worktreePath: explicitWorktreePath,
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
const branchesBefore =
source.kind === "checkout-branch" ||
source.kind === "checkout-change-request" ||
source.kind === "checkout-github-pr"
? await listLocalBranchNames(cwd)
: new Set<string>();
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
const fetchedBranch = planCreatedLocalBranch(source, sourcePlan);
const fetchedTip =
fetchedBranch && !branchesBefore.has(fetchedBranch)
? await readLocalBranchTip(cwd, fetchedBranch)
: null;
try {
let worktreePath =
explicitWorktreePath ??
(await resolveDefaultWorktreePath({ cwd, paseoHome, worktreesRoot, worktreeSlug }));
await mkdir(dirname(worktreePath), { recursive: true });
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
await mkdir(dirname(worktreePath), { recursive: true });
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
await runGitCommand(["worktree", "add", worktreePath, ...sourcePlan.addArguments], {
cwd,
timeout: 120_000,
});
worktreePath = await normalizePathForOwnership(worktreePath);
if (sourcePlan.pushRemote) {
await configureWorktreePushRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.pushRemote,
});
}
if (sourcePlan.trackingRemote) {
await configureWorktreeTrackingRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.trackingRemote,
});
}
await writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
...(sourcePlan.changeRequestLookupTarget
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
: {}),
});
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
return {
branchName: sourcePlan.branchName,
worktreePath,
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
};
} catch (error) {
if (
fetchedBranch &&
fetchedTip &&
(await readLocalBranchTip(cwd, fetchedBranch)) === fetchedTip
) {
await runGitCommand(["branch", "-D", fetchedBranch], { cwd }).catch(() => {});
}
throw error;
// Also handle worktree path collision
let finalWorktreePath = worktreePath;
let pathSuffix = 1;
while (await pathExists(finalWorktreePath)) {
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
pathSuffix++;
}
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
cwd,
timeout: 120_000,
});
worktreePath = await normalizePathForOwnership(finalWorktreePath);
if (sourcePlan.pushRemote) {
await configureWorktreePushRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.pushRemote,
});
}
if (sourcePlan.trackingRemote) {
await configureWorktreeTrackingRemote({
cwd,
branchName: sourcePlan.branchName,
remote: sourcePlan.trackingRemote,
});
}
await writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
...(sourcePlan.changeRequestLookupTarget
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
: {}),
});
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
return {
branchName: sourcePlan.branchName,
worktreePath,
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
};
};
interface ResolveWorktreeSourcePlanOptions {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect } from "./docs";
describe("plugin documentation", () => {
it("groups current guides by task without version navigation", () => {
@@ -20,14 +20,6 @@ describe("plugin documentation", () => {
});
});
it.each(["en", "zh-CN"] as const)("resolves worktree placement hook links in %s", (locale) => {
const hooks = getDoc("plugins/hooks", locale)!;
expect(parseHeadings(hooks.content).map((heading) => heading.id)).toContain(
"worktreecreatebefore",
);
expect(hooks.content).toContain("plugin-examples/worktree-placement");
});
it.each(["v0.7", "v0.8"])("redirects %s links to existing current pages", (version) => {
for (const page of ["", "/index", "/reference", "/providers", "/migration"]) {
const target = `/docs/plugins${page === "/index" ? "" : page}`;
+1 -1
View File
@@ -1,6 +1,6 @@
# Lifecycle logger
Registers agent, workspace, and worktree hooks and logs their data as JSON. It observes agent creation,
Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation,
session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return
the request unchanged. Environment values are redacted; prompts and timeline content are logged.
@@ -1,36 +0,0 @@
# Worktree placement
Place worktrees next to their source checkout, using the repository directory name and branch.
For `/home/me/src/paseo` on branch `feature/x`, the new worktree is
`/home/me/src/paseo.feature-x`. Slashes in branch names become hyphens.
Install on Paseo 0.14.0-beta.1 or newer with plugins enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/worktree-placement
```
The plugin is one file, `index.server.ts`:
```ts
import { basename, dirname, join } from "node:path";
import type { PluginServerContext } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
return server.addHook("worktree.create.before", async ({ request }) => ({
...request,
path: join(
dirname(request.sourceCheckout),
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
),
}));
}
```
Paseo validates the returned path and refuses an existing one. It creates, lists, restores,
and removes the worktree through the normal workspace workflow. Restoring uses the recorded
path without running the hook. Git can choose a unique branch name after this hook runs;
the path retains the branch name received by the hook.
See [worktree.create.before](../../public-docs/plugins/hooks.md#worktreecreatebefore)
for the request fields and validation rules.
@@ -1,12 +0,0 @@
import { basename, dirname, join } from "node:path";
import type { PluginServerContext } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
return server.addHook("worktree.create.before", async ({ request }) => ({
...request,
path: join(
dirname(request.sourceCheckout),
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
),
}));
}
@@ -1,4 +0,0 @@
{
"id": "worktree-placement",
"requirements": { "paseo": ">=0.14.0-beta.1" }
}
+5
View File
@@ -21,6 +21,7 @@ export class Children {
private readonly parentId: string,
private readonly cwd: string,
private readonly emit: (event: ProviderEvent) => void,
private readonly subsessions: boolean,
) {}
async update(item: WireItem): Promise<void> {
@@ -34,6 +35,10 @@ export class Children {
)
)
return;
// Emitting a child session.opened with parentSessionId is only valid when the
// parent session negotiated the session.subsession capability; the daemon
// fails the provider connection otherwise.
if (!this.subsessions) return;
const id = item.childSessionId;
if (!this.children.has(id)) {
const { session } = await this.host.request(
+1
View File
@@ -27,6 +27,7 @@ const capabilities = [
"session.persistence",
"permission",
"session.list",
"session.subsession",
] as const;
export function createMuseProvider(usage: Usage): ProviderRegistration {
+7 -1
View File
@@ -142,7 +142,13 @@ export class Session {
const nativeId = response.session.sessionId;
this.liveState = {
nativeId,
children: new Children(this.host, this.options.id, this.config.cwd, this.options.emit),
children: new Children(
this.host,
this.options.id,
this.config.cwd,
this.options.emit,
this.options.capabilities.includes("session.subsession"),
),
timeline: new Timeline(
this.host,
nativeId,
+16 -1
View File
@@ -45,6 +45,7 @@ async function harness(
scenario = "text-reasoning",
env: Record<string, string> = {},
providerOptions?: Record<string, unknown>,
connectCapabilities: readonly string[] = PROVIDER_CAPABILITIES,
) {
const root = await mkdtemp(path.join(os.tmpdir(), "muse-provider-test-"));
roots.push(root);
@@ -62,7 +63,7 @@ async function harness(
const connection = await provider.connect({
launch,
versions: [1],
capabilities: PROVIDER_CAPABILITIES,
capabilities: connectCapabilities,
});
connections.push(connection);
const events: ProviderEvent[] = [];
@@ -1192,6 +1193,20 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
).toHaveLength(1);
});
}
test("subagent children are not projected when session.subsession is not negotiated", async () => {
const h = await harness(
"subagent",
{ MUSE_TEST_CHILD: "tool" },
undefined,
PROVIDER_CAPABILITIES.filter((capability) => capability !== "session.subsession"),
);
await h.open();
await h.prompt();
await h.wait((e) => e.type === "session.turn" && e.state === "completed");
expect(h.events.filter((e) => e.type === "session.opened" && e.parentSessionId)).toHaveLength(0);
expect((await h.recorded()).filter((f) => f.method === "session/read")).toEqual([]);
});
test("gap backfill repairs a missing completion without duplicating item revisions", async () => {
const h = await harness("text-reasoning", { MUSE_TEST_GAP: "1" });
await h.open();
+6 -47
View File
@@ -54,7 +54,6 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
| `workspace.create.before` | yes | `{ request }` | Entire create request |
| `workspace.create.after` | no | `{ workspace }` | |
| `workspace.archive.after` | no | `{ workspace }` | |
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` |
| `agent.create.after` | no | `{ agent }` | |
@@ -146,46 +145,6 @@ server.addHook("workspace.archive.after", async ({ workspace }) => {
});
```
## worktree.create.before {#worktreecreatebefore}
Choose the location and creation options for a new worktree. Paseo awaits this hook before `git worktree add`, on creation from the app, CLI, MCP, and agent creation. Receives `{ request }`; return a complete replacement request or `undefined` to keep it. Throwing, invalid returned data, or a 30-second timeout aborts creation with the plugin and hook named in the error.
```ts
import { basename, dirname, join } from "node:path";
server.addHook("worktree.create.before", async ({ request }) => ({
...request,
path: join(
dirname(request.sourceCheckout),
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
),
}));
```
For source `/home/me/src/paseo` and branch `feature/x`, this creates `/home/me/src/paseo.feature-x`. See the complete [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement).
| Request field | Meaning |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `branch-off`, `checkout-branch`, `checkout-change-request`, or `checkout-github-pr` |
| `branch` | Requested local branch name |
| `baseRef` | Starting ref for branch-off; comparison base for change requests; absent for `checkout-branch`. To branch off a ref, return `kind: "branch-off"`. |
| `slug` | Normalized worktree name |
| `path` | Absolute destination; initially the default location with any existing-path suffix resolved |
| `repoRoot` | Read-only main repository root |
| `sourceCheckout` | Read-only source checkout root, including when creation starts from another worktree |
| `forge`, `changeRequestNumber`, `headRef` | Change-request checkout identity; GitHub legacy checkouts use `githubPrNumber` instead of `forge` and `changeRequestNumber` |
| `headRepositoryOwner`, `headRepository` | Optional source repository facts; `headRepository` is present only on change-request checkouts |
| `checkoutRefs` | Optional fetch sources: `{ remoteName?: string, remoteRef: string }[]` |
| `pushRemoteUrl`, `trackOriginHead` | Optional push destination and tracking policy |
Once `path` is returned, `slug` only feeds branch-off naming; the CLI looks up worktrees by the basename of their path.
Every field except `repoRoot` and `sourceCheckout` is transformable. Return all required facts for the chosen `kind`. Paseo resolves the Git source plan after this hook, so handlers can change the branch and base before fetching refs, while Git can still choose a unique final branch name afterward.
Paseo resolves symlinked parents and validates the returned path. It must be absolute, must not exist, and must not be the filesystem root, your home directory, the repository root, inside a `.git` directory, or an ancestor of the repository. An existing destination fails creation; Paseo never adds a suffix to a plugin-chosen path. The plugin must leave directory creation to Paseo.
Ordering: `worktree.create.before` → `git worktree add` → `worktree.create.after` → workspace registration. Restoring a missing worktree recreates it at its recorded path without running either worktree creation hook.
## worktree.create.after
Paseo waits for this hook after a new git worktree exists and before workspace registration, setup, terminals, or agents. Receives `{ worktree: { path, branch, sourceCheckout } }`: the absolute worktree path, branch name, and source checkout directory. There is no workspace id yet. Return nothing; write files directly into the worktree. Throwing or exceeding 30 seconds aborts creation and removes the new worktree.
@@ -630,12 +589,12 @@ replaces that scope's prior value.
## Complete examples
| Plugin | Includes |
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent, workspace, and worktree hooks; JSON logs with environment values redacted |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process |
| Plugin | Includes |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All thirteen hooks; JSON logs with environment values redacted |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process |
Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`.
+6 -47
View File
@@ -52,7 +52,6 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
| `workspace.create.after` | 否 | `{ workspace }` | |
| `workspace.archive.after` | 否 | `{ workspace }` | |
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` |
| `agent.create.after` | 否 | `{ agent }` | |
@@ -143,46 +142,6 @@ server.addHook("workspace.archive.after", async ({ workspace }) => {
});
```
## worktree.create.before {#worktreecreatebefore}
为新 worktree 选择路径和创建选项。通过应用、CLI、MCP 或创建 Agent 新建 worktree 时,Paseo 在 `git worktree add` 之前等待此钩子。钩子收到 `{ request }`,返回完整的替换请求,或返回 `undefined` 保持原值。抛出错误、返回无效数据或超过 30 秒会终止创建,错误包含插件和钩子名称。
```ts
import { basename, dirname, join } from "node:path";
server.addHook("worktree.create.before", async ({ request }) => ({
...request,
path: join(
dirname(request.sourceCheckout),
`${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`,
),
}));
```
源检出目录为 `/home/me/src/paseo`、分支为 `feature/x` 时,目标为 `/home/me/src/paseo.feature-x`。完整插件见 [worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement)。
| 请求字段 | 含义 |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `kind` | `branch-off`、`checkout-branch`、`checkout-change-request` 或 `checkout-github-pr` |
| `branch` | 请求的本地分支名称 |
| `baseRef` | branch-off 的起始引用;变更请求的比较基准;`checkout-branch` 不含此字段。要从引用创建分支,返回 `kind: "branch-off"`。 |
| `slug` | 规范化的 worktree 名称 |
| `path` | 绝对目标路径,初始值为已处理现有路径后缀的默认位置 |
| `repoRoot` | 只读,主仓库根目录 |
| `sourceCheckout` | 只读,源检出根目录,也支持从另一个 worktree 发起创建 |
| `forge`、`changeRequestNumber`、`headRef` | 变更请求检出标识;旧版 GitHub 检出用 `githubPrNumber` 代替 `forge` 和 `changeRequestNumber` |
| `headRepositoryOwner`、`headRepository` | 可选的源仓库信息;`headRepository` 仅用于变更请求检出 |
| `checkoutRefs` | 可选的获取源:`{ remoteName?: string, remoteRef: string }[]` |
| `pushRemoteUrl`、`trackOriginHead` | 可选的推送目标和跟踪策略 |
返回 `path` 后,`slug` 仅用于 branch-off 分支命名;CLI 使用路径的最后一级目录名查找 worktree。
除 `repoRoot` 和 `sourceCheckout` 外,所有字段都可以修改。返回所选 `kind` 的全部必填字段。Paseo 在钩子之后解析 Git 源计划,因此钩子可以在获取引用之前修改分支和基准,而 Git 之后仍可能选择唯一的最终分支名称。
Paseo 解析父目录中的符号链接并验证返回的路径。路径必须为绝对路径且不存在,不能是文件系统根目录、用户主目录、仓库根目录、`.git` 目录内的路径或仓库的祖先目录。目标已存在时创建失败,Paseo 不会为插件选择的路径添加后缀。插件应让 Paseo 创建目录。
顺序:`worktree.create.before` → `git worktree add` → `worktree.create.after` → 注册工作区。恢复缺失的 worktree 时使用记录的原路径,不运行这两个创建钩子。
## worktree.create.after {#worktreecreateafter}
新的 git worktree 在磁盘上创建后,Paseo 会等待此钩子,然后才注册工作区、运行 setup、打开终端或启动 Agent。收到 `{ worktree: { path, branch, sourceCheckout } }`,分别是 worktree 绝对路径、分支名、来源 checkout 目录。此时还没有工作区 id。无需返回数据,直接写入 worktree 即可。抛出错误或超过 30 秒会中止创建并删除新 worktree。
@@ -612,12 +571,12 @@ Creation request
## 完整示例 {#complete-examples}
| 插件 | 包含 |
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent、工作区和 worktree 钩子;隐去环境变量值的 JSON 日志 |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 |
| 插件 | 包含 |
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十三个钩子;隐去环境变量值的 JSON 日志 |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 |
用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。
+2 -1
View File
@@ -289,7 +289,8 @@ servers during `session.open`. There is no separate reload operation.
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
recreate it while restoring the parent.
recreate it while restoring the parent. The parent's `session.opened` capabilities must include
`session.subsession`. Paseo fails the whole connection when a parent without it opens a child.
## Add commands and steering
-2
View File
@@ -35,8 +35,6 @@ With a custom root, Paseo keeps the same hashed layout under that directory:
}
```
Plugins can choose a location anywhere on disk with [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore). The [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) places worktrees next to the source checkout. Paseo validates the path, refuses an existing destination, and manages listing, archive, and deletion there. Recovery uses the recorded path without running creation hooks.
1. Create a workspace with worktree isolation, Paseo creates the worktree and runs your setup hooks
2. Launch one or more agents in that workspace
3. Review the diff against the base branch
-2
View File
@@ -33,8 +33,6 @@ worktree 默认存放在 `$PASEO_HOME/worktrees/` 下,按源检出路径的哈
}
```
插件可以通过 [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore) 将 worktree 放到磁盘上的自选位置。[worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) 将其放在源检出目录旁边。Paseo 验证路径并拒绝已存在的目标,同时支持在该位置列出、归档和删除 worktree。恢复时使用记录的原路径,不运行创建钩子。
1. 创建一个使用 worktree 隔离的工作区,Paseo 会创建 worktree 并运行你的 setup 钩子
2. 在该工作区中启动一个或多个 Agent
3. 对照基础分支审查 diff