feat: improve agent workspace flows and directory suggestions

This commit is contained in:
Mohamed Boudra
2026-02-16 23:19:01 +07:00
parent 22da0951d5
commit 170ee27813
49 changed files with 3053 additions and 293 deletions
@@ -347,6 +347,63 @@ describe("DaemonClient", () => {
});
});
test("requests directory suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getDirectorySuggestions(
{ query: "proj", limit: 10 },
"req-directories"
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "directory_suggestions_request";
query: string;
limit?: number;
requestId: string;
};
};
expect(request.message.type).toBe("directory_suggestions_request");
expect(request.message.query).toBe("proj");
expect(request.message.limit).toBe(10);
expect(request.message.requestId).toBe("req-directories");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "directory_suggestions_response",
payload: {
directories: ["/Users/test/projects/paseo"],
error: null,
requestId: "req-directories",
},
},
})
);
await expect(promise).resolves.toEqual({
directories: ["/Users/test/projects/paseo"],
error: null,
requestId: "req-directories",
});
});
test("requests checkout merge from base via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -26,6 +26,7 @@ import type {
CheckoutPrStatusResponse,
ValidateBranchResponse,
BranchSuggestionsResponse,
DirectorySuggestionsResponse,
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
@@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type ValidateBranchPayload = ValidateBranchResponse["payload"];
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"];
@@ -2082,6 +2084,22 @@ export class DaemonClient {
});
}
async getDirectorySuggestions(
options: { query: string; limit?: number },
requestId?: string
): Promise<DirectorySuggestionsPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "directory_suggestions_request",
query: options.query,
limit: options.limit,
},
responseType: "directory_suggestions_response",
timeout: 10000,
});
}
// ============================================================================
// File Explorer
// ============================================================================
@@ -168,6 +168,32 @@ describe("daemon client E2E", () => {
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
try {
const insideQuery = path.basename(insideHomeDir);
const insideResult = await ctx.client.getDirectorySuggestions({
query: insideQuery,
limit: 25,
});
expect(insideResult.error).toBeNull();
expect(insideResult.directories).toContain(insideHomeDir);
const outsideQuery = path.basename(outsideHomeDir);
const outsideResult = await ctx.client.getDirectorySuggestions({
query: outsideQuery,
limit: 25,
});
expect(outsideResult.error).toBeNull();
expect(outsideResult.directories).not.toContain(outsideHomeDir);
} finally {
rmSync(insideHomeDir, { recursive: true, force: true });
rmSync(outsideHomeDir, { recursive: true, force: true });
}
}, 30000);
test("emits server_info on websocket connect", async () => {
const client = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
@@ -105,6 +105,43 @@ async function waitForPathExists(
);
}
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
const originalShell = process.env.SHELL;
process.env.SHELL = shell;
try {
return await run();
} finally {
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
}
}
interface WorktreeTerminalBootstrapEntry {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
function getWorktreeTerminalBootstrapEntries(
item: Extract<AgentTimelineItem, { type: "tool_call" }>
): WorktreeTerminalBootstrapEntry[] | null {
const detail = item.detail;
if (!detail || detail.type !== "unknown" || !detail.output) {
return null;
}
const output = detail.output as Record<string, unknown>;
const terminals = output.terminals;
if (!Array.isArray(terminals)) {
return null;
}
return terminals as WorktreeTerminalBootstrapEntry[];
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -413,100 +450,136 @@ describe("daemon E2E", () => {
test(
"bootstraps configured worktree terminals after setup succeeds",
async () => {
const repoRoot = tmpCwd();
await withShell("/bin/sh", async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
{
command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
cwd: repoRoot,
stdio: "pipe",
});
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
title: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: "tail -f /dev/null",
},
{
command: "tail -f /dev/null",
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
cwd: repoRoot,
stdio: "pipe",
});
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000
);
const terminalsBootstrapToolCall = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_terminals" && item.status === "completed",
30000
);
const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(
terminalsBootstrapToolCall
);
expect(bootstrappedTerminals).toBeTruthy();
expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2);
const failedBootstraps =
bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? [];
expect(failedBootstraps).toEqual([]);
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
await waitForPathExists({
targetPath: path.join(agent.cwd, "setup-port.txt"),
timeoutMs: 30000,
label: "setup runtime port marker",
});
const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check");
expect(createdTerminal.error).toBeNull();
expect(createdTerminal.terminal).toBeTruthy();
const manualTerminalId = createdTerminal.terminal?.id;
expect(manualTerminalId).toBeTruthy();
if (!manualTerminalId) {
throw new Error("Expected manual terminal id");
}
ctx.client.sendTerminalInput(manualTerminalId, {
type: "input",
data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r',
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "manual-terminal-port.txt"),
timeoutMs: 30000,
label: "manual terminal runtime port marker",
});
const manualTerminalPort = readFileSync(
path.join(agent.cwd, "manual-terminal-port.txt"),
"utf8"
).trim();
expect(manualTerminalPort).toBe(setupPort);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000
);
await waitForPathExists({
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
timeoutMs: 15000,
label: "dev terminal marker",
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "lint-terminal.txt"),
timeoutMs: 15000,
label: "lint terminal marker",
});
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
+40 -1
View File
@@ -4,6 +4,7 @@ import { stat } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import { join, resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import {
@@ -26,6 +27,7 @@ import {
type DetachTerminalStreamRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type ProjectCheckoutLitePayload,
type ProjectPlacementPayload,
} from "./messages.js";
@@ -138,6 +140,7 @@ import {
} from "../utils/checkout-git.js";
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories } from "../utils/directory-suggestions.js";
import {
ensureLocalSpeechModels,
getLocalSpeechModelDir,
@@ -1347,6 +1350,10 @@ export class Session {
await this.handleBranchSuggestionsRequest(msg);
break;
case "directory_suggestions_request":
await this.handleDirectorySuggestionsRequest(msg);
break;
case "subscribe_checkout_diff_request":
await this.handleSubscribeCheckoutDiffRequest(msg);
break;
@@ -3563,9 +3570,10 @@ export class Session {
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
if (!status.isGit) {
this.emit({
type: "checkout_status_response",
@@ -3754,6 +3762,37 @@ export class Session {
}
}
private async handleDirectorySuggestionsRequest(
msg: DirectorySuggestionsRequest
): Promise<void> {
const { query, limit, requestId } = msg;
try {
const directories = await searchHomeDirectories({
homeDir: process.env.HOME ?? homedir(),
query,
limit,
});
this.emit({
type: "directory_suggestions_response",
payload: {
directories,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "directory_suggestions_response",
payload: {
directories: [],
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
}
}
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as pythonParser } from "@lezer/python";
import { parser as markdownParser } from "@lezer/markdown";
import { parser as elixirParser } from "lezer-elixir";
import type { Parser } from "@lezer/common";
// Map file extensions to parsers
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
htm: htmlParser,
// Python
py: pythonParser,
// Elixir
ex: elixirParser,
exs: elixirParser,
// Markdown
md: markdownParser,
mdx: markdownParser,
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
@@ -15,6 +15,17 @@ describe("runAsyncWorktreeBootstrap", () => {
let repoDir: string;
let paseoHome: string;
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (existsSync(targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Timed out waiting for path: ${targetPath}`);
}
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
repoDir = join(tempDir, "repo");
@@ -236,4 +247,216 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(persistedSetupItem.detail.log).toContain("-suffix");
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
});
it("waits for terminal output before sending bootstrap commands", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
terminals: [
{
name: "Ready Terminal",
command: "echo ready",
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", {
cwd: repoDir,
stdio: "pipe",
});
const worktree = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-terminal-readiness",
baseBranch: "main",
worktreeSlug: "feature-terminal-readiness",
paseoHome,
});
let readyAt = 0;
let sendAt = 0;
let outputListener: ((chunk: { data: string }) => void) | null = null;
await runAsyncWorktreeBootstrap({
agentId: "agent-terminal-readiness",
worktree,
terminalManager: {
async getTerminals() {
return [];
},
async createTerminal(options) {
setTimeout(() => {
readyAt = Date.now();
outputListener?.({ data: "$ " });
}, 25);
return {
id: "term-ready",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {
sendAt = Date.now();
},
subscribe: () => () => {},
onExit: () => () => {},
subscribeRaw: (listener) => {
outputListener = (chunk) =>
listener({
data: chunk.data,
startOffset: 0,
endOffset: chunk.data.length,
replay: false,
});
return {
unsubscribe: () => {
outputListener = null;
},
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: false,
};
},
getOutputOffset: () => 0,
getState: () => ({
rows: 0,
cols: 0,
grid: [],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
appendTimelineItem: async () => true,
emitLiveTimelineItem: async () => true,
});
expect(readyAt).toBeGreaterThan(0);
expect(sendAt).toBeGreaterThan(0);
expect(sendAt).toBeGreaterThanOrEqual(readyAt);
});
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
terminals: [
{
name: "Port Terminal",
command: "true",
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", {
cwd: repoDir,
stdio: "pipe",
});
const worktree = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
worktreeSlug: "feature-shared-runtime-port",
paseoHome,
});
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
const createTerminalEnvs: Record<string, string>[] = [];
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-shared-runtime-port",
worktree,
terminalManager: {
async getTerminals() {
return [];
},
async createTerminal(options) {
createTerminalEnvs.push(options.env ?? {});
return {
id: "term-1",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
subscribeRaw: () => ({
unsubscribe: () => {},
replayedFrom: 0,
currentOffset: 0,
earliestAvailableOffset: 0,
reset: false,
}),
getOutputOffset: () => 1,
getState: () => ({
rows: 0,
cols: 0,
grid: [],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
};
},
registerCwdEnv(options) {
registeredEnvs.push({ cwd: options.cwd, env: options.env });
},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
await waitForPathExists(setupPortPath);
const setupPort = readFileSync(setupPortPath, "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
expect(registeredEnvs).toHaveLength(1);
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalEnvs.length).toBeGreaterThan(0);
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
const terminalToolCall = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" &&
item.name === "paseo_worktree_terminals" &&
item.status === "completed"
);
expect(terminalToolCall?.status).toBe("completed");
});
});
@@ -1,13 +1,16 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import {
createWorktree,
getWorktreeTerminalSpecs,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv,
} from "../utils/worktree.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
@@ -38,6 +41,7 @@ export interface CreateAgentWorktreeOptions {
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
type MiddleTruncationAccumulator = {
totalBytes: number;
@@ -335,8 +339,51 @@ function buildTerminalTimelineItem(input: {
};
}
async function waitForTerminalBootstrapReadiness(
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">
): Promise<void> {
if (terminal.getOutputOffset() > 0) {
return;
}
await new Promise<void>((resolve) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
let unsubscribe: (() => void) | null = null;
const finish = () => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
resolve();
};
const rawSubscription = terminal.subscribeRaw(() => {
finish();
});
unsubscribe = rawSubscription.unsubscribe;
if (terminal.getOutputOffset() > 0) {
finish();
return;
}
timeout = setTimeout(finish, WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS);
});
}
async function runWorktreeTerminalBootstrap(
options: RunAsyncWorktreeBootstrapOptions
options: RunAsyncWorktreeBootstrapOptions,
runtimeEnv: WorktreeRuntimeEnv
): Promise<void> {
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
if (terminalSpecs.length === 0) {
@@ -376,7 +423,9 @@ async function runWorktreeTerminalBootstrap(
const terminal = await options.terminalManager.createTerminal({
cwd: options.worktree.worktreePath,
name: spec.name,
env: runtimeEnv,
});
await waitForTerminalBootstrapReadiness(terminal);
terminal.send({
type: "input",
data: `${spec.command}\r`,
@@ -420,6 +469,7 @@ export async function runAsyncWorktreeBootstrap(
): Promise<void> {
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
let runtimeEnv: WorktreeRuntimeEnv | null = null;
const emitLiveTimelineItem = options.emitLiveTimelineItem;
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
@@ -454,10 +504,20 @@ export async function runAsyncWorktreeBootstrap(
};
try {
runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
});
options.terminalManager?.registerCwdEnv({
cwd: options.worktree.worktreePath,
env: runtimeEnv,
});
setupResults = await runWorktreeSetupCommands({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
cleanupOnFailure: false,
runtimeEnv,
onEvent: (event) => {
const existing = runningResultsByIndex.get(event.index);
const baseResult: WorktreeSetupCommandResult = existing ?? {
@@ -533,5 +593,5 @@ export async function runAsyncWorktreeBootstrap(
return;
}
await runWorktreeTerminalBootstrap(options);
await runWorktreeTerminalBootstrap(options, runtimeEnv);
}
@@ -100,6 +100,26 @@ describe("shared messages stream parsing", () => {
expect(parsed.success).toBe(false);
});
it("parses directory suggestions request and response payloads", () => {
const requestParsed = SessionInboundMessageSchema.safeParse({
type: "directory_suggestions_request",
query: "proj",
limit: 20,
requestId: "req-dir-1",
});
expect(requestParsed.success).toBe(true);
const responseParsed = SessionOutboundMessageSchema.safeParse({
type: "directory_suggestions_response",
payload: {
directories: ["/Users/test/projects/paseo"],
error: null,
requestId: "req-dir-1",
},
});
expect(responseParsed.success).toBe(true);
});
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
const fixture = {
type: "agent_stream_snapshot",
+20
View File
@@ -826,6 +826,13 @@ export const BranchSuggestionsRequestSchema = z.object({
requestId: z.string(),
});
export const DirectorySuggestionsRequestSchema = z.object({
type: z.literal("directory_suggestions_request"),
query: z.string(),
limit: z.number().int().min(1).max(100).optional(),
requestId: z.string(),
});
export const PaseoWorktreeListRequestSchema = z.object({
type: z.literal("paseo_worktree_list_request"),
cwd: z.string().optional(),
@@ -1075,6 +1082,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPrStatusRequestSchema,
ValidateBranchRequestSchema,
BranchSuggestionsRequestSchema,
DirectorySuggestionsRequestSchema,
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
FileExplorerRequestSchema,
@@ -1722,6 +1730,15 @@ export const BranchSuggestionsResponseSchema = z.object({
}),
});
export const DirectorySuggestionsResponseSchema = z.object({
type: z.literal("directory_suggestions_response"),
payload: z.object({
directories: z.array(z.string()),
error: z.string().nullable(),
requestId: z.string(),
}),
});
const PaseoWorktreeSchema = z.object({
worktreePath: z.string(),
branchName: z.string().nullable().optional(),
@@ -2031,6 +2048,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPrStatusResponseSchema,
ValidateBranchResponseSchema,
BranchSuggestionsResponseSchema,
DirectorySuggestionsResponseSchema,
PaseoWorktreeListResponseSchema,
PaseoWorktreeArchiveResponseSchema,
FileExplorerResponseSchema,
@@ -2164,6 +2182,8 @@ export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
export type DirectorySuggestionsRequest = z.infer<typeof DirectorySuggestionsRequestSchema>;
export type DirectorySuggestionsResponse = z.infer<typeof DirectorySuggestionsResponseSchema>;
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
@@ -1,5 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
async function waitForCondition(
predicate: () => boolean,
@@ -16,13 +19,34 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
const originalShell = process.env.SHELL;
process.env.SHELL = shell;
try {
return await run();
} finally {
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
}
}
describe("TerminalManager", () => {
let manager: TerminalManager;
const temporaryDirs: string[] = [];
afterEach(() => {
if (manager) {
manager.killAll();
}
while (temporaryDirs.length > 0) {
const dir = temporaryDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("getTerminals", () => {
@@ -94,6 +118,58 @@ describe("TerminalManager", () => {
manager = createTerminalManager();
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
});
it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
temporaryDirs.push(cwd);
const markerPath = join(cwd, "root-port.txt");
manager.registerCwdEnv({
cwd,
env: { PASEO_WORKTREE_PORT: "45678" },
});
const session = await manager.createTerminal({ cwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45678");
});
});
it("inherits registered env for subdirectories within the worktree", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
const subdirCwd = join(rootCwd, "packages", "app");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const markerPath = join(subdirCwd, "subdir-port.txt");
manager.registerCwdEnv({
cwd: rootCwd,
env: { PASEO_WORKTREE_PORT: "45679" },
});
const session = await manager.createTerminal({ cwd: subdirCwd });
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
session.send({
type: "input",
data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`,
});
await new Promise((resolve) => setTimeout(resolve, 100));
}
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("45679");
});
});
});
describe("getTerminal", () => {
@@ -156,7 +232,7 @@ describe("TerminalManager", () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const exitedId = terminals[0].id;
terminals[0].send({ type: "input", data: "\u0004" });
terminals[0].kill();
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
@@ -1,4 +1,5 @@
import { createTerminal, type TerminalSession } from "./terminal.js";
import { resolve, sep } from "node:path";
export interface TerminalListItem {
id: string;
@@ -15,7 +16,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
export interface TerminalManager {
getTerminals(cwd: string): Promise<TerminalSession[]>;
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
killTerminal(id: string): void;
listDirectories(): string[];
@@ -28,6 +34,7 @@ export function createTerminalManager(): TerminalManager {
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
@@ -67,6 +74,23 @@ export function createTerminalManager(): TerminalManager {
emitTerminalsChanged({ cwd: session.cwd });
}
function resolveDefaultEnvForCwd(cwd: string): Record<string, string> | undefined {
const normalizedCwd = resolve(cwd);
let bestMatchRoot: string | null = null;
for (const rootCwd of defaultEnvByRootCwd.keys()) {
const matches = normalizedCwd === rootCwd || normalizedCwd.startsWith(`${rootCwd}${sep}`);
if (!matches) {
continue;
}
if (!bestMatchRoot || rootCwd.length > bestMatchRoot.length) {
bestMatchRoot = rootCwd;
}
}
return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined;
}
function registerSession(session: TerminalSession): TerminalSession {
terminalsById.set(session.id, session);
const unsubscribeExit = session.onExit(() => {
@@ -112,8 +136,13 @@ export function createTerminalManager(): TerminalManager {
let terminals = terminalsByCwd.get(cwd);
if (!terminals || terminals.length === 0) {
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
const session = registerSession(
await createTerminal({ cwd, name: "Terminal 1" })
await createTerminal({
cwd,
name: "Terminal 1",
...(inheritedEnv ? { env: inheritedEnv } : {}),
})
);
terminals = [session];
terminalsByCwd.set(cwd, terminals);
@@ -122,15 +151,25 @@ export function createTerminalManager(): TerminalManager {
return terminals;
},
async createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession> {
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}): Promise<TerminalSession> {
assertAbsolutePath(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? [];
const defaultName = `Terminal ${terminals.length + 1}`;
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
const mergedEnv =
inheritedEnv || options.env
? { ...(inheritedEnv ?? {}), ...(options.env ?? {}) }
: undefined;
const session = registerSession(
await createTerminal({
cwd: options.cwd,
name: options.name ?? defaultName,
...(mergedEnv ? { env: mergedEnv } : {}),
})
);
@@ -141,6 +180,11 @@ export function createTerminalManager(): TerminalManager {
return session;
},
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {
assertAbsolutePath(options.cwd);
defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env });
},
getTerminal(id: string): TerminalSession | undefined {
return terminalsById.get(id);
},
@@ -0,0 +1,154 @@
import {
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
describe("searchHomeDirectories", () => {
let tempRoot: string;
let homeDir: string;
let outsideDir: string;
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
homeDir = path.join(tempRoot, "home");
outsideDir = path.join(tempRoot, "outside");
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it("returns an empty list for blank queries", async () => {
await expect(
searchHomeDirectories({
homeDir,
query: " ",
limit: 10,
})
).resolves.toEqual([]);
});
it("returns only existing directories", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "proj",
limit: 10,
});
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
});
it("supports home-relative path query syntax", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~/projects/pa",
limit: 10,
});
expect(results).toEqual([
path.join(homeDir, "projects", "paseo"),
]);
});
it("prioritizes exact segment matches before segment-prefix matches", async () => {
const exactSegmentPath = path.join(
homeDir,
"something",
"faro",
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
const results = await searchHomeDirectories({
homeDir,
query: "faro",
limit: 30,
});
const exactIndex = results.indexOf(exactSegmentPath);
const prefixIndex = results.indexOf(prefixSegmentPath);
expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex);
});
it("prioritizes partial matches that appear earlier in the path", async () => {
const earlierPath = path.join(homeDir, "farofoo");
const laterPath = path.join(homeDir, "x", "y", "farofoo");
mkdirSync(earlierPath, { recursive: true });
mkdirSync(laterPath, { recursive: true });
const results = await searchHomeDirectories({
homeDir,
query: "arofo",
limit: 30,
});
const earlierIndex = results.indexOf(earlierPath);
const laterIndex = results.indexOf(laterPath);
expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex);
});
it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~",
limit: 20,
});
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "documents"));
});
it("does not return paths that escape home through symlinks", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "outside",
limit: 20,
});
expect(results).not.toContain(path.join(homeDir, "outside-link"));
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
});
it("respects the result limit", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "p",
limit: 1,
});
expect(results).toHaveLength(1);
});
});
@@ -0,0 +1,456 @@
import type { Dirent } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
export interface SearchHomeDirectoriesOptions {
homeDir: string;
query: string;
limit?: number;
maxDepth?: number;
maxDirectoriesScanned?: number;
}
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
type QueryParts = {
isPathQuery: boolean;
parentPart: string;
searchTerm: string;
};
type RankedDirectory = {
absolutePath: string;
matchTier: number;
segmentIndex: number;
matchOffset: number;
depth: number;
};
type ChildDirectoryEntry = {
name: string;
absolutePath: string;
};
type DirectoryListCacheEntry = {
expiresAt: number;
entries: ChildDirectoryEntry[];
};
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
export async function searchHomeDirectories(
options: SearchHomeDirectoriesOptions
): Promise<string[]> {
const query = options.query.trim();
if (!query) {
return [];
}
const limit = normalizeLimit(options.limit);
const homeRoot = await resolveDirectory(options.homeDir);
if (!homeRoot) {
return [];
}
const queryParts = normalizeQueryParts(query, homeRoot);
if (!queryParts) {
return [];
}
if (queryParts.isPathQuery) {
return searchWithinParentDirectory({
homeRoot,
parentPart: queryParts.parentPart,
searchTerm: queryParts.searchTerm,
limit,
});
}
return searchAcrossHomeTree({
homeRoot,
searchTerm: queryParts.searchTerm,
limit,
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxDirectoriesScanned:
options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
});
}
function normalizeLimit(limit: number | undefined): number {
const candidate = limit ?? DEFAULT_LIMIT;
if (!Number.isFinite(candidate)) {
return DEFAULT_LIMIT;
}
const bounded = Math.trunc(candidate);
return Math.max(1, Math.min(MAX_LIMIT, bounded));
}
async function searchWithinParentDirectory(input: {
homeRoot: string;
parentPart: string;
searchTerm: string;
limit: number;
}): Promise<string[]> {
const parentPath = path.resolve(input.homeRoot, input.parentPart || ".");
const parentRoot = await resolveDirectory(parentPath);
if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) {
return [];
}
const searchLower = input.searchTerm.toLowerCase();
const ranked: RankedDirectory[] = [];
const entries = await listChildDirectories({
directory: parentRoot,
homeRoot: input.homeRoot,
});
for (const entry of entries) {
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
continue;
}
ranked.push(
rankDirectory({
absolutePath: entry.absolutePath,
homeRoot: input.homeRoot,
searchLower,
})
);
}
return dedupeAndSort(ranked).slice(0, input.limit);
}
async function searchAcrossHomeTree(input: {
homeRoot: string;
searchTerm: string;
limit: number;
maxDepth: number;
maxDirectoriesScanned: number;
}): Promise<string[]> {
const queue: Array<{ directory: string; depth: number }> = [
{ directory: input.homeRoot, depth: 0 },
];
const visited = new Set<string>([input.homeRoot]);
const ranked: RankedDirectory[] = [];
let scanned = 0;
const searchLower = input.searchTerm.toLowerCase();
for (
let queueIndex = 0;
queueIndex < queue.length && scanned < input.maxDirectoriesScanned;
queueIndex += 1
) {
const current = queue[queueIndex];
if (!current) continue;
const entries = await listChildDirectories({
directory: current.directory,
homeRoot: input.homeRoot,
});
for (const entry of entries) {
const resolvedCandidate = entry.absolutePath;
if (visited.has(resolvedCandidate)) {
continue;
}
visited.add(resolvedCandidate);
scanned += 1;
const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate);
if (
relativePath.toLowerCase().includes(searchLower) ||
entry.name.toLowerCase().includes(searchLower)
) {
ranked.push(
rankDirectory({
absolutePath: resolvedCandidate,
homeRoot: input.homeRoot,
searchLower,
})
);
}
if (
current.depth < input.maxDepth &&
scanned < input.maxDirectoriesScanned
) {
queue.push({ directory: resolvedCandidate, depth: current.depth + 1 });
}
}
}
return dedupeAndSort(ranked).slice(0, input.limit);
}
function dedupeAndSort(ranked: RankedDirectory[]): string[] {
const byPath = new Map<string, RankedDirectory>();
for (const entry of ranked) {
const existing = byPath.get(entry.absolutePath);
if (!existing || compareRankedDirectories(entry, existing) < 0) {
byPath.set(entry.absolutePath, entry);
}
}
return Array.from(byPath.values())
.sort(compareRankedDirectories)
.map((entry) => entry.absolutePath);
}
function compareRankedDirectories(
left: RankedDirectory,
right: RankedDirectory
): number {
if (left.matchTier !== right.matchTier) {
return left.matchTier - right.matchTier;
}
if (left.segmentIndex !== right.segmentIndex) {
return left.segmentIndex - right.segmentIndex;
}
if (left.matchOffset !== right.matchOffset) {
return left.matchOffset - right.matchOffset;
}
if (left.depth !== right.depth) {
return left.depth - right.depth;
}
return left.absolutePath.localeCompare(right.absolutePath);
}
function rankDirectory(input: {
absolutePath: string;
homeRoot: string;
searchLower: string;
}): RankedDirectory {
const relative = normalizeRelativePath(input.homeRoot, input.absolutePath);
const relativeLower = relative.toLowerCase();
const depth = relative === "." ? 0 : relative.split("/").length;
const searchLower = input.searchLower;
if (!searchLower) {
return {
absolutePath: input.absolutePath,
matchTier: 3,
segmentIndex: NO_SEGMENT_INDEX,
matchOffset: 0,
depth,
};
}
const segments = relativeLower === "." ? [] : relativeLower.split("/");
const exactSegmentIndex = findSegmentMatchIndex(
segments,
(segment) => segment === searchLower
);
const prefixSegmentIndex = findSegmentMatchIndex(
segments,
(segment) => segment.startsWith(searchLower)
);
const partialSegmentIndex = findSegmentMatchIndex(
segments,
(segment) => segment.includes(searchLower)
);
const matchOffset = relativeLower.indexOf(searchLower);
let matchTier = 4;
let segmentIndex = NO_SEGMENT_INDEX;
if (exactSegmentIndex >= 0) {
matchTier = 0;
segmentIndex = exactSegmentIndex;
} else if (prefixSegmentIndex >= 0) {
matchTier = 1;
segmentIndex = prefixSegmentIndex;
} else if (partialSegmentIndex >= 0) {
matchTier = 2;
segmentIndex = partialSegmentIndex;
} else if (relativeLower.startsWith(searchLower)) {
matchTier = 3;
}
return {
absolutePath: input.absolutePath,
matchTier,
segmentIndex,
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
depth,
};
}
function findSegmentMatchIndex(
segments: string[],
predicate: (segment: string) => boolean
): number {
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
if (!segment) {
continue;
}
if (predicate(segment)) {
return index;
}
}
return -1;
}
function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
const relative = path.relative(homeRoot, absolutePath);
if (!relative) {
return ".";
}
return relative.split(path.sep).join("/");
}
function isPathInsideRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
const typedQuery = query.trim().replace(/\\/g, "/");
let normalized = typedQuery;
if (!normalized) {
return null;
}
if (normalized.startsWith("~")) {
normalized = normalized.slice(1);
if (normalized.startsWith("/")) {
normalized = normalized.slice(1);
}
}
if (path.isAbsolute(normalized)) {
const absolute = path.resolve(normalized);
if (!isPathInsideRoot(homeRoot, absolute)) {
return null;
}
normalized = normalizeRelativePath(homeRoot, absolute);
}
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
if (!normalized) {
// Treat "~" and "~/" as a request to browse the home root.
if (typedQuery === "~" || typedQuery === "~/") {
return {
isPathQuery: true,
parentPart: "",
searchTerm: "",
};
}
return null;
}
const isPathQuery = normalized.includes("/");
const slashIndex = normalized.lastIndexOf("/");
const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : "";
const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
return {
isPathQuery,
parentPart,
searchTerm,
};
}
async function resolveDirectory(inputPath: string): Promise<string | null> {
try {
const resolved = await realpath(path.resolve(inputPath));
const stats = await stat(resolved);
if (!stats.isDirectory()) {
return null;
}
return resolved;
} catch {
return null;
}
}
async function listChildDirectories(input: {
directory: string;
homeRoot: string;
}): Promise<ChildDirectoryEntry[]> {
const now = Date.now();
const cached = directoryListCache.get(input.directory);
if (cached && cached.expiresAt > now) {
return cached.entries;
}
const dirents = await readdir(input.directory, { withFileTypes: true }).catch(
() => [] as Dirent[]
);
const entries: ChildDirectoryEntry[] = [];
for (const dirent of dirents) {
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) {
continue;
}
const candidatePath = path.join(input.directory, dirent.name);
const absolutePath = await resolveDirectoryCandidate({
candidatePath,
dirent,
homeRoot: input.homeRoot,
});
if (!absolutePath) {
continue;
}
entries.push({
name: dirent.name,
absolutePath,
});
}
setDirectoryListCache(input.directory, {
expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS,
entries,
});
return entries;
}
async function resolveDirectoryCandidate(input: {
candidatePath: string;
dirent: Dirent;
homeRoot: string;
}): Promise<string | null> {
if (input.dirent.isDirectory()) {
const resolved = path.resolve(input.candidatePath);
return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null;
}
const resolved = await resolveDirectory(input.candidatePath);
if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) {
return null;
}
return resolved;
}
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
directoryListCache.set(cacheKey, entry);
pruneDirectoryListCache();
}
function pruneDirectoryListCache(): void {
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
return;
}
const now = Date.now();
for (const [cacheKey, entry] of directoryListCache) {
if (entry.expiresAt <= now) {
directoryListCache.delete(cacheKey);
}
}
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
const oldestKey = directoryListCache.keys().next().value as string | undefined;
if (!oldestKey) {
return;
}
directoryListCache.delete(oldestKey);
}
}
+52 -1
View File
@@ -2,11 +2,26 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { isAbsolute, join, resolve } from "path";
import { z } from "zod";
const PaseoWorktreeMetadataSchema = z.object({
const PaseoWorktreeMetadataV1Schema = z.object({
version: z.literal(1),
baseRefName: z.string().min(1),
});
const PaseoWorktreeMetadataV2Schema = z.object({
version: z.literal(2),
baseRefName: z.string().min(1),
runtime: z
.object({
worktreePort: z.number().int().positive(),
})
.optional(),
});
const PaseoWorktreeMetadataSchema = z.union([
PaseoWorktreeMetadataV1Schema,
PaseoWorktreeMetadataV2Schema,
]);
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
@@ -68,6 +83,31 @@ export function writePaseoWorktreeMetadata(
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
}
export function writePaseoWorktreeRuntimeMetadata(
worktreeRoot: string,
options: { worktreePort: number }
): void {
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
}
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current) {
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
}
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
runtime: {
worktreePort: options.worktreePort,
},
};
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
if (!existsSync(metadataPath)) {
@@ -85,3 +125,14 @@ export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
}
return metadata.baseRefName;
}
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
if (!metadata) {
return null;
}
if (metadata.version === 2 && metadata.runtime?.worktreePort) {
return metadata.runtime.worktreePort;
}
return null;
}
@@ -5,6 +5,7 @@ import {
getWorktreeTerminalSpecs,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
resolveWorktreeRuntimeEnv,
type WorktreeSetupCommandProgressEvent,
runWorktreeSetupCommands,
slugify,
@@ -14,6 +15,7 @@ import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import net from "node:net";
describe("createWorktree", () => {
let tempDir: string;
@@ -265,6 +267,68 @@ describe("createWorktree", () => {
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
});
it("reuses persisted worktree runtime port across resolutions", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "runtime-env-port-reuse",
runSetup: false,
paseoHome,
});
const first = await resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
const second = await resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT);
});
it("fails runtime env resolution when persisted port is in use", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "runtime-env-port-conflict",
runSetup: false,
paseoHome,
});
const env = await resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
});
const port = Number(env.PASEO_WORKTREE_PORT);
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, () => resolve());
});
await expect(
resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
})
).rejects.toThrow(`Persisted worktree port ${port} is already in use`);
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
it("cleans up worktree if setup command fails", async () => {
// Create paseo.json with failing setup command
const paseoConfig = {
+81 -14
View File
@@ -4,7 +4,13 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createNameId } from "mnemonic-id";
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
readPaseoWorktreeRuntimePort,
writePaseoWorktreeMetadata,
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
interface PaseoConfig {
@@ -26,6 +32,14 @@ export interface WorktreeConfig {
worktreePath: string;
}
export type WorktreeRuntimeEnv = {
PASEO_SOURCE_CHECKOUT_PATH: string;
PASEO_ROOT_PATH: string;
PASEO_WORKTREE_PATH: string;
PASEO_BRANCH_NAME: string;
PASEO_WORKTREE_PORT: string;
};
export type WorktreeSetupCommandResult = {
command: string;
cwd: string;
@@ -327,6 +341,29 @@ async function getAvailablePort(): Promise<number> {
});
}
async function assertPortAvailable(port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => {
const message = error?.code === "EADDRINUSE"
? `Persisted worktree port ${port} is already in use`
: error instanceof Error
? error.message
: String(error);
reject(new Error(message));
});
server.listen(port, () => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
}
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(worktreePath);
@@ -360,6 +397,7 @@ export async function runWorktreeSetupCommands(options: {
branchName: string;
cleanupOnFailure: boolean;
repoRootPath?: string;
runtimeEnv?: WorktreeRuntimeEnv;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
@@ -368,21 +406,16 @@ export async function runWorktreeSetupCommands(options: {
return [];
}
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const worktreePort = await getAvailablePort();
const runtimeEnv =
options.runtimeEnv ??
(await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = {
...process.env,
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
// source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: options.branchName,
PASEO_WORKTREE_PORT: String(worktreePort),
...runtimeEnv,
};
const results: WorktreeSetupCommandResult[] = [];
@@ -439,6 +472,40 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<s
return basename(worktreePath);
}
export async function resolveWorktreeRuntimeEnv(options: {
worktreePath: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeRuntimeEnv> {
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
if (worktreePort === null) {
worktreePort = await getAvailablePort();
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
if (metadata) {
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
}
} else {
await assertPortAvailable(worktreePort);
}
return {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
// source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
PASEO_WORKTREE_PORT: String(worktreePort),
};
}
export async function runWorktreeDestroyCommands(options: {
worktreePath: string;
branchName?: string;