Merge branch 'main' into storage-terminal-ui-dev
This commit is contained in:
@@ -39,13 +39,12 @@ import type {
|
||||
ListCommandsResponse,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
SpeechModelsListResponse,
|
||||
SpeechModelsDownloadResponse,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
TerminalState,
|
||||
KillTerminalResponse,
|
||||
CaptureTerminalResponse,
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
@@ -216,8 +215,6 @@ type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
|
||||
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
@@ -240,6 +237,7 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
|
||||
type ChatCreatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "chat/create/response" }
|
||||
@@ -2485,32 +2483,6 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listSpeechModels(requestId?: string): Promise<SpeechModelsListPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "speech_models_list_request",
|
||||
},
|
||||
responseType: "speech_models_list_response",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async downloadSpeechModels(options?: {
|
||||
modelIds?: string[];
|
||||
requestId?: string;
|
||||
}): Promise<SpeechModelsDownloadPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "speech_models_download_request",
|
||||
modelIds: options?.modelIds,
|
||||
},
|
||||
responseType: "speech_models_download_response",
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
|
||||
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
|
||||
async listCommands(
|
||||
@@ -2737,11 +2709,11 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listTerminals(cwd: string, requestId?: string): Promise<ListTerminalsPayload> {
|
||||
async listTerminals(cwd?: string, requestId?: string): Promise<ListTerminalsPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "list_terminals_request",
|
||||
cwd,
|
||||
...(cwd === undefined ? {} : { cwd }),
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
@@ -2859,6 +2831,29 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async captureTerminal(
|
||||
terminalId: string,
|
||||
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<CaptureTerminalPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "capture_terminal_request",
|
||||
terminalId,
|
||||
...(options?.start === undefined ? {} : { start: options.start }),
|
||||
...(options?.end === undefined ? {} : { end: options.end }),
|
||||
...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }),
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
responseType: "capture_terminal_response",
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
});
|
||||
}
|
||||
|
||||
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options.requestId,
|
||||
|
||||
@@ -91,7 +91,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
|
||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
@@ -116,7 +116,6 @@ import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
@@ -187,10 +186,6 @@ export type PaseoDaemonConfig = {
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
pidLock?: {
|
||||
mode?: "self" | "external";
|
||||
ownerPid?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface PaseoDaemon {
|
||||
@@ -459,7 +454,6 @@ export async function createPaseoDaemon(
|
||||
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
|
||||
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
||||
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
|
||||
let unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
|
||||
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
|
||||
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
|
||||
@@ -622,21 +616,12 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
},
|
||||
});
|
||||
const {
|
||||
resolveVoiceTurnDetection,
|
||||
resolveVoiceStt,
|
||||
resolveVoiceTts,
|
||||
resolveDictationStt,
|
||||
getSpeechReadiness,
|
||||
subscribeSpeechReadiness,
|
||||
cleanup: cleanupSpeechRuntime,
|
||||
localModelConfig,
|
||||
} = await initializeSpeechRuntime({
|
||||
const speechService = createSpeechService({
|
||||
logger,
|
||||
openaiConfig: config.openai,
|
||||
speechConfig: config.speech,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Speech runtime initialized");
|
||||
logger.info({ elapsed: elapsed() }, "Speech service created");
|
||||
|
||||
wsServer = new VoiceAssistantWebSocketServer(
|
||||
httpServer,
|
||||
@@ -648,7 +633,7 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
createInMemoryAgentMcpTransport,
|
||||
{ allowedOrigins, allowedHosts: config.allowedHosts },
|
||||
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts },
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
voiceAgentMcpStdio: {
|
||||
@@ -666,9 +651,6 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
{
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
stt: resolveDictationStt,
|
||||
localModels: localModelConfig ?? undefined,
|
||||
getSpeechReadiness,
|
||||
},
|
||||
config.agentProviderSettings,
|
||||
daemonVersion,
|
||||
@@ -686,9 +668,6 @@ export async function createPaseoDaemon(
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
);
|
||||
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
|
||||
wsServer?.publishSpeechReadiness(snapshot);
|
||||
});
|
||||
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
@@ -724,6 +703,16 @@ export async function createPaseoDaemon(
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
||||
process.send({
|
||||
type: "paseo:ready",
|
||||
listen:
|
||||
boundListenTarget.type === "tcp"
|
||||
? `${boundListenTarget.host}:${boundListenTarget.port}`
|
||||
: boundListenTarget.path,
|
||||
});
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
@@ -764,6 +753,10 @@ export async function createPaseoDaemon(
|
||||
httpServer.listen(listenTarget.path);
|
||||
}
|
||||
});
|
||||
|
||||
// Start speech service after listening so synchronous Sherpa native
|
||||
// model loading doesn't block the server from accepting connections.
|
||||
speechService.start();
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
@@ -773,9 +766,7 @@ export async function createPaseoDaemon(
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
});
|
||||
terminalManager.killAll();
|
||||
unsubscribeSpeechReadiness?.();
|
||||
unsubscribeSpeechReadiness = null;
|
||||
cleanupSpeechRuntime();
|
||||
speechService.stop();
|
||||
await scheduleService.stop().catch(() => undefined);
|
||||
await relayTransport?.stop().catch(() => undefined);
|
||||
if (wsServer) {
|
||||
@@ -792,12 +783,6 @@ export async function createPaseoDaemon(
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
// Release PID lock
|
||||
if (ownsPidLock) {
|
||||
await releasePidLock(config.paseoHome, {
|
||||
ownerPid: pidLockOwnerPid,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -730,7 +730,7 @@ describe("daemon E2E", () => {
|
||||
});
|
||||
|
||||
describe("archivePaseoWorktree", () => {
|
||||
test("archives worktree by running destroy commands and shutting down worktree terminals", async () => {
|
||||
test("archives worktree by running teardown commands and shutting down worktree terminals", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
@@ -749,7 +749,7 @@ describe("daemon E2E", () => {
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt");
|
||||
const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt");
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
@@ -760,12 +760,12 @@ describe("daemon E2E", () => {
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
destroy: [`echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`],
|
||||
teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", {
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
@@ -813,8 +813,8 @@ describe("daemon E2E", () => {
|
||||
expect(archive.removedAgents).toContain(agent.id);
|
||||
|
||||
expect(existsSync(agent.cwd)).toBe(false);
|
||||
expect(existsSync(destroyMarkerPath)).toBe(true);
|
||||
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd);
|
||||
expect(existsSync(teardownMarkerPath)).toBe(true);
|
||||
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
|
||||
|
||||
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
|
||||
expect(afterArchiveDirectories).not.toContain(agent.cwd);
|
||||
|
||||
@@ -415,12 +415,17 @@ async function subscribeRawTerminal(
|
||||
|
||||
describe("daemon E2E terminal", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
let tempDirs: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
@@ -1090,4 +1095,161 @@ describe("daemon E2E terminal", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 40000);
|
||||
|
||||
describe("capture", () => {
|
||||
test("captures visible terminal output as plain text", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo hello world\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
|
||||
|
||||
const capture = await ctx.client.captureTerminal(terminalId);
|
||||
|
||||
expect(capture.lines.join("\n")).toContain("hello world");
|
||||
expect(capture.totalLines).toBeGreaterThan(0);
|
||||
}, 15000);
|
||||
|
||||
test("captures with start/end line range", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo line1\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo line2\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo line3\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000);
|
||||
|
||||
const fullCapture = await ctx.client.captureTerminal(terminalId);
|
||||
const rangedCapture = await ctx.client.captureTerminal(terminalId, {
|
||||
start: 0,
|
||||
end: 2,
|
||||
});
|
||||
|
||||
expect(rangedCapture.lines).toHaveLength(3);
|
||||
expect(rangedCapture.totalLines).toBe(fullCapture.totalLines);
|
||||
}, 15000);
|
||||
|
||||
test("supports negative line indices", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo alpha\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo beta\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "echo gamma\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000);
|
||||
|
||||
const capture = await ctx.client.captureTerminal(terminalId, {
|
||||
start: -3,
|
||||
});
|
||||
|
||||
expect(capture.lines).toHaveLength(3);
|
||||
}, 15000);
|
||||
|
||||
test("strips ANSI by default", async () => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "printf '\\033[31mred text\\033[0m\\n'\r",
|
||||
});
|
||||
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
|
||||
|
||||
const capture = await ctx.client.captureTerminal(terminalId);
|
||||
const capturedText = capture.lines.join("\n");
|
||||
|
||||
expect(capturedText).toContain("red text");
|
||||
expect(capturedText).not.toContain("\u001b[31m");
|
||||
}, 15000);
|
||||
|
||||
test("returns empty for non-existent terminal", async () => {
|
||||
const capture = await ctx.client.captureTerminal("terminal-does-not-exist");
|
||||
|
||||
expect(capture.lines).toEqual([]);
|
||||
expect(capture.totalLines).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list terminals across directories", () => {
|
||||
test("lists terminals from all directories when cwd is omitted", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
const cwd2 = tmpCwd();
|
||||
tempDirs.push(cwd1, cwd2);
|
||||
|
||||
const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal");
|
||||
const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal");
|
||||
|
||||
const list = await ctx.client.listTerminals();
|
||||
|
||||
expect(list).not.toHaveProperty("cwd");
|
||||
expect(list.terminals).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: firstCreated.terminal!.id,
|
||||
name: "first-terminal",
|
||||
},
|
||||
{
|
||||
id: secondCreated.terminal!.id,
|
||||
name: "second-terminal",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists terminals for specific directory when cwd is provided", async () => {
|
||||
const cwd1 = tmpCwd();
|
||||
const cwd2 = tmpCwd();
|
||||
tempDirs.push(cwd1, cwd2);
|
||||
|
||||
const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal");
|
||||
await ctx.client.createTerminal(cwd2, "cwd-two-terminal");
|
||||
|
||||
const list = await ctx.client.listTerminals(cwd1);
|
||||
|
||||
expect(list.cwd).toBe(cwd1);
|
||||
expect(list.terminals).toEqual([
|
||||
{
|
||||
id: firstCreated.terminal!.id,
|
||||
name: "cwd-one-terminal",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,14 +131,10 @@ async function main() {
|
||||
};
|
||||
|
||||
try {
|
||||
const pidLockMode = process.env.PASEO_PID_LOCK_MODE === "external" ? "external" : "self";
|
||||
daemon = await createPaseoDaemon(
|
||||
{
|
||||
...config,
|
||||
onLifecycleIntent: handleLifecycleIntent,
|
||||
pidLock: {
|
||||
mode: pidLockMode,
|
||||
},
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { acquirePidLock, getPidLockInfo, releasePidLock } from "./pid-lock.js";
|
||||
import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js";
|
||||
|
||||
describe("pid-lock ownership", () => {
|
||||
test("writes and releases lock for explicit owner pid", async () => {
|
||||
@@ -14,13 +14,25 @@ describe("pid-lock ownership", () => {
|
||||
await (
|
||||
acquirePidLock as unknown as (
|
||||
home: string,
|
||||
sockPath: string,
|
||||
sockPath: string | null,
|
||||
options: { ownerPid: number },
|
||||
) => Promise<void>
|
||||
)(paseoHome, "127.0.0.1:6767", { ownerPid });
|
||||
)(paseoHome, null, { ownerPid });
|
||||
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.pid).toBe(ownerPid);
|
||||
expect(lock?.listen).toBeNull();
|
||||
|
||||
await (
|
||||
updatePidLock as unknown as (
|
||||
home: string,
|
||||
patch: { listen: string },
|
||||
options: { ownerPid: number },
|
||||
) => Promise<void>
|
||||
)(paseoHome, { listen: "127.0.0.1:6767" }, { ownerPid });
|
||||
|
||||
const updatedLock = await getPidLockInfo(paseoHome);
|
||||
expect(updatedLock?.listen).toBe("127.0.0.1:6767");
|
||||
|
||||
await (
|
||||
releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PidLockInfo {
|
||||
startedAt: string;
|
||||
hostname: string;
|
||||
uid: number;
|
||||
listen: string;
|
||||
listen: string | null;
|
||||
}
|
||||
|
||||
export class PidLockError extends Error {
|
||||
@@ -43,7 +43,7 @@ function resolveOwnerPid(ownerPid?: number): number {
|
||||
|
||||
export async function acquirePidLock(
|
||||
paseoHome: string,
|
||||
listen: string,
|
||||
listen: string | null,
|
||||
options?: { ownerPid?: number },
|
||||
): Promise<void> {
|
||||
const pidPath = getPidFilePath(paseoHome);
|
||||
@@ -114,6 +114,37 @@ export async function acquirePidLock(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePidLock(
|
||||
paseoHome: string,
|
||||
patch: { listen: string },
|
||||
options?: { ownerPid?: number },
|
||||
): Promise<void> {
|
||||
const pidPath = getPidFilePath(paseoHome);
|
||||
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
const existingLock = JSON.parse(content) as PidLockInfo;
|
||||
|
||||
if (existingLock.pid !== lockOwnerPid) {
|
||||
throw new PidLockError(
|
||||
`Cannot update PID lock owned by PID ${existingLock.pid}`,
|
||||
existingLock,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedLock: PidLockInfo = {
|
||||
...existingLock,
|
||||
...patch,
|
||||
};
|
||||
|
||||
const fd = await open(pidPath, "r+");
|
||||
try {
|
||||
await fd.truncate(0);
|
||||
await fd.writeFile(JSON.stringify(updatedLock));
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function releasePidLock(
|
||||
paseoHome: string,
|
||||
options?: { ownerPid?: number },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import type { PaseoSpeechConfig } from "../bootstrap.js";
|
||||
import type { InitializedLocalSpeech } from "./providers/local/runtime.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./turn-detection-provider.js";
|
||||
import { initializeSpeechRuntime } from "./speech-runtime.js";
|
||||
import { createSpeechService } from "./speech-runtime.js";
|
||||
|
||||
const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({
|
||||
initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(),
|
||||
@@ -70,7 +70,7 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe
|
||||
return { providers };
|
||||
}
|
||||
|
||||
describe("initializeSpeechRuntime readiness", () => {
|
||||
describe("createSpeechService readiness", () => {
|
||||
beforeEach(() => {
|
||||
initializeLocalSpeechServicesMock.mockReset();
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
cleanup: () => {},
|
||||
});
|
||||
|
||||
const runtime = await initializeSpeechRuntime({
|
||||
const runtime = createSpeechService({
|
||||
logger: pino({ level: "silent" }),
|
||||
speechConfig: createSpeechConfig({
|
||||
dictationStt: { provider: "local", enabled: true, explicit: true },
|
||||
@@ -101,14 +101,16 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
voiceTts: { provider: "local", enabled: false, explicit: true },
|
||||
}),
|
||||
});
|
||||
runtime.start();
|
||||
await runtime.ready;
|
||||
|
||||
const readiness = runtime.getSpeechReadiness();
|
||||
const readiness = runtime.getReadiness();
|
||||
expect(readiness.dictation.available).toBe(true);
|
||||
expect(readiness.realtimeVoice.reasonCode).toBe("disabled");
|
||||
expect(readiness.voiceFeature.available).toBe(true);
|
||||
expect(readiness.voiceFeature.reasonCode).toBe("ready");
|
||||
|
||||
runtime.cleanup();
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it("keeps voice feature available when only realtime voice is enabled and ready", async () => {
|
||||
@@ -130,7 +132,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
cleanup: () => {},
|
||||
});
|
||||
|
||||
const runtime = await initializeSpeechRuntime({
|
||||
const runtime = createSpeechService({
|
||||
logger: pino({ level: "silent" }),
|
||||
speechConfig: createSpeechConfig({
|
||||
dictationStt: { provider: "local", enabled: false, explicit: true },
|
||||
@@ -139,13 +141,15 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
voiceTts: { provider: "local", enabled: true, explicit: true },
|
||||
}),
|
||||
});
|
||||
runtime.start();
|
||||
await runtime.ready;
|
||||
|
||||
const readiness = runtime.getSpeechReadiness();
|
||||
const readiness = runtime.getReadiness();
|
||||
expect(readiness.realtimeVoice.available).toBe(true);
|
||||
expect(readiness.dictation.reasonCode).toBe("disabled");
|
||||
expect(readiness.voiceFeature.available).toBe(true);
|
||||
expect(readiness.voiceFeature.reasonCode).toBe("ready");
|
||||
|
||||
runtime.cleanup();
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,25 +336,23 @@ function resolveEffectiveProviderIds(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export type InitializedSpeechRuntime = {
|
||||
resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
|
||||
resolveVoiceStt: () => SpeechToTextProvider | null;
|
||||
resolveVoiceTts: () => TextToSpeechProvider | null;
|
||||
export type SpeechService = {
|
||||
resolveStt: () => SpeechToTextProvider | null;
|
||||
resolveTts: () => TextToSpeechProvider | null;
|
||||
resolveTurnDetection: () => TurnDetectionProvider | null;
|
||||
resolveDictationStt: () => SpeechToTextProvider | null;
|
||||
getSpeechReadiness: () => SpeechReadinessSnapshot;
|
||||
subscribeSpeechReadiness: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
||||
cleanup: () => void;
|
||||
localModelConfig: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
} | null;
|
||||
getReadiness: () => SpeechReadinessSnapshot;
|
||||
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
ready: Promise<void>;
|
||||
};
|
||||
|
||||
export async function initializeSpeechRuntime(params: {
|
||||
export function createSpeechService(params: {
|
||||
logger: Logger;
|
||||
openaiConfig?: PaseoOpenAIConfig;
|
||||
speechConfig?: PaseoSpeechConfig;
|
||||
}): Promise<InitializedSpeechRuntime> {
|
||||
}): SpeechService {
|
||||
const logger = params.logger.child({ module: "speech-runtime" });
|
||||
const speechConfig = params.speechConfig ?? null;
|
||||
const openaiConfig = params.openaiConfig;
|
||||
@@ -397,6 +395,14 @@ export async function initializeSpeechRuntime(params: {
|
||||
const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>();
|
||||
let lastReadinessFingerprint: string | null = null;
|
||||
let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null;
|
||||
let started = false;
|
||||
let readySettled = false;
|
||||
let resolveReady!: () => void;
|
||||
let rejectReady!: (error: unknown) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
|
||||
const realtimeVoice = buildRealtimeVoiceReadiness({
|
||||
@@ -658,16 +664,36 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
};
|
||||
|
||||
await runReconcile();
|
||||
const snapshot = computeReadinessSnapshot();
|
||||
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
|
||||
if (missingLocalModelIds.length > 0) {
|
||||
startBackgroundDownload();
|
||||
const start = (): void => {
|
||||
if (started || stopped) {
|
||||
return;
|
||||
}
|
||||
scheduleMonitor();
|
||||
}
|
||||
started = true;
|
||||
void (async () => {
|
||||
try {
|
||||
await runReconcile();
|
||||
const snapshot = computeReadinessSnapshot();
|
||||
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
|
||||
if (missingLocalModelIds.length > 0) {
|
||||
startBackgroundDownload();
|
||||
}
|
||||
scheduleMonitor();
|
||||
}
|
||||
if (!readySettled) {
|
||||
readySettled = true;
|
||||
resolveReady();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!readySettled) {
|
||||
readySettled = true;
|
||||
rejectReady(error);
|
||||
}
|
||||
logger.error({ err: error }, "Speech runtime failed during initial reconcile");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
const stop = (): void => {
|
||||
stopped = true;
|
||||
if (monitorTimeout) {
|
||||
clearTimeout(monitorTimeout);
|
||||
@@ -677,13 +703,14 @@ export async function initializeSpeechRuntime(params: {
|
||||
};
|
||||
|
||||
return {
|
||||
resolveVoiceTurnDetection: () => turnDetectionService,
|
||||
resolveVoiceStt: () => sttService,
|
||||
resolveVoiceTts: () => ttsService,
|
||||
resolveTurnDetection: () => turnDetectionService,
|
||||
resolveStt: () => sttService,
|
||||
resolveTts: () => ttsService,
|
||||
resolveDictationStt: () => dictationSttService,
|
||||
getSpeechReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||
subscribeSpeechReadiness,
|
||||
cleanup,
|
||||
localModelConfig,
|
||||
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||
onReadinessChange: subscribeSpeechReadiness,
|
||||
start,
|
||||
stop,
|
||||
ready,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,11 +33,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
|
||||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import {
|
||||
computeShouldNotifyClient,
|
||||
@@ -244,18 +240,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly pushService: PushService;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly stt: Resolvable<SpeechToTextProvider | null>;
|
||||
private readonly tts: Resolvable<TextToSpeechProvider | null>;
|
||||
private readonly turnDetection: Resolvable<TurnDetectionProvider | null>;
|
||||
private readonly speech: SpeechService | null;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly dictation: {
|
||||
finalTimeoutMs?: number;
|
||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||
localModels?: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
};
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
} | null;
|
||||
private readonly voice: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
@@ -289,6 +277,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly inboundSessionRequestCounts = new Map<string, number>();
|
||||
private readonly requestLatencies = new Map<string, number[]>();
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private unsubscribeSpeechReadiness: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
server: HTTPServer,
|
||||
@@ -300,11 +289,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
paseoHome: string,
|
||||
createAgentMcpTransport: AgentMcpTransportFactory,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
speech?: {
|
||||
turnDetection: Resolvable<TurnDetectionProvider | null>;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
},
|
||||
speech?: SpeechService | null,
|
||||
terminalManager?: TerminalManager | null,
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||
@@ -313,12 +298,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
},
|
||||
dictation?: {
|
||||
finalTimeoutMs?: number;
|
||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||
localModels?: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
};
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
},
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
daemonVersion?: string,
|
||||
@@ -359,17 +338,18 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.turnDetection = speech?.turnDetection ?? null;
|
||||
this.stt = speech?.stt ?? null;
|
||||
this.tts = speech?.tts ?? null;
|
||||
this.speech = speech ?? null;
|
||||
this.terminalManager = terminalManager ?? null;
|
||||
this.voice = voice ?? null;
|
||||
this.dictation = dictation ?? null;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.serverCapabilities = buildServerCapabilities({
|
||||
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
|
||||
readiness: this.speech?.getReadiness() ?? null,
|
||||
});
|
||||
this.unsubscribeSpeechReadiness = this.speech?.onReadinessChange((snapshot) => {
|
||||
this.publishSpeechReadiness(snapshot);
|
||||
}) ?? null;
|
||||
|
||||
const pushLogger = this.logger.child({ module: "push" });
|
||||
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
|
||||
@@ -460,6 +440,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.unsubscribeSpeechReadiness?.();
|
||||
this.unsubscribeSpeechReadiness = null;
|
||||
if (this.runtimeMetricsInterval) {
|
||||
clearInterval(this.runtimeMetricsInterval);
|
||||
this.runtimeMetricsInterval = null;
|
||||
@@ -661,12 +643,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
scheduleService: this.scheduleService,
|
||||
checkoutDiffManager: this.checkoutDiffManager,
|
||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||
stt: this.stt,
|
||||
tts: this.tts,
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
tts: () => this.speech?.resolveTts() ?? null,
|
||||
terminalManager: this.terminalManager,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
turnDetection: this.turnDetection,
|
||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||
},
|
||||
voiceBridge: {
|
||||
registerVoiceSpeakHandler: (agentId, handler) => {
|
||||
@@ -684,7 +666,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent,
|
||||
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
|
||||
},
|
||||
dictation: this.dictation ?? undefined,
|
||||
dictation:
|
||||
this.dictation || this.speech
|
||||
? {
|
||||
finalTimeoutMs: this.dictation?.finalTimeoutMs,
|
||||
stt: () => this.speech?.resolveDictationStt() ?? null,
|
||||
getSpeechReadiness: () => this.speech!.getReadiness(),
|
||||
}
|
||||
: undefined,
|
||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { Logger } from "pino";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import {
|
||||
type GitSetupOptions,
|
||||
type ProjectPlacementPayload,
|
||||
type SessionInboundMessage,
|
||||
type SessionOutboundMessage,
|
||||
type WorkspaceDescriptorPayload,
|
||||
} from "./messages.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { createAgentWorktree } from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import {
|
||||
getCheckoutStatusLite,
|
||||
resolveRepositoryDefaultBranch,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
computeWorktreePath,
|
||||
deletePaseoWorktree,
|
||||
getWorktreeSetupCommands,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||
|
||||
export type NormalizedGitOptions = {
|
||||
baseBranch?: string;
|
||||
createNewBranch: boolean;
|
||||
newBranchName?: string;
|
||||
createWorktree: boolean;
|
||||
worktreeSlug?: string;
|
||||
};
|
||||
|
||||
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
||||
|
||||
type BuildAgentSessionConfigDependencies = {
|
||||
paseoHome?: string;
|
||||
sessionLogger: Logger;
|
||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
|
||||
createBranchFromBase: (params: {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
newBranchName: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
type ArchivePaseoWorktreeDependencies = {
|
||||
paseoHome?: string;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||
buildPersistedProjectRecord: (input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => PersistedProjectRecord;
|
||||
buildPersistedWorkspaceRecord: (input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => PersistedWorkspaceRecord;
|
||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
syncWorkspaceGitWatchTarget: (
|
||||
cwd: string,
|
||||
options: { isGit: boolean },
|
||||
) => Promise<void>;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
|
||||
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
paseoHome?: string;
|
||||
emitWorkspaceUpdateForCwd: (
|
||||
cwd: string,
|
||||
options?: { dedupeGitState?: boolean },
|
||||
) => Promise<void>;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
};
|
||||
|
||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
paseoHome?: string;
|
||||
describeWorkspaceRecord: (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
emit: EmitSessionMessage;
|
||||
registerPendingWorktreeWorkspace: (options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}) => Promise<PersistedWorkspaceRecord>;
|
||||
sessionLogger: Logger;
|
||||
createPaseoWorktreeInBackground: (options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
baseBranch: string;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
type KillTerminalsUnderPathDependencies = {
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
};
|
||||
|
||||
export async function buildAgentSessionConfig(
|
||||
dependencies: BuildAgentSessionConfigDependencies,
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
_labels?: Record<string, string>,
|
||||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let worktreeConfig: WorktreeConfig | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.createWorktree) {
|
||||
let targetBranch: string;
|
||||
|
||||
if (normalized.createNewBranch) {
|
||||
targetBranch = normalized.newBranchName!;
|
||||
} else {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
targetBranch = stdout.trim();
|
||||
}
|
||||
|
||||
if (!targetBranch) {
|
||||
throw new Error("A branch name is required when creating a worktree.");
|
||||
}
|
||||
|
||||
dependencies.sessionLogger.info(
|
||||
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
|
||||
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`,
|
||||
);
|
||||
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
baseBranch,
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
cwd = createdWorktree.worktreePath;
|
||||
worktreeConfig = createdWorktree;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
await dependencies.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch,
|
||||
newBranchName: normalized.newBranchName!,
|
||||
});
|
||||
} else if (normalized.baseBranch) {
|
||||
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
worktreeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGitOptions(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
): NormalizedGitOptions | null {
|
||||
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
|
||||
? {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: legacyWorktreeName,
|
||||
worktreeSlug: legacyWorktreeName,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const merged = gitOptions ?? fallbackOptions;
|
||||
if (!merged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseBranch = merged.baseBranch?.trim() || undefined;
|
||||
const createWorktree = Boolean(merged.createWorktree);
|
||||
const createNewBranch = Boolean(merged.createNewBranch);
|
||||
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
|
||||
const normalizedWorktreeSlug = merged.worktreeSlug
|
||||
? slugify(merged.worktreeSlug)
|
||||
: normalizedBranchName;
|
||||
|
||||
if (!createWorktree && !createNewBranch && !baseBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseBranch) {
|
||||
assertSafeGitRef(baseBranch, "base branch");
|
||||
}
|
||||
|
||||
if (createNewBranch) {
|
||||
if (!normalizedBranchName) {
|
||||
throw new Error("New branch name is required");
|
||||
}
|
||||
const validation = validateBranchSlug(normalizedBranchName);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid branch name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedWorktreeSlug) {
|
||||
const validation = validateBranchSlug(normalizedWorktreeSlug);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseBranch,
|
||||
createNewBranch,
|
||||
newBranchName: normalizedBranchName,
|
||||
createWorktree,
|
||||
worktreeSlug: normalizedWorktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitCreateBaseBranch(
|
||||
cwd: string,
|
||||
paseoHome?: string,
|
||||
): Promise<string> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Cannot create a worktree outside a git repository");
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
return baseBranch;
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeListRequest(
|
||||
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
const cwd = msg.repoRoot ?? msg.cwd;
|
||||
if (!cwd) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
worktrees: [],
|
||||
error: { code: "UNKNOWN", message: "cwd or repoRoot is required" },
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome });
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
worktrees: worktrees.map((entry) => ({
|
||||
worktreePath: entry.path,
|
||||
createdAt: entry.createdAt,
|
||||
branchName: entry.branchName ?? null,
|
||||
head: entry.head ?? null,
|
||||
})),
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
worktrees: [],
|
||||
error: toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function archivePaseoWorktree(
|
||||
dependencies: ArchivePaseoWorktreeDependencies,
|
||||
options: {
|
||||
targetPath: string;
|
||||
repoRoot: string;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let targetPath = options.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
|
||||
const removedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath]);
|
||||
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
|
||||
const agents = dependencies.agentManager.listAgents();
|
||||
for (const agent of agents) {
|
||||
if (!dependencies.isPathWithinRoot(targetPath, agent.cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
removedAgents.add(agent.id);
|
||||
affectedWorkspaceCwds.add(agent.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
|
||||
try {
|
||||
await dependencies.agentManager.closeAgent(agent.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
try {
|
||||
await dependencies.agentStorage.remove(agent.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
const registryRecords = await dependencies.agentStorage.list();
|
||||
for (const record of registryRecords) {
|
||||
if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
removedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
|
||||
try {
|
||||
await dependencies.agentStorage.remove(record.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
await dependencies.killTerminalsUnderPath(targetPath);
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
for (const workspaceId of affectedWorkspaceIds) {
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
}
|
||||
|
||||
for (const agentId of removedAgents) {
|
||||
dependencies.emit({
|
||||
type: "agent_deleted",
|
||||
payload: {
|
||||
agentId,
|
||||
requestId: options.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
|
||||
|
||||
return Array.from(removedAgents);
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeArchiveRequest(
|
||||
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
let targetPath = msg.worktreePath;
|
||||
let repoRoot = msg.repoRoot ?? null;
|
||||
|
||||
try {
|
||||
if (!targetPath) {
|
||||
if (!repoRoot || !msg.branchName) {
|
||||
throw new Error("worktreePath or repoRoot+branchName is required");
|
||||
}
|
||||
const worktrees = await listPaseoWorktrees({
|
||||
cwd: repoRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
|
||||
if (!match) {
|
||||
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
|
||||
}
|
||||
targetPath = match.path;
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (!ownership.allowed) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_archive_response",
|
||||
payload: {
|
||||
success: false,
|
||||
removedAgents: [],
|
||||
error: {
|
||||
code: "NOT_ALLOWED",
|
||||
message: "Worktree is not a Paseo-owned worktree",
|
||||
},
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
|
||||
if (!repoRoot) {
|
||||
throw new Error("Unable to resolve repo root for worktree");
|
||||
}
|
||||
|
||||
const removedAgents = await archivePaseoWorktree(dependencies, {
|
||||
targetPath,
|
||||
repoRoot,
|
||||
requestId,
|
||||
});
|
||||
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_archive_response",
|
||||
payload: {
|
||||
success: true,
|
||||
removedAgents,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_archive_response",
|
||||
payload: {
|
||||
success: false,
|
||||
removedAgents: [],
|
||||
error: toCheckoutError(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPendingWorktreeWorkspace(
|
||||
dependencies: RegisterPendingWorktreeWorkspaceDependencies,
|
||||
options: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
},
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
|
||||
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId);
|
||||
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await dependencies.projectRegistry.upsert(nextProjectRecord);
|
||||
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord;
|
||||
}
|
||||
|
||||
export async function handleCreatePaseoWorktreeRequest(
|
||||
dependencies: HandleCreatePaseoWorktreeRequestDependencies,
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const checkout = await getCheckoutStatusLite(request.cwd, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Create worktree requires a git repository");
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
|
||||
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4();
|
||||
const validation = validateBranchSlug(normalizedSlug);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
|
||||
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
|
||||
const workspace = await dependencies.registerPendingWorktreeWorkspace({
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName: normalizedSlug,
|
||||
});
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
workspace: descriptor,
|
||||
error: null,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
|
||||
void dependencies.createPaseoWorktreeInBackground({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot,
|
||||
baseBranch,
|
||||
slug: normalizedSlug,
|
||||
worktreePath,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create worktree";
|
||||
dependencies.sessionLogger.error(
|
||||
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
|
||||
"Failed to create worktree",
|
||||
);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
workspace: null,
|
||||
error: message,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPaseoWorktreeInBackground(
|
||||
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
|
||||
options: {
|
||||
requestCwd: string;
|
||||
repoRoot: string;
|
||||
baseBranch: string;
|
||||
slug: string;
|
||||
worktreePath: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
let setupTerminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await createAgentWorktree({
|
||||
cwd: options.repoRoot,
|
||||
branchName: options.slug,
|
||||
baseBranch: options.baseBranch,
|
||||
worktreeSlug: options.slug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
||||
if (setupCommands.length > 0 && dependencies.terminalManager) {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktreePath,
|
||||
branchName: options.slug,
|
||||
repoRootPath: options.repoRoot,
|
||||
});
|
||||
dependencies.terminalManager.registerCwdEnv({
|
||||
cwd: options.worktreePath,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
const terminal = await dependencies.terminalManager.createTerminal({
|
||||
cwd: options.worktreePath,
|
||||
name: `setup-${options.slug}`,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
setupTerminalId = terminal.id;
|
||||
|
||||
for (const command of setupCommands) {
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: `${command}\r`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: options.slug,
|
||||
worktreePath: options.worktreePath,
|
||||
setupTerminalId,
|
||||
},
|
||||
"Background worktree creation failed",
|
||||
);
|
||||
} finally {
|
||||
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
): Promise<void> {
|
||||
if (!dependencies.terminalManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
|
||||
const terminalDirectories = [...dependencies.terminalManager.listDirectories()];
|
||||
for (const terminalCwd of terminalDirectories) {
|
||||
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const terminals = await dependencies.terminalManager.getTerminals(terminalCwd);
|
||||
for (const terminal of [...terminals]) {
|
||||
dependencies.killTrackedTerminal(terminal.id, { emitExit: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cleanupErrors.push({ cwd: terminalCwd, message });
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, cwd: terminalCwd },
|
||||
"Failed to clean up worktree terminals during archive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupErrors.length > 0) {
|
||||
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; ");
|
||||
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
|
||||
}
|
||||
}
|
||||
@@ -739,17 +739,6 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechModelsListRequestSchema = z.object({
|
||||
type: z.literal("speech_models_list_request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SpeechModelsDownloadRequestSchema = z.object({
|
||||
type: z.literal("speech_models_download_request"),
|
||||
modelIds: z.array(z.string()).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ResumeAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("resume_agent_request"),
|
||||
handle: AgentPersistenceHandleSchema,
|
||||
@@ -1141,7 +1130,7 @@ export const RegisterPushTokenMessageSchema = z.object({
|
||||
|
||||
export const ListTerminalsRequestSchema = z.object({
|
||||
type: z.literal("list_terminals_request"),
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -1200,6 +1189,15 @@ export const KillTerminalRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CaptureTerminalRequestSchema = z.object({
|
||||
type: z.literal("capture_terminal_request"),
|
||||
terminalId: z.string(),
|
||||
start: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
stripAnsi: z.boolean().default(true),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
VoiceAudioChunkMessageSchema,
|
||||
AbortRequestMessageSchema,
|
||||
@@ -1220,8 +1218,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CreateAgentRequestMessageSchema,
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
SpeechModelsListRequestSchema,
|
||||
SpeechModelsDownloadRequestSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
CancelAgentRequestMessageSchema,
|
||||
@@ -1265,6 +1261,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
TerminalInputSchema,
|
||||
KillTerminalRequestSchema,
|
||||
CaptureTerminalRequestSchema,
|
||||
ChatCreateRequestSchema,
|
||||
ChatListRequestSchema,
|
||||
ChatInspectRequestSchema,
|
||||
@@ -2112,34 +2109,6 @@ export const ListAvailableProvidersResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const SpeechModelsListResponseSchema = z.object({
|
||||
type: z.literal("speech_models_list_response"),
|
||||
payload: z.object({
|
||||
modelsDir: z.string(),
|
||||
models: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.string(),
|
||||
description: z.string(),
|
||||
modelDir: z.string(),
|
||||
isDownloaded: z.boolean(),
|
||||
missingFiles: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SpeechModelsDownloadResponseSchema = z.object({
|
||||
type: z.literal("speech_models_download_response"),
|
||||
payload: z.object({
|
||||
modelsDir: z.string(),
|
||||
downloadedModelIds: z.array(z.string()),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const AgentSlashCommandSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -2209,7 +2178,7 @@ export const TerminalStateSchema = z
|
||||
export const ListTerminalsResponseSchema = z.object({
|
||||
type: z.literal("list_terminals_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
@@ -2258,6 +2227,16 @@ export const KillTerminalResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CaptureTerminalResponseSchema = z.object({
|
||||
type: z.literal("capture_terminal_response"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
lines: z.array(z.string()),
|
||||
totalLines: z.number().int().nonnegative(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalStreamExitSchema = z.object({
|
||||
type: z.literal("terminal_stream_exit"),
|
||||
payload: z.object({
|
||||
@@ -2321,14 +2300,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileDownloadTokenResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
SpeechModelsListResponseSchema,
|
||||
SpeechModelsDownloadResponseSchema,
|
||||
ListCommandsResponseSchema,
|
||||
ListTerminalsResponseSchema,
|
||||
TerminalsChangedSchema,
|
||||
CreateTerminalResponseSchema,
|
||||
SubscribeTerminalResponseSchema,
|
||||
KillTerminalResponseSchema,
|
||||
CaptureTerminalResponseSchema,
|
||||
TerminalStreamExitSchema,
|
||||
ChatCreateResponseSchema,
|
||||
ChatListResponseSchema,
|
||||
@@ -2391,8 +2369,6 @@ export type ListProviderModelsResponseMessage = z.infer<
|
||||
typeof ListProviderModelsResponseMessageSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
|
||||
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
|
||||
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
||||
@@ -2453,8 +2429,6 @@ export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
||||
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
||||
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
|
||||
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
|
||||
export type SpeechModelsDownloadRequestMessage = z.infer<typeof SpeechModelsDownloadRequestSchema>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
||||
@@ -2525,6 +2499,8 @@ export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
|
||||
export type TerminalState = z.infer<typeof TerminalStateSchema>;
|
||||
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
||||
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
||||
export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema>;
|
||||
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
|
||||
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -5,6 +5,7 @@ import { chmodSync, existsSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
@@ -62,6 +63,17 @@ interface BuildTerminalEnvironmentInput {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CaptureTerminalLinesOptions {
|
||||
start?: number;
|
||||
end?: number;
|
||||
stripAnsi?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureTerminalLinesResult {
|
||||
lines: string[];
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
type EnsureNodePtySpawnHelperExecutableOptions = {
|
||||
packageRoot?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
@@ -426,6 +438,60 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
|
||||
return lines.slice(-limit);
|
||||
}
|
||||
|
||||
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
|
||||
const text = cells.map((cell) => cell.char).join("").trimEnd();
|
||||
return options.stripAnsi ? stripAnsi(text) : text;
|
||||
}
|
||||
|
||||
function resolveCaptureLineIndex(
|
||||
lineNumber: number | undefined,
|
||||
totalLines: number,
|
||||
fallback: "start" | "end",
|
||||
): number {
|
||||
if (totalLines === 0) {
|
||||
return fallback === "start" ? 0 : -1;
|
||||
}
|
||||
|
||||
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
|
||||
if (typeof lineNumber !== "number") {
|
||||
return defaultIndex;
|
||||
}
|
||||
|
||||
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
|
||||
if (resolvedIndex < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (resolvedIndex >= totalLines) {
|
||||
return totalLines - 1;
|
||||
}
|
||||
return resolvedIndex;
|
||||
}
|
||||
|
||||
export function captureTerminalLines(
|
||||
terminal: TerminalSession,
|
||||
options: CaptureTerminalLinesOptions = {},
|
||||
): CaptureTerminalLinesResult {
|
||||
const state = terminal.getState();
|
||||
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
|
||||
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
|
||||
);
|
||||
const totalLines = allLines.length;
|
||||
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
|
||||
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
|
||||
|
||||
if (totalLines === 0 || startIndex > endIndex) {
|
||||
return {
|
||||
lines: [],
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lines: allLines.slice(startIndex, endIndex + 1),
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
} from "./worktree";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
} from "./worktree-metadata.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
@@ -527,69 +529,106 @@ describe("paseo worktree manager", () => {
|
||||
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("runs destroy commands from paseo.json before deleting a worktree", async () => {
|
||||
it("runs teardown commands from paseo.json before deleting a worktree", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
teardown: [
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
|
||||
{
|
||||
cwd: repoDir,
|
||||
},
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "destroy-branch",
|
||||
branchName: "teardown-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "destroy-test",
|
||||
worktreeSlug: "teardown-test",
|
||||
paseoHome,
|
||||
});
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: created.worktreePath,
|
||||
branchName: created.branchName,
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
|
||||
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8");
|
||||
expect(destroyLog).toContain(`source=${repoDir}`);
|
||||
expect(destroyLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(destroyLog).toContain("branch=destroy-branch");
|
||||
const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
|
||||
expect(teardownLog).toContain(`source=${repoDir}`);
|
||||
expect(teardownLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(teardownLog).toContain("branch=teardown-branch");
|
||||
expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`);
|
||||
});
|
||||
|
||||
it("does not remove worktree when a destroy command fails", async () => {
|
||||
it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"',
|
||||
teardown: [
|
||||
'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"',
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'",
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "teardown-port-missing-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "teardown-port-missing-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
|
||||
expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset");
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not remove worktree when a teardown command fails", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
teardown: [
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"',
|
||||
"echo boom 1>&2; exit 9",
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing destroy commands'",
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'",
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "destroy-failure-branch",
|
||||
branchName: "teardown-failure-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "destroy-failure-test",
|
||||
worktreeSlug: "teardown-failure-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await expect(
|
||||
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
|
||||
).rejects.toThrow("Worktree destroy command failed");
|
||||
).rejects.toThrow("Worktree teardown command failed");
|
||||
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true);
|
||||
expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
interface PaseoConfig {
|
||||
worktree?: {
|
||||
setup?: string[];
|
||||
destroy?: string[];
|
||||
teardown?: string[];
|
||||
terminals?: WorktreeTerminalConfig[];
|
||||
};
|
||||
}
|
||||
@@ -94,14 +94,14 @@ export class WorktreeSetupError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult;
|
||||
export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
|
||||
|
||||
export class WorktreeDestroyError extends Error {
|
||||
readonly results: WorktreeDestroyCommandResult[];
|
||||
export class WorktreeTeardownError extends Error {
|
||||
readonly results: WorktreeTeardownCommandResult[];
|
||||
|
||||
constructor(message: string, results: WorktreeDestroyCommandResult[]) {
|
||||
constructor(message: string, results: WorktreeTeardownCommandResult[]) {
|
||||
super(message);
|
||||
this.name = "WorktreeDestroyError";
|
||||
this.name = "WorktreeTeardownError";
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
@@ -150,13 +150,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeDestroyCommands(repoRoot: string): string[] {
|
||||
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const destroyCommands = config?.worktree?.destroy;
|
||||
if (!destroyCommands || destroyCommands.length === 0) {
|
||||
const teardownCommands = config?.worktree?.teardown;
|
||||
if (!teardownCommands || teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||
@@ -506,14 +506,14 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorktreeDestroyCommands(options: {
|
||||
export async function runWorktreeTeardownCommands(options: {
|
||||
worktreePath: string;
|
||||
branchName?: string;
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeDestroyCommandResult[]> {
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||
const destroyCommands = getWorktreeDestroyCommands(options.worktreePath);
|
||||
if (destroyCommands.length === 0) {
|
||||
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
|
||||
if (teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -521,30 +521,32 @@ export async function runWorktreeDestroyCommands(options: {
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const destroyEnv = {
|
||||
const teardownEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows destroy scripts to clean resources using paths from the
|
||||
// source checkout.
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// 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,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
};
|
||||
|
||||
const results: WorktreeDestroyCommandResult[] = [];
|
||||
for (const cmd of destroyCommands) {
|
||||
const results: WorktreeTeardownCommandResult[] = [];
|
||||
for (const cmd of teardownCommands) {
|
||||
const result = await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
env: destroyEnv,
|
||||
env: teardownEnv,
|
||||
});
|
||||
results.push(result);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new WorktreeDestroyError(
|
||||
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
throw new WorktreeTeardownError(
|
||||
`Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
results,
|
||||
);
|
||||
}
|
||||
@@ -884,7 +886,7 @@ export async function deletePaseoWorktree({
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
await runWorktreeDestroyCommands({
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user