Compare commits

...

7 Commits

Author SHA1 Message Date
Mohamed Boudra
57291b1fd9 chore(release): cut 0.1.36 2026-03-27 18:30:49 +07:00
Mohamed Boudra
89f285ffd0 docs: clarify draft release flow and changelog dependency 2026-03-27 18:29:53 +07:00
Mohamed Boudra
e6c06e2263 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-27 18:18:56 +07:00
Mohamed Boudra
ca443b3ecf 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.
2026-03-27 18:17:48 +07:00
Mohamed Boudra
d8e9298222 Add metrics collection and terminal performance tests 2026-03-26 23:38:32 +07:00
Mohamed Boudra
c667aca3e0 chore: remove stale agent-event-stream-redesign doc 2026-03-26 20:56:03 +07:00
Mohamed Boudra
7892b3cbe1 fix(nix): update stale npmDepsHash and auto-fix on all lockfile changes
Hash mismatch was hidden by continue-on-error. Now the Nix Build
workflow fails visibly, and fix-nix-hash runs on every push/PR that
touches the lockfile (not just Dependabot).
2026-03-26 20:19:12 +07:00
40 changed files with 1040 additions and 262 deletions

View File

@@ -1,6 +1,11 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
@@ -12,11 +17,10 @@ permissions:
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4

View File

@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: true
continue-on-error: false
steps:
- uses: actions/checkout@v4

View File

@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
```
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
## Fixing a failed release build
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] `npm run release:patch` completes successfully
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green

View File

@@ -1,170 +0,0 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()``activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()``liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()``routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-gOwvUBvem1SxDMypaexz6RaHRm2xFmUT9iwOW2ErEAM=";
npmDepsHash = "sha256-xj5pSRE/F8zvrOcVgc22bSHG4GTFzVHgB6eFSs+eI8Y=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.35",
"version": "0.1.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.35",
"version": "0.1.36",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34842,16 +34842,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.35",
"version": "0.1.36",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.35",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/expo-two-way-audio": "0.1.36",
"@getpaseo/highlight": "0.1.36",
"@getpaseo/server": "0.1.36",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34967,11 +34967,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.35",
"version": "0.1.36",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/relay": "0.1.36",
"@getpaseo/server": "0.1.36",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35012,11 +35012,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.35",
"version": "0.1.36",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/cli": "0.1.36",
"@getpaseo/server": "0.1.36",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35049,7 +35049,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.35",
"version": "0.1.36",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35250,7 +35250,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.35",
"version": "0.1.36",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35276,7 +35276,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.35",
"version": "0.1.36",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35292,13 +35292,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.35",
"version": "0.1.36",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/relay": "0.1.35",
"@getpaseo/highlight": "0.1.36",
"@getpaseo/relay": "0.1.36",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35686,7 +35686,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.35",
"version": "0.1.36",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.35",
"version": "0.1.36",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

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",
},

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;
}

View File

@@ -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(() => {});
}
});
});

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.35",
"version": "0.1.36",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.35",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/expo-two-way-audio": "0.1.36",
"@getpaseo/highlight": "0.1.36",
"@getpaseo/server": "0.1.36",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.35",
"version": "0.1.36",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/relay": "0.1.36",
"@getpaseo/server": "0.1.36",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

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);

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.

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

View File

@@ -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;
}

View File

@@ -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",

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}`;
}

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 + "/")
);
}

View File

@@ -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 ===");

View File

@@ -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-"));

View File

@@ -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 ===");

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.35",
"version": "0.1.36",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.35",
"@getpaseo/server": "0.1.35",
"@getpaseo/cli": "0.1.36",
"@getpaseo/server": "0.1.36",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},

View File

@@ -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;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.35",
"version": "0.1.36",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.35",
"version": "0.1.36",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.35",
"version": "0.1.36",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.35",
"version": "0.1.36",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -63,8 +63,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/relay": "0.1.35",
"@getpaseo/highlight": "0.1.36",
"@getpaseo/relay": "0.1.36",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",

View File

@@ -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();

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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",

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}`);
}

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 {

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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");
}
}

View File

@@ -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);
});
});
});

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")) {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.35",
"version": "0.1.36",
"private": true,
"type": "module",
"scripts": {