fix: handle Windows drive-letter paths across the codebase (#148)

* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
This commit is contained in:
Mohamed Boudra
2026-03-27 18:17:48 +07:00
committed by GitHub
parent c667aca3e0
commit ca443b3ecf
25 changed files with 980 additions and 49 deletions
+9 -6
View File
@@ -448,12 +448,15 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
+228
View File
@@ -0,0 +1,228 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
await page.goto(workspaceRoute);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,160 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
+2 -4
View File
@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
+2 -5
View File
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command {
return cmd
@@ -193,11 +194,7 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
// Apply label filtering only when explicitly requested.
+2 -4
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
SingleResult,
@@ -75,12 +76,9 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
// Stop specific agent
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
@@ -1,3 +1,4 @@
import path from "path";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split("/").pop();
const name = path.basename(wt.worktreePath);
return name === nameArg || wt.branchName === nameArg;
});
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
throw error;
}
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
return {
type: "single",
+6 -1
View File
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
}
if (path.isAbsolute(trimmed)) {
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
return `unix://${trimmed}`;
}
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Path utilities for cwd filtering in agent commands.
*/
/**
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
*
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
* This is important because agent cwd paths come from the agent's OS (could be Windows)
* while the CLI filter path comes from the user (could also be Windows or Unix).
*/
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
normalizedBase = normalizedBase.toLowerCase();
normalizedCandidate = normalizedCandidate.toLowerCase();
}
return (
normalizedCandidate === normalizedBase ||
normalizedCandidate.startsWith(normalizedBase + "/")
);
}
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
console.log("✓ rejects empty and non-host listen values\n");
}
{
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
console.log("✓ rejects Windows absolute paths\n");
}
console.log("=== All local daemon utility tests passed ===");
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
}
{
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
}
{
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
@@ -0,0 +1,213 @@
#!/usr/bin/env npx tsx
/**
* CWD Filter Path Tests
*
* Tests that cwd filtering in agent commands works correctly
* with both Unix and Windows-style paths.
*
* Bug: The original code used hardcoded "/" separators for:
* 1. Stripping trailing separators (only stripped "/", not "\")
* 2. Descendant matching (appended "/" instead of platform separator)
*
* This causes Windows paths like "C:\Users\dev\project" to fail matching
* against "C:\Users\dev\project\sub" because the code appends "/" instead
* of "\" for the startsWith check.
*/
import assert from "node:assert";
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
console.log("=== CWD Filter Path Tests ===\n");
// Test 1: Unix exact match
{
console.log("Test 1: Unix exact match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
true,
"exact Unix paths should match",
);
console.log("✓ Unix exact match\n");
}
// Test 2: Unix descendant match
{
console.log("Test 2: Unix descendant match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
true,
"Unix descendant should match",
);
console.log("✓ Unix descendant match\n");
}
// Test 3: Unix non-match (sibling)
{
console.log("Test 3: Unix non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
false,
"sibling directories should not match",
);
console.log("✓ Unix non-match (sibling)\n");
}
// Test 4: Unix prefix overlap (project vs project2)
{
console.log("Test 4: Unix prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
false,
"prefix overlap without separator should not match",
);
console.log("✓ Unix prefix overlap\n");
}
// Test 5: Unix trailing slash on base
{
console.log("Test 5: Unix trailing slash on base");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
true,
"trailing slash on base should still match descendants",
);
console.log("✓ Unix trailing slash on base\n");
}
// Test 6: Unix trailing slash on candidate
{
console.log("Test 6: Unix trailing slash on candidate");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
true,
"trailing slash on candidate should match as same dir",
);
console.log("✓ Unix trailing slash on candidate\n");
}
// Test 7: Windows exact match
{
console.log("Test 7: Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
true,
"exact Windows paths should match",
);
console.log("✓ Windows exact match\n");
}
// Test 8: Windows descendant match
{
console.log("Test 8: Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
true,
"Windows descendant should match",
);
console.log("✓ Windows descendant match\n");
}
// Test 9: Windows non-match (sibling)
{
console.log("Test 9: Windows non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
false,
"Windows sibling directories should not match",
);
console.log("✓ Windows non-match (sibling)\n");
}
// Test 10: Windows prefix overlap (project vs project2)
{
console.log("Test 10: Windows prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
false,
"Windows prefix overlap without separator should not match",
);
console.log("✓ Windows prefix overlap\n");
}
// Test 11: Windows trailing backslash on base
{
console.log("Test 11: Windows trailing backslash on base");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
true,
"trailing backslash on base should still match descendants",
);
console.log("✓ Windows trailing backslash on base\n");
}
// Test 12: Windows trailing backslash on candidate
{
console.log("Test 12: Windows trailing backslash on candidate");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
true,
"trailing backslash on candidate should match as same dir",
);
console.log("✓ Windows trailing backslash on candidate\n");
}
// Test 13: Mixed separators (agent might use \ while CLI sends /)
{
console.log("Test 13: Mixed separators");
assert.strictEqual(
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
true,
"mixed separators should still match",
);
console.log("✓ Mixed separators\n");
}
// Test 14: Deep Windows descendant
{
console.log("Test 14: Deep Windows descendant");
assert.strictEqual(
isSameOrDescendantPath(
"C:\\Users\\dev\\project",
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
),
true,
"deep Windows descendant should match",
);
console.log("✓ Deep Windows descendant\n");
}
// Test 15: Case-insensitive Windows descendant match
{
console.log("Test 15: Case-insensitive Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
true,
"Windows paths with different casing should match (case-insensitive)",
);
console.log("✓ Case-insensitive Windows descendant match\n");
}
// Test 16: Case-insensitive Windows exact match
{
console.log("Test 16: Case-insensitive Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
true,
"Windows paths with different casing should match exactly (case-insensitive)",
);
console.log("✓ Case-insensitive Windows exact match\n");
}
// Test 17: Parent should not match
{
console.log("Test 15: Parent should not match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
false,
"parent directory should not match (only same-or-descendant)",
);
console.log("✓ Parent should not match\n");
}
console.log("=== All CWD filter path tests passed ===");
@@ -189,7 +189,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
@@ -231,6 +231,16 @@ export type ManagedAgent =
| ManagedAgentError
| ManagedAgentClosed;
export interface AgentMetricsSnapshot {
total: number;
byLifecycle: Record<string, number>;
withActiveForegroundTurn: number;
timelineStats: {
totalItems: number;
maxItemsPerAgent: number;
};
}
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
@@ -345,6 +355,37 @@ export class AgentManager {
this.onAgentAttention = callback;
}
public getMetricsSnapshot(): AgentMetricsSnapshot {
const byLifecycle: Record<string, number> = {};
let withActiveForegroundTurn = 0;
let totalItems = 0;
let maxItemsPerAgent = 0;
for (const agent of this.agents.values()) {
byLifecycle[agent.lifecycle] = (byLifecycle[agent.lifecycle] ?? 0) + 1;
if (agent.activeForegroundTurnId !== null) {
withActiveForegroundTurn++;
}
const len = agent.timeline.length;
totalItems += len;
if (len > maxItemsPerAgent) {
maxItemsPerAgent = len;
}
}
return {
total: this.agents.size,
byLifecycle,
withActiveForegroundTurn,
timelineStats: {
totalItems,
maxItemsPerAgent,
},
};
}
private touchUpdatedAt(agent: ManagedAgent): Date {
const nowMs = Date.now();
const previousMs = agent.updatedAt.getTime();
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { promises as fs } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js";
@@ -352,6 +352,24 @@ describe("AgentStorage", () => {
expect(records[0]?.internal).toBe(true);
});
test("Windows drive-letter paths produce valid directory names", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "win-agent",
cwd: "D:\\Users\\dev\\MyProject",
}),
);
const record = await storage.get("win-agent");
expect(record).not.toBeNull();
// The persisted directory must not contain a colon (invalid on Windows)
const dirs = readdirSync(storagePath);
expect(dirs).toHaveLength(1);
expect(dirs[0]).not.toContain(":");
expect(dirs[0]).toBe("D-Users-dev-MyProject");
});
test("remove deletes all duplicate record files across project directories", async () => {
const agentId = "agent-duplicate";
@@ -335,11 +335,16 @@ export class AgentStorage {
}
function projectDirNameFromCwd(cwd: string): string {
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
if (!trimmed) {
return "root";
// path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
const { root } = path.win32.parse(cwd);
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
// Sanitize root: strip colons and separators, keep letters (e.g. "C:\" → "C", "\\server\share\" → "server-share")
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
const prefix = sanitizedRoot ? sanitizedRoot + "-" : "";
if (!withoutRoot) {
return sanitizedRoot || "root";
}
return trimmed.replace(/[\\/]+/g, "-");
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
}
async function writeFileAtomically(targetPath: string, payload: string) {
@@ -122,6 +122,23 @@ describe("paseo daemon bootstrap", () => {
}
});
test("parses whitespace-padded numeric port strings", () => {
expect(parseListenString(" 6767 ")).toEqual({
type: "tcp",
host: "127.0.0.1",
port: 6767,
});
});
test("rejects Windows absolute paths that are not named pipes", () => {
// A Windows drive path like C:\daemon must NOT be silently parsed as TCP
// (split(":") would yield host="C" and port="\\daemon" which is nonsensical).
expect(() => parseListenString(String.raw`C:\daemon`)).toThrow();
expect(() => parseListenString(String.raw`D:\Users\foo\.paseo\daemon.sock`)).toThrow();
// Single-letter "host" with no valid port is not a valid listen string
expect(() => parseListenString(String.raw`C:\some\path`)).toThrow();
});
test("parses Windows named pipes as managed IPC listen targets", () => {
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
type: "pipe",
+23 -14
View File
@@ -35,34 +35,43 @@ function resolveBoundListenTarget(
};
}
// Matches a Windows drive-letter path like C:\ or D:\
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
export function parseListenString(listen: string): ListenTarget {
// 1. Windows named pipes: \\.\pipe\... or pipe://...
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
return {
type: "pipe",
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
};
}
// Unix socket: starts with / or ~ or contains .sock
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
return { type: "socket", path: listen };
}
// Explicit unix:// prefix
// 2. Explicit unix:// prefix
if (listen.startsWith("unix://")) {
return { type: "socket", path: listen.slice(7) };
}
// TCP: host:port or just port
// 3. Reject Windows absolute drive paths — they are not Unix sockets
if (WINDOWS_DRIVE_RE.test(listen)) {
throw new Error(`Invalid listen string (Windows path is not a valid listen target): ${listen}`);
}
// 4. POSIX absolute path (/ or ~) — Unix socket
if (listen.startsWith("/") || listen.startsWith("~")) {
return { type: "socket", path: listen };
}
// 5. Pure numeric — TCP port on 127.0.0.1
const trimmed = listen.trim();
if (/^\d+$/.test(trimmed)) {
const port = parseInt(trimmed, 10);
return { type: "tcp", host: "127.0.0.1", port };
}
// 6. host:port — TCP
if (listen.includes(":")) {
const [host, portStr] = listen.split(":");
const port = parseInt(portStr, 10);
if (!Number.isFinite(port)) {
const parsedPort = parseInt(portStr, 10);
if (!Number.isFinite(parsedPort)) {
throw new Error(`Invalid port in listen string: ${listen}`);
}
return { type: "tcp", host: host || "127.0.0.1", port };
}
// Just a port number
const port = parseInt(listen, 10);
if (Number.isFinite(port)) {
return { type: "tcp", host: "127.0.0.1", port };
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
}
throw new Error(`Invalid listen string: ${listen}`);
}
+18
View File
@@ -291,6 +291,8 @@ export type SessionRuntimeMetrics = {
checkoutDiffFallbackRefreshTargetCount: number;
terminalDirectorySubscriptionCount: number;
terminalSubscriptionCount: number;
inflightRequests: number;
peakInflightRequests: number;
};
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
@@ -610,6 +612,8 @@ export class Session {
private readonly activeTerminalStreams = new Map<number, ActiveTerminalStream>();
private readonly terminalIdToSlot = new Map<string, number>();
private nextTerminalSlot = 0;
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
@@ -754,6 +758,8 @@ export class Session {
checkoutDiffFallbackRefreshTargetCount,
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
terminalSubscriptionCount: this.activeTerminalStreams.size,
inflightRequests: this.inflightRequests,
peakInflightRequests: this.peakInflightRequests,
};
}
@@ -1454,6 +1460,11 @@ export class Session {
* Main entry point for processing session messages
*/
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
this.inflightRequests++;
if (this.inflightRequests > this.peakInflightRequests) {
this.peakInflightRequests = this.inflightRequests;
}
try {
this.sessionLogger.trace({ inbound: msg }, "inbound message");
try {
switch (msg.type) {
@@ -1779,6 +1790,13 @@ export class Session {
},
});
}
} finally {
this.inflightRequests--;
}
}
public resetPeakInflight(): void {
this.peakInflightRequests = this.inflightRequests;
}
public handleBinaryFrame(frame: TerminalStreamFrame): void {
@@ -197,6 +197,7 @@ type WebSocketRuntimeCounters = {
hostRejected: number;
};
const SLOW_REQUEST_THRESHOLD_MS = 500;
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
const HELLO_TIMEOUT_MS = 15_000;
const WS_CLOSE_HELLO_TIMEOUT = 4001;
@@ -275,6 +276,7 @@ export class VoiceAssistantWebSocketServer {
};
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly requestLatencies = new Map<string, number[]>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
constructor(
@@ -1056,7 +1058,21 @@ export class VoiceAssistantWebSocketServer {
if (message.type === "session") {
this.recordInboundSessionRequestType(message.message.type);
const startMs = performance.now();
await activeConnection.session.handleMessage(message.message);
const durationMs = performance.now() - startMs;
this.recordRequestLatency(message.message.type, durationMs);
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
activeConnection.connectionLogger.warn(
{
requestType: message.message.type,
durationMs: Math.round(durationMs),
inflightRequests: activeConnection.session.getRuntimeMetrics().inflightRequests,
},
"ws_slow_request",
);
}
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
@@ -1147,10 +1163,49 @@ export class VoiceAssistantWebSocketServer {
this.incrementCount(this.inboundSessionRequestCounts, type);
}
private recordRequestLatency(type: string, durationMs: number): void {
let latencies = this.requestLatencies.get(type);
if (!latencies) {
latencies = [];
this.requestLatencies.set(type, latencies);
}
latencies.push(durationMs);
}
private getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
private computeLatencyStats(): Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> {
const stats: Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> = [];
for (const [type, latencies] of this.requestLatencies) {
if (latencies.length === 0) continue;
latencies.sort((a, b) => a - b);
const count = latencies.length;
const minMs = Math.round(latencies[0]!);
const maxMs = Math.round(latencies[count - 1]!);
const p50Ms = Math.round(latencies[Math.floor(count / 2)]!);
const totalMs = Math.round(latencies.reduce((sum, v) => sum + v, 0));
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
}
stats.sort((a, b) => b.totalMs - a.totalMs);
return stats.slice(0, 15);
}
private collectSessionRuntimeMetrics(): SessionRuntimeMetrics {
const uniqueConnections = new Set<SessionConnection>(this.externalSessionsByKey.values());
let checkoutDiffTargetCount = 0;
@@ -1159,6 +1214,8 @@ export class VoiceAssistantWebSocketServer {
let checkoutDiffFallbackRefreshTargetCount = 0;
let terminalDirectorySubscriptionCount = 0;
let terminalSubscriptionCount = 0;
let inflightRequests = 0;
let peakInflightRequests = 0;
for (const connection of uniqueConnections) {
const sessionMetrics = connection.session.getRuntimeMetrics();
@@ -1169,6 +1226,9 @@ export class VoiceAssistantWebSocketServer {
sessionMetrics.checkoutDiffFallbackRefreshTargetCount;
terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount;
terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount;
inflightRequests += sessionMetrics.inflightRequests;
peakInflightRequests = Math.max(peakInflightRequests, sessionMetrics.peakInflightRequests);
connection.session.resetPeakInflight();
}
return {
@@ -1178,6 +1238,8 @@ export class VoiceAssistantWebSocketServer {
checkoutDiffFallbackRefreshTargetCount,
terminalDirectorySubscriptionCount,
terminalSubscriptionCount,
inflightRequests,
peakInflightRequests,
};
}
@@ -1192,6 +1254,8 @@ export class VoiceAssistantWebSocketServer {
connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null,
).length;
const sessionMetrics = this.collectSessionRuntimeMetrics();
const latencyStats = this.computeLatencyStats();
const agentSnapshot = this.agentManager.getMetricsSnapshot();
this.logger.info(
{
@@ -1210,6 +1274,8 @@ export class VoiceAssistantWebSocketServer {
inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12),
inboundSessionRequestTypesTop: this.getTopCounts(this.inboundSessionRequestCounts, 20),
runtime: sessionMetrics,
latency: latencyStats,
agents: agentSnapshot,
},
"ws_runtime_metrics",
);
@@ -1221,6 +1287,7 @@ export class VoiceAssistantWebSocketServer {
}
this.inboundMessageCounts.clear();
this.inboundSessionRequestCounts.clear();
this.requestLatencies.clear();
this.runtimeWindowStartedAt = now;
}
@@ -73,6 +73,12 @@ describe("TerminalManager", () => {
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
});
it("accepts Windows absolute paths", async () => {
manager = createTerminalManager();
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
});
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
@@ -121,6 +127,18 @@ describe("TerminalManager", () => {
);
});
it("does not reject Windows absolute paths as relative", async () => {
manager = createTerminalManager();
// Should pass path validation (not throw "cwd must be absolute path").
// The terminal may or may not spawn successfully on non-Windows hosts,
// so we only assert the validation error is absent.
try {
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
});
it("inherits registered env for the worktree root cwd", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
@@ -1,5 +1,5 @@
import { createTerminal, type TerminalSession } from "./terminal.js";
import { resolve, sep } from "node:path";
import { resolve, sep, win32, posix } from "node:path";
export interface TerminalListItem {
id: string;
@@ -37,7 +37,7 @@ export function createTerminalManager(): TerminalManager {
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
throw new Error("cwd must be absolute path");
}
}
@@ -18,6 +18,9 @@ import {
NotGitRepoError,
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
isPaseoWorktreePath,
isDescendantPath,
} from "./checkout-git.js";
import { createWorktree } from "./worktree.js";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
@@ -812,4 +815,73 @@ const x = 1;
).rejects.toThrow(/base/i);
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
});
describe("parseWorktreeList", () => {
it("parses porcelain worktree output", () => {
const output = [
"worktree /home/user/repo",
"branch refs/heads/main",
"",
"worktree /home/user/.paseo/worktrees/feature",
"branch refs/heads/feature",
"",
].join("\n");
const entries = parseWorktreeList(output);
expect(entries).toHaveLength(2);
expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" });
expect(entries[1]).toEqual({
path: "/home/user/.paseo/worktrees/feature",
branchRef: "refs/heads/feature",
});
});
it("detects bare repos", () => {
const output = ["worktree /home/user/repo.git", "bare", ""].join("\n");
const entries = parseWorktreeList(output);
expect(entries).toHaveLength(1);
expect(entries[0]?.isBare).toBe(true);
});
});
describe("isPaseoWorktreePath", () => {
it("matches Unix .paseo/worktrees/ paths", () => {
expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true);
});
it("matches Windows .paseo\\worktrees\\ paths", () => {
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
});
it("rejects paths without .paseo/worktrees segment", () => {
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);
});
});
describe("isDescendantPath", () => {
it("detects children with Unix separators", () => {
expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true);
});
it("detects children with Windows separators", () => {
expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true);
});
it("rejects the parent itself", () => {
expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false);
});
it("rejects siblings that share a prefix", () => {
expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false);
});
it("handles mixed separators", () => {
expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true);
});
it("is case insensitive on Windows paths", () => {
expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true);
});
});
});
+23 -4
View File
@@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter(
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"),
(wt) => !wt.isBare && !isPaseoWorktreePath(wt.path),
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/"));
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
}
type GitWorktreeEntry = {
export type GitWorktreeEntry = {
path: string;
branchRef?: string;
isBare?: boolean;
};
function parseWorktreeList(output: string): GitWorktreeEntry[] {
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
export function isPaseoWorktreePath(p: string): boolean {
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
}
/** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
export function isDescendantPath(child: string, parent: string): boolean {
let c = child.replace(/\\/g, "/").replace(/\/+$/, "");
let p = parent.replace(/\\/g, "/").replace(/\/+$/, "");
// Case-insensitive on Windows (drive letter like C: or D:)
if (/^[A-Za-z]:/.test(c) || /^[A-Za-z]:/.test(p)) {
c = c.toLowerCase();
p = p.toLowerCase();
}
if (!c.startsWith(p)) return false;
if (c.length === p.length) return false;
return c[p.length] === "/";
}
export function parseWorktreeList(output: string): GitWorktreeEntry[] {
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
for (const line of output.split("\n")) {