diff --git a/CLAUDE.md b/CLAUDE.md
index 2edfb88f8..9d897d32f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
+- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
+ - Run only the specific test file you changed: `npx vitest run --bail=1`
+ - Never run `npm run test` for an entire workspace unless explicitly asked.
+ - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
+ - Never re-run a test suite that another agent already ran and reported green — trust the result.
+ - For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
diff --git a/SECURITY.md b/SECURITY.md
index 6ad5fdeb2..d582f9cfd 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
-Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
+Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
## Agent authentication
diff --git a/docs/AD-HOC-DAEMON-TESTING.md b/docs/AD-HOC-DAEMON-TESTING.md
index 30c0d2e65..6918bb727 100644
--- a/docs/AD-HOC-DAEMON-TESTING.md
+++ b/docs/AD-HOC-DAEMON-TESTING.md
@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
listen: "127.0.0.1:0", // OS picks a free port
paseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 86fb7306a..f839afb51 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
version: 1,
daemon: {
listen: "127.0.0.1:6767",
- allowedHosts: true | string[],
+ hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
diff --git a/nix/module.nix b/nix/module.nix
index 72a387614..022fc9eb8 100644
--- a/nix/module.nix
+++ b/nix/module.nix
@@ -9,6 +9,10 @@ let
cfg = config.services.paseo;
in
{
+ imports = [
+ (lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
+ ];
+
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
@@ -58,12 +62,12 @@ in
description = "Whether to open the firewall for the Paseo daemon port.";
};
- allowedHosts = lib.mkOption {
+ hostnames = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
- Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
+ Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
@@ -141,10 +145,10 @@ in
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
- } // lib.optionalAttrs (cfg.allowedHosts == true) {
- PASEO_ALLOWED_HOSTS = "true";
- } // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
- PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
+ } // lib.optionalAttrs (cfg.hostnames == true) {
+ PASEO_HOSTNAMES = "true";
+ } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
+ PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // cfg.environment;
serviceConfig = {
diff --git a/nix/package.nix b/nix/package.nix
index fdc7e37e4..dd0909a3f 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -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-7AL13lVZq/+K8G7SFHVCtWjK5cBL7XN2kLTULONv/Xs=";
+ npmDepsHash = "sha256-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8=";
# 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).
diff --git a/package-lock.json b/package-lock.json
index a8b72ec83..ebba06cc2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34854,16 +34854,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"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.55-rc.2",
- "@getpaseo/highlight": "0.1.55-rc.2",
- "@getpaseo/server": "0.1.55-rc.2",
+ "@getpaseo/expo-two-way-audio": "0.1.56",
+ "@getpaseo/highlight": "0.1.56",
+ "@getpaseo/server": "0.1.56",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35004,11 +35004,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/relay": "0.1.55-rc.2",
- "@getpaseo/server": "0.1.55-rc.2",
+ "@getpaseo/relay": "0.1.56",
+ "@getpaseo/server": "0.1.56",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35049,11 +35049,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"license": "AGPL-3.0-or-later",
"dependencies": {
- "@getpaseo/cli": "0.1.55-rc.2",
- "@getpaseo/server": "0.1.55-rc.2",
+ "@getpaseo/cli": "0.1.56",
+ "@getpaseo/server": "0.1.56",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35087,7 +35087,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35288,7 +35288,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35314,7 +35314,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35330,14 +35330,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
- "@getpaseo/highlight": "0.1.55-rc.2",
- "@getpaseo/relay": "0.1.55-rc.2",
+ "@getpaseo/highlight": "0.1.56",
+ "@getpaseo/relay": "0.1.56",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35765,7 +35765,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
diff --git a/package.json b/package.json
index c84601039..20ec2b057 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "paseo",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -14,6 +14,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
+ "dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
@@ -36,6 +37,7 @@
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
+ "dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts
index 3ac61ee2e..9e319e851 100644
--- a/packages/app/e2e/global-setup.ts
+++ b/packages/app/e2e/global-setup.ts
@@ -1,4 +1,4 @@
-import { spawn, type ChildProcess, execSync } from "node:child_process";
+import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
return offer as OfferPayload;
}
+function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
+ const stdout = execFileSync(
+ process.execPath,
+ ["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
+ {
+ cwd: repoRoot,
+ env: {
+ ...process.env,
+ PASEO_HOME: paseoHomePath,
+ },
+ encoding: "utf8",
+ },
+ );
+ const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
+ if (payload.relayEnabled !== true || typeof payload.url !== "string") {
+ throw new Error(`Unexpected daemon pair response: ${stdout}`);
+ }
+ return decodeOfferFromFragmentUrl(payload.url);
+}
+
+async function waitForPairingOfferFromCli(args: {
+ repoRoot: string;
+ paseoHome: string;
+ timeoutMs?: number;
+}): Promise {
+ const timeoutMs = args.timeoutMs ?? 15000;
+ const start = Date.now();
+ let lastError: unknown = null;
+
+ while (Date.now() - start < timeoutMs) {
+ try {
+ return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
+ } catch (error) {
+ lastError = error;
+ await sleep(100);
+ }
+ }
+
+ throw new Error(
+ `Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
+ lastError instanceof Error ? lastError.message : String(lastError)
+ }`,
+ );
+}
+
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
@@ -433,12 +478,6 @@ export default async function globalSetup() {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
- let offerPayload: OfferPayload | null = null;
- let offerResolve: (() => void) | null = null;
- const offerPromise = new Promise((resolve) => {
- offerResolve = resolve;
- });
-
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
@@ -473,26 +512,6 @@ export default async function globalSetup() {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
- if (!offerPayload) {
- const clean = stripAnsi(trimmed);
- try {
- const obj = JSON.parse(clean) as { msg?: string; url?: string };
- if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
- offerPayload = decodeOfferFromFragmentUrl(obj.url);
- offerResolve?.();
- }
- } catch {
- const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
- if (match && clean.includes("pairing_offer")) {
- try {
- offerPayload = decodeOfferFromFragmentUrl(match[0]);
- offerResolve?.();
- } catch {
- // ignore parsing failures
- }
- }
- }
- }
console.log(`[daemon] ${trimmed}`);
}
});
@@ -523,17 +542,10 @@ export default async function globalSetup() {
}),
]);
- // Wait for daemon to emit a pairing offer (includes relay session ID).
- await Promise.race([
- offerPromise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
- ),
- ]);
- if (!offerPayload) {
- throw new Error("pairing_offer was not parsed from daemon logs");
- }
- const offer = offerPayload as OfferPayload;
+ const offer = await waitForPairingOfferFromCli({
+ repoRoot,
+ paseoHome,
+ });
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
diff --git a/packages/app/package.json b/packages/app/package.json
index aeaae5b55..c840d37f4 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"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.55-rc.2",
- "@getpaseo/highlight": "0.1.55-rc.2",
- "@getpaseo/server": "0.1.55-rc.2",
+ "@getpaseo/expo-two-way-audio": "0.1.56",
+ "@getpaseo/highlight": "0.1.56",
+ "@getpaseo/server": "0.1.56",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx
index f9ce26bf9..aeef7b346 100644
--- a/packages/app/src/components/branch-switcher.tsx
+++ b/packages/app/src/components/branch-switcher.tsx
@@ -43,12 +43,17 @@ export function BranchSwitcher({
queryClient,
});
- if (!currentBranchName) {
- return (
+ const titleContent = (
+ <>
+
{title}
- );
+ >
+ );
+
+ if (!currentBranchName) {
+ return {titleContent};
}
return (
@@ -63,10 +68,7 @@ export function BranchSwitcher({
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
-
-
- {title}
-
+ {titleContent}
{!isCompact ? : null}
({
xs: -theme.spacing[2],
md: 0,
},
- paddingVertical: theme.spacing[1],
+ paddingVertical: {
+ xs: 0,
+ md: theme.spacing[1],
+ },
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx
index c2522bbe6..5b1664c1c 100644
--- a/packages/app/src/components/composer.tsx
+++ b/packages/app/src/components/composer.tsx
@@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface ComposerProps {
agentId: string;
serverId: string;
- isInputActive: boolean;
+ isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
@@ -106,7 +106,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function Composer({
agentId,
serverId,
- isInputActive,
+ isPaneFocused,
onSubmitMessage,
hasExternalContent = false,
allowEmptySubmit = false,
@@ -435,7 +435,7 @@ export function Composer({
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
- if (!isInputActive) {
+ if (!isPaneFocused) {
return false;
}
@@ -479,7 +479,7 @@ export function Composer({
return false;
}
},
- [isInputActive],
+ [isPaneFocused],
);
useKeyboardActionHandler({
@@ -493,9 +493,9 @@ export function Composer({
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
- enabled: isInputActive,
+ enabled: isPaneFocused,
priority: isMessageInputFocused ? 200 : 100,
- isActive: () => isInputActive,
+ isActive: () => isPaneFocused,
handle: handleKeyboardAction,
});
@@ -766,7 +766,7 @@ export function Composer({
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
- isInputActive={isInputActive}
+ isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx
index d65a34cbe..e11419e57 100644
--- a/packages/app/src/components/file-pane.tsx
+++ b/packages/app/src/components/file-pane.tsx
@@ -264,6 +264,7 @@ export function FilePane({
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
+ refetchOnMount: true,
});
return (
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 15a0c03ab..b2e9fa8ca 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -42,6 +42,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
+import { formatShortcut } from "@/utils/format-shortcut";
+import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import {
markScrollInvestigationEvent,
@@ -81,8 +83,8 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
- /** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
- isInputActive?: boolean;
+ /** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
+ isPaneFocused?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
@@ -219,7 +221,7 @@ export const MessageInput = forwardRef(funct
autoFocus = false,
autoFocusKey,
disabled = false,
- isInputActive = true,
+ isPaneFocused = true,
leftContent,
beforeVoiceContent,
rightContent,
@@ -247,7 +249,9 @@ export const MessageInput = forwardRef(funct
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const queueKeys = useShortcutKeys("message-input-queue");
+ const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
+ const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef(null);
const inputWrapperRef = useRef(null);
const textInputRef = useRef unknown }) | null>(
@@ -441,7 +445,7 @@ export const MessageInput = forwardRef(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
- autoStopWhenHidden: { isVisible: isInputActive },
+ autoStopWhenHidden: { isVisible: isPaneFocused },
enableDuration: true,
});
@@ -1025,10 +1029,12 @@ export const MessageInput = forwardRef(funct
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
+ setIsInputFocused(true);
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
+ setIsInputFocused(false);
onFocusChange?.(false);
}}
style={[
@@ -1053,6 +1059,11 @@ export const MessageInput = forwardRef(funct
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
+ {isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
+
+ {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
+
+ ) : null}
{/* Button row */}
@@ -1313,6 +1324,14 @@ const styles = StyleSheet.create(((theme: any) => ({
textInputScrollWrapper: {
position: "relative",
},
+ focusHintText: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foregroundMuted,
+ opacity: 0.5,
+ },
textInput: {
width: "100%",
color: theme.colors.foreground,
diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx
index b509ee4bb..b00e91ebe 100644
--- a/packages/app/src/components/workspace-setup-dialog.tsx
+++ b/packages/app/src/components/workspace-setup-dialog.tsx
@@ -279,7 +279,7 @@ export function WorkspaceSetupDialog() {
{});
},
[agentId, attentionReason, client, isConnected, requiresAttention],
);
diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts
index f07d8d687..9bf1ed285 100644
--- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts
+++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts
@@ -9,10 +9,6 @@ import {
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
-import {
- summarizeSidebarProjects,
- summarizeWorkspaceCollection,
-} from "@/utils/workspace-fetch-debug";
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -302,14 +298,6 @@ export function useSidebarWorkspacesList(options?: {
if (!serverId) {
return;
}
-
- console.log("[WorkspaceFetch][sidebar] model", {
- serverId,
- connectionStatus,
- hasHydratedWorkspaces,
- sessionWorkspaces: summarizeWorkspaceCollection(sessionWorkspaces?.values()),
- projects: summarizeSidebarProjects(projects),
- });
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
useEffect(() => {
@@ -355,21 +343,10 @@ export function useSidebarWorkspacesList(options?: {
let cursor: string | null = null;
try {
while (true) {
- console.log("[WorkspaceFetch][sidebar-refresh] request", {
- serverId,
- cursor,
- existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()),
- });
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
- console.log("[WorkspaceFetch][sidebar-refresh] response", {
- serverId,
- cursor,
- pageInfo: payload.pageInfo,
- payload: summarizeWorkspaceCollection(payload.entries),
- });
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
next.set(
@@ -388,10 +365,6 @@ export function useSidebarWorkspacesList(options?: {
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
- console.log("[WorkspaceFetch][sidebar-refresh] applied", {
- serverId,
- nextWorkspaces: summarizeWorkspaceCollection(next.values()),
- });
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts
index 92a8ef714..8aac268d6 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.ts
@@ -788,6 +788,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Message input ---
+ {
+ id: "message-input-focus-cmd-l-mac",
+ action: "message-input.action",
+ combo: "Cmd+L",
+ when: { mac: true, commandCenter: false },
+ payload: { type: "message-input", kind: "focus" },
+ help: {
+ id: "focus-message-input",
+ section: "agent-input",
+ label: "Focus message input",
+ keys: ["mod", "L"],
+ },
+ },
+ {
+ id: "message-input-focus-ctrl-l-non-mac",
+ action: "message-input.action",
+ combo: "Ctrl+L",
+ when: { mac: false, commandCenter: false, terminal: false },
+ payload: { type: "message-input", kind: "focus" },
+ help: {
+ id: "focus-message-input",
+ section: "agent-input",
+ label: "Focus message input",
+ keys: ["mod", "L"],
+ },
+ },
{
id: "message-input-voice-toggle-cmd-shift-d-mac",
action: "message-input.action",
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index 28e72240d..02045f1c0 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -965,7 +965,7 @@ function ChatAgentContent({
",
- 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
+ "--hostnames ",
+ 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
- .action(withOutput(runDaemonRestartCommand));
+ .addOption(new Option("--allowed-hosts ").hideHelp())
+ .action(
+ withOutput((...args) => {
+ const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
+ return runDaemonRestartCommand(
+ {
+ ...options,
+ hostnames:
+ typeof options.hostnames === "string"
+ ? options.hostnames
+ : typeof options.allowedHosts === "string"
+ ? options.allowedHosts
+ : undefined,
+ },
+ command,
+ );
+ }),
+ );
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand());
diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts
index 5cb4f5b80..d9ef61297 100644
--- a/packages/cli/src/commands/daemon/index.ts
+++ b/packages/cli/src/commands/daemon/index.ts
@@ -1,4 +1,4 @@
-import { Command } from "commander";
+import { Command, Option } from "commander";
import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
@@ -36,10 +36,27 @@ export function createDaemonCommand(): Command {
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option(
- "--allowed-hosts ",
- 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
+ "--hostnames ",
+ 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
- .action(withOutput(runRestartCommand));
+ .addOption(new Option("--allowed-hosts ").hideHelp())
+ .action(
+ withOutput((...args) => {
+ const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
+ return runRestartCommand(
+ {
+ ...options,
+ hostnames:
+ typeof options.hostnames === "string"
+ ? options.hostnames
+ : typeof options.allowedHosts === "string"
+ ? options.allowedHosts
+ : undefined,
+ },
+ command,
+ );
+ }),
+ );
return daemon;
}
diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts
index 7d00a3271..812a48ae4 100644
--- a/packages/cli/src/commands/daemon/local-daemon.ts
+++ b/packages/cli/src/commands/daemon/local-daemon.ts
@@ -13,7 +13,7 @@ export interface DaemonStartOptions {
relay?: boolean;
mcp?: boolean;
injectMcp?: boolean;
- allowedHosts?: string;
+ hostnames?: string;
}
export interface LocalDaemonPidInfo {
@@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
} else if (options.port) {
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
}
- if (options.allowedHosts) {
- childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
+ if (options.hostnames) {
+ childEnv.PASEO_HOSTNAMES = options.hostnames;
}
return childEnv;
}
@@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
...envWithHome(options.home),
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
PASEO_LISTEN: undefined,
+ PASEO_HOSTNAMES: undefined,
PASEO_ALLOWED_HOSTS: undefined,
};
const home = resolvePaseoHome(env);
diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts
index 82df597f5..741057f6a 100644
--- a/packages/cli/src/commands/daemon/restart.ts
+++ b/packages/cli/src/commands/daemon/restart.ts
@@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
- allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
+ hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
};
if (startOptions.listen && startOptions.port) {
diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts
index 1e5cd2264..7a3e5991e 100644
--- a/packages/cli/src/commands/daemon/start.ts
+++ b/packages/cli/src/commands/daemon/start.ts
@@ -1,4 +1,4 @@
-import { Command } from "commander";
+import { Command, Option } from "commander";
import chalk from "chalk";
import {
startLocalDaemonForeground,
@@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js";
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
+type RawStartCommandOptions = StartOptions & {
+ allowedHosts?: string;
+};
+
export function startCommand(): Command {
return new Command("start")
.description("Start the local Paseo daemon")
@@ -20,11 +24,15 @@ export function startCommand(): Command {
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option(
- "--allowed-hosts ",
- 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
+ "--hostnames ",
+ 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
- .action(async (options: StartOptions) => {
- await runStart(options);
+ .addOption(new Option("--allowed-hosts ").hideHelp())
+ .action(async (options: RawStartCommandOptions) => {
+ await runStart({
+ ...options,
+ hostnames: options.hostnames ?? options.allowedHosts,
+ });
});
}
diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts
index a9d4168d3..480e857a7 100644
--- a/packages/cli/src/commands/onboard.ts
+++ b/packages/cli/src/commands/onboard.ts
@@ -1,5 +1,5 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
-import { Command } from "commander";
+import { Command, Option } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import {
@@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions {
voice?: "ask" | "enable" | "disable";
}
+type RawOnboardOptions = OnboardOptions & {
+ allowedHosts?: string;
+};
+
type OnboardPersistedConfig = PersistedConfig & {
features?: PersistedConfig["features"] & {
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
@@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number {
return Math.ceil(seconds * 1000);
}
-function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
+function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {};
if (options.listen) {
@@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
cliOverrides.relayEnabled = false;
}
- if (options.allowedHosts) {
- const raw = options.allowedHosts.trim();
- cliOverrides.allowedHosts =
+ if (options.hostnames) {
+ const raw = options.hostnames.trim();
+ cliOverrides.hostnames =
raw.toLowerCase() === "true"
? true
: raw
@@ -297,13 +301,17 @@ export function onboardCommand(): Command {
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
- "--allowed-hosts ",
- 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
+ "--hostnames ",
+ 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
+ .addOption(new Option("--allowed-hosts ").hideHelp())
.option("--timeout ", "Max time to wait for daemon readiness (default: 600)")
.option("--voice ", "Voice setup mode: ask, enable, disable", "ask")
- .action(async (options: OnboardOptions) => {
- await runOnboard(options);
+ .action(async (options: RawOnboardOptions) => {
+ await runOnboard({
+ ...options,
+ hostnames: options.hostnames ?? options.allowedHosts,
+ });
});
}
diff --git a/packages/cli/tests/21-run-output-schema-helper.test.ts b/packages/cli/tests/21-run-output-schema-helper.test.ts
index 6db80fd66..398b16175 100644
--- a/packages/cli/tests/21-run-output-schema-helper.test.ts
+++ b/packages/cli/tests/21-run-output-schema-helper.test.ts
@@ -2,10 +2,10 @@
import assert from "node:assert";
import {
- resolveProviderAndModel,
resolveStructuredResponseMessage,
type StructuredResponseTimelineClient,
} from "../src/commands/agent/run.ts";
+import { resolveProviderAndModel } from "../src/utils/provider-model.ts";
type TimelineEntry = {
item: {
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 9b18e8e5b..d251e2a9f 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -8,12 +8,13 @@
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "./scripts/dev.sh",
+ "dev:win": "powershell ./scripts/dev.ps1",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
- "@getpaseo/cli": "0.1.55-rc.2",
- "@getpaseo/server": "0.1.55-rc.2",
+ "@getpaseo/cli": "0.1.56",
+ "@getpaseo/server": "0.1.56",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1
new file mode 100644
index 000000000..609704eac
--- /dev/null
+++ b/packages/desktop/scripts/dev.ps1
@@ -0,0 +1,36 @@
+$ErrorActionPreference = "Stop"
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
+$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
+$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
+
+# Build the Electron main process
+npm run build:main
+
+# Get a random available port for Metro
+$env:EXPO_PORT = (npx get-port-cli).Trim()
+
+# Set EXPO_DEV_URL in the environment so Electron inherits it
+$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
+
+# Allow any origin in dev so Electron on random ports works.
+# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
+# the daemon binds to localhost and this script is never used for production.
+$env:PASEO_CORS_ORIGINS = "*"
+
+Write-Host @"
+======================================================
+ Paseo Desktop Dev (Windows)
+======================================================
+ Metro: http://localhost:$($env:EXPO_PORT)
+======================================================
+"@
+
+# Launch Metro + Electron together, kill both on exit
+& "$RootDir\node_modules\.bin\concurrently" `
+ --kill-others `
+ --names "metro,electron" `
+ --prefix-colors "magenta,cyan" `
+ "cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
+ "npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json
index 7e948d764..454748fa5 100644
--- a/packages/expo-two-way-audio/package.json
+++ b/packages/expo-two-way-audio/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
diff --git a/packages/highlight/package.json b/packages/highlight/package.json
index 14a114124..5803e4b3f 100644
--- a/packages/highlight/package.json
+++ b/packages/highlight/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"type": "module",
"publishConfig": {
"access": "public"
diff --git a/packages/relay/package.json b/packages/relay/package.json
index b31fa4141..d33a39760 100644
--- a/packages/relay/package.json
+++ b/packages/relay/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
diff --git a/packages/server/package.json b/packages/server/package.json
index 99d7f9555..4c3ed9db9 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
- "@getpaseo/highlight": "0.1.55-rc.2",
- "@getpaseo/relay": "0.1.55-rc.2",
+ "@getpaseo/highlight": "0.1.56",
+ "@getpaseo/relay": "0.1.56",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index 3ebe2ed9a..6a3f00f04 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -1201,8 +1201,28 @@ export class DaemonClient {
}
}
- clearAgentAttention(agentId: string | string[]): void {
- this.sendSessionMessage({ type: "clear_agent_attention", agentId });
+ async clearAgentAttention(agentId: string | string[]): Promise {
+ const requestId = this.createRequestId();
+ const message = SessionInboundMessageSchema.parse({
+ type: "clear_agent_attention",
+ agentId,
+ requestId,
+ });
+ await this.sendRequest({
+ requestId,
+ message,
+ timeout: 15000,
+ options: { skipQueue: true },
+ select: (msg) => {
+ if (msg.type !== "clear_agent_attention_response") {
+ return null;
+ }
+ if (msg.payload.requestId !== requestId) {
+ return null;
+ }
+ return msg.payload;
+ },
+ });
}
sendHeartbeat(params: {
@@ -1745,7 +1765,27 @@ export class DaemonClient {
}
async cancelAgent(agentId: string): Promise {
- this.sendSessionMessage({ type: "cancel_agent_request", agentId });
+ const requestId = this.createRequestId();
+ const message = SessionInboundMessageSchema.parse({
+ type: "cancel_agent_request",
+ agentId,
+ requestId,
+ });
+ await this.sendRequest({
+ requestId,
+ message,
+ timeout: 15000,
+ options: { skipQueue: true },
+ select: (msg) => {
+ if (msg.type !== "cancel_agent_response") {
+ return null;
+ }
+ if (msg.payload.requestId !== requestId) {
+ return null;
+ }
+ return msg.payload;
+ },
+ });
}
async setAgentMode(agentId: string, modeId: string): Promise {
diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts
index ad95ce1b6..16ff84152 100644
--- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts
+++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts
@@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${disabledPort}`,
paseoHome: disabledPaseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: true,
mcpInjectIntoAgents: false,
staticDir: disabledStaticDir,
@@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts
index da63dae7f..8b5c3aa71 100644
--- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts
+++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise();
const createAgentMcpTransport = async (callerAgentId?: string) => {
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise {
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise ({
+ createOpencodeClient: vi.fn(),
+}));
+
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
+
+import { createTestLogger } from "../../../test-utils/test-logger.js";
+import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
+
+function createDeferred(): {
+ promise: Promise;
+ resolve: (value: T) => void;
+ reject: (error: unknown) => void;
+} {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("OpenCodeAgentSession slash command timeout handling", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test("waits for SSE completion when slash commands hit a header timeout", async () => {
+ const idleEventGate = createDeferred();
+
+ vi.mocked(createOpencodeClient).mockReturnValue({
+ session: {
+ create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
+ command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
+ },
+ provider: {
+ list: vi.fn().mockResolvedValue({
+ data: {
+ connected: ["openai"],
+ all: [{ id: "openai", name: "OpenAI", models: {} }],
+ },
+ }),
+ },
+ event: {
+ subscribe: vi.fn().mockResolvedValue({
+ stream: (async function* () {
+ await idleEventGate.promise;
+ yield {
+ type: "session.idle",
+ properties: { sessionID: "session-1" },
+ };
+ })(),
+ }),
+ },
+ command: {
+ list: vi.fn().mockResolvedValue({
+ data: [{ name: "help", description: "Show help", hints: [] }],
+ }),
+ },
+ app: {
+ agents: vi.fn().mockResolvedValue({ data: [] }),
+ },
+ } as never);
+
+ vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
+ ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
+ } as never);
+
+ const client = new OpenCodeAgentClient(createTestLogger());
+ const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
+
+ const runPromise = session.run("/help");
+ await Promise.resolve();
+ idleEventGate.resolve();
+
+ await expect(runPromise).resolves.toMatchObject({
+ sessionId: "session-1",
+ finalText: "",
+ timeline: [],
+ usage: undefined,
+ });
+ });
+});
diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts
index 4e6a416a8..65fe87ec4 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.test.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts
@@ -316,6 +316,65 @@ const hasOpenCode = isBinaryInstalled("opencode");
});
describe("OpenCode adapter context-window normalization", () => {
+ test("close reconciliation aborts then archives upstream session", async () => {
+ const abort = vi.fn().mockResolvedValue({ data: true, error: undefined });
+ const update = vi.fn().mockResolvedValue({
+ data: { id: "session-1", time: { archived: Date.now() } },
+ error: undefined,
+ });
+
+ await __openCodeInternals.reconcileOpenCodeSessionClose({
+ client: {
+ session: {
+ abort,
+ update,
+ },
+ } as never,
+ sessionId: "session-1",
+ directory: "/tmp/project",
+ logger: createTestLogger(),
+ });
+
+ expect(abort).toHaveBeenCalledWith({
+ sessionID: "session-1",
+ directory: "/tmp/project",
+ });
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(update).toHaveBeenCalledWith({
+ sessionID: "session-1",
+ directory: "/tmp/project",
+ time: {
+ archived: expect.any(Number),
+ },
+ });
+ });
+
+ test("close reconciliation still archives when abort returns an error", async () => {
+ const abort = vi.fn().mockResolvedValue({
+ data: undefined,
+ error: { data: {}, errors: [], success: false },
+ });
+ const update = vi.fn().mockResolvedValue({
+ data: { id: "session-1", time: { archived: Date.now() } },
+ error: undefined,
+ });
+
+ await __openCodeInternals.reconcileOpenCodeSessionClose({
+ client: {
+ session: {
+ abort,
+ update,
+ },
+ } as never,
+ sessionId: "session-1",
+ directory: "/tmp/project",
+ logger: createTestLogger(),
+ });
+
+ expect(abort).toHaveBeenCalledTimes(1);
+ expect(update).toHaveBeenCalledTimes(1);
+ });
+
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts
index 3299794a4..369e11a76 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.ts
@@ -108,6 +108,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
"does not exist",
"unsupported model",
] as const;
+const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
+ "headers timeout",
+ "headers timeout error",
+ "headers_timeout",
+ "und_err_headers_timeout",
+] as const;
const OpencodeToolStateSchema = z
.object({
@@ -232,6 +238,73 @@ function normalizeTurnFailureError(error: unknown): string {
return normalized.length > 0 ? normalized : "Unknown error";
}
+function isOpenCodeNotFoundError(error: unknown): boolean {
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ "name" in error &&
+ (error as { name?: unknown }).name === "NotFoundError"
+ );
+}
+
+async function reconcileOpenCodeSessionClose(params: {
+ client: Pick;
+ sessionId: string;
+ directory: string;
+ logger: Logger;
+}): Promise {
+ const { client, sessionId, directory, logger } = params;
+
+ try {
+ const response = await client.session.abort({
+ sessionID: sessionId,
+ directory,
+ });
+ if (response.error && !isOpenCodeNotFoundError(response.error)) {
+ logger.warn(
+ {
+ sessionId,
+ error: normalizeTurnFailureError(response.error),
+ },
+ "Failed to abort OpenCode session during close",
+ );
+ }
+ } catch (error) {
+ logger.warn(
+ {
+ sessionId,
+ error: normalizeTurnFailureError(error),
+ },
+ "Failed to abort OpenCode session during close",
+ );
+ }
+
+ try {
+ const response = await client.session.update({
+ sessionID: sessionId,
+ directory,
+ time: { archived: Date.now() },
+ });
+ if (response.error && !isOpenCodeNotFoundError(response.error)) {
+ logger.warn(
+ {
+ sessionId,
+ error: normalizeTurnFailureError(response.error),
+ },
+ "Failed to archive OpenCode session during close",
+ );
+ }
+ } catch (error) {
+ logger.warn(
+ {
+ sessionId,
+ error: normalizeTurnFailureError(error),
+ },
+ "Failed to archive OpenCode session during close",
+ );
+ }
+}
+
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
if (!normalized) {
@@ -240,6 +313,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
}
+function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
+ const diagnostics = new Set();
+ const queue: unknown[] = [error];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (!current) {
+ continue;
+ }
+
+ const normalized = stringifyUnknownError(current).trim().toLowerCase();
+ if (normalized) {
+ diagnostics.add(normalized);
+ }
+
+ if (typeof current === "object") {
+ const record = current as {
+ message?: unknown;
+ code?: unknown;
+ name?: unknown;
+ cause?: unknown;
+ };
+
+ for (const value of [record.message, record.code, record.name]) {
+ if (typeof value === "string") {
+ const diagnostic = value.trim().toLowerCase();
+ if (diagnostic) {
+ diagnostics.add(diagnostic);
+ }
+ }
+ }
+
+ if (record.cause) {
+ queue.push(record.cause);
+ }
+ }
+ }
+
+ return [...diagnostics].some((diagnostic) =>
+ OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)),
+ );
+}
+
function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -575,6 +691,7 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
+ reconcileOpenCodeSessionClose,
resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow,
};
@@ -1535,6 +1652,17 @@ class OpenCodeAgentSession implements AgentSession {
})
.then((response) => {
if (response.error) {
+ if (isOpenCodeHeadersTimeoutFailure(response.error)) {
+ this.logger.warn(
+ {
+ err: response.error,
+ commandName: slashCommand.commandName,
+ turnId,
+ },
+ "OpenCode slash command hit a header timeout; waiting for SSE terminal event",
+ );
+ return;
+ }
const errorMsg = normalizeTurnFailureError(response.error);
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
@@ -1548,6 +1676,17 @@ class OpenCodeAgentSession implements AgentSession {
}
})
.catch((err) => {
+ if (isOpenCodeHeadersTimeoutFailure(err)) {
+ this.logger.warn(
+ {
+ err,
+ commandName: slashCommand.commandName,
+ turnId,
+ },
+ "OpenCode slash command hit a header timeout; waiting for SSE terminal event",
+ );
+ return;
+ }
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
@@ -1937,6 +2076,12 @@ class OpenCodeAgentSession implements AgentSession {
async close(): Promise {
this.abortController?.abort();
+ await reconcileOpenCodeSessionClose({
+ client: this.client,
+ sessionId: this.sessionId,
+ directory: this.config.cwd,
+ logger: this.logger,
+ });
this.subscribers.clear();
this.activeForegroundTurnId = null;
}
diff --git a/packages/server/src/server/allowed-hosts.test.ts b/packages/server/src/server/allowed-hosts.test.ts
deleted file mode 100644
index 1e0659bde..000000000
--- a/packages/server/src/server/allowed-hosts.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js";
-
-describe("allowed hosts (vite-style)", () => {
- it("allows localhost by default", () => {
- expect(isHostAllowed("localhost:6767", undefined)).toBe(true);
- });
-
- it("allows subdomains of .localhost by default", () => {
- expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true);
- });
-
- it("allows IP addresses by default", () => {
- expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true);
- expect(isHostAllowed("[::1]:6767", undefined)).toBe(true);
- });
-
- it("rejects non-default hosts when no allowlist is provided", () => {
- expect(isHostAllowed("evil.com:6767", undefined)).toBe(false);
- });
-
- it("allows any host when set to true", () => {
- expect(isHostAllowed("evil.com:6767", true)).toBe(true);
- });
-
- it("supports leading-dot patterns", () => {
- const allowed = [".example.com"];
- expect(isHostAllowed("example.com:6767", allowed)).toBe(true);
- expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true);
- expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true);
- expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false);
- });
-
- it("merges arrays (append + de-dupe) and short-circuits on true", () => {
- expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]);
- expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true);
- });
-
- it("parses env var values", () => {
- expect(parseAllowedHostsEnv(undefined)).toBeUndefined();
- expect(parseAllowedHostsEnv("")).toBeUndefined();
- expect(parseAllowedHostsEnv("true")).toBe(true);
- expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
- });
-});
diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts
index ee2b4e520..9d1096aea 100644
--- a/packages/server/src/server/bootstrap.smoke.test.ts
+++ b/packages/server/src/server/bootstrap.smoke.test.ts
@@ -1,11 +1,11 @@
import os from "node:os";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
-import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
+import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
@@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
@@ -150,30 +150,20 @@ describe("paseo daemon bootstrap", () => {
});
});
- test("emits a relay pairing offer for unix socket listeners", async () => {
+ test("generates a relay pairing offer for unix socket listeners", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
-
- const lines: string[] = [];
- const logger = pino(
- { level: "info" },
- new Writable({
- write(chunk, _encoding, callback) {
- lines.push(chunk.toString("utf8"));
- callback();
- },
- }),
- );
+ const logger = pino({ level: "silent" });
const config: PaseoDaemonConfig = {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
@@ -191,7 +181,16 @@ describe("paseo daemon bootstrap", () => {
try {
await daemon.start();
- expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
+ const pairing = await generateLocalPairingOffer({
+ paseoHome,
+ relayEnabled: true,
+ relayEndpoint: "127.0.0.1:9",
+ relayPublicEndpoint: "127.0.0.1:9",
+ appBaseUrl: "https://app.paseo.sh",
+ includeQr: false,
+ });
+ expect(pairing.relayEnabled).toBe(true);
+ expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
} finally {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts
index 67a22ddae..970cf1cb8 100644
--- a/packages/server/src/server/bootstrap.ts
+++ b/packages/server/src/server/bootstrap.ts
@@ -108,6 +108,7 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
+import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -119,7 +120,6 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
-import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
@@ -128,6 +128,7 @@ import {
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
+import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
type AgentMcpTransportMap = Map;
@@ -170,7 +171,8 @@ export type PaseoDaemonConfig = {
listen: string;
paseoHome: string;
corsAllowedOrigins: string[];
- allowedHosts?: AllowedHostsConfig;
+ allowedHosts?: HostnamesConfig;
+ hostnames?: HostnamesConfig;
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
staticDir: string;
@@ -238,6 +240,7 @@ export async function createPaseoDaemon(
const scriptRouteStore = new ScriptRouteStore();
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
+ const configuredHostnames = config.hostnames ?? config.allowedHosts;
let wsServer: VoiceAssistantWebSocketServer | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
routeStore: scriptRouteStore,
@@ -264,7 +267,7 @@ export async function createPaseoDaemon(
if (listenTarget.type === "tcp") {
app.use((req, res, next) => {
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
- if (!isHostAllowed(hostHeader, config.allowedHosts)) {
+ if (!isHostnameAllowed(hostHeader, configuredHostnames)) {
res.status(403).json({ error: "Invalid Host header" });
return;
}
@@ -418,6 +421,10 @@ export async function createPaseoDaemon(
});
const terminalManager = createTerminalManager();
+ const workspaceGitService = new WorkspaceGitServiceImpl({
+ logger,
+ paseoHome: config.paseoHome,
+ });
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
@@ -431,6 +438,7 @@ export async function createPaseoDaemon(
agentStorage,
projectRegistry,
workspaceRegistry,
+ workspaceGitService,
logger,
});
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
@@ -439,6 +447,7 @@ export async function createPaseoDaemon(
const checkoutDiffManager = new CheckoutDiffManager({
logger,
paseoHome: config.paseoHome,
+ workspaceGitService,
});
const loopService = new LoopService({
paseoHome: config.paseoHome,
@@ -647,7 +656,7 @@ export async function createPaseoDaemon(
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
- { allowedOrigins, allowedHosts: config.allowedHosts },
+ { allowedOrigins, hostnames: configuredHostnames },
speechService,
terminalManager,
{
@@ -675,6 +684,7 @@ export async function createPaseoDaemon(
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
+ workspaceGitService,
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
@@ -694,8 +704,7 @@ export async function createPaseoDaemon(
relay: { endpoint: relayPublicEndpoint },
});
- const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
- logger.info({ url }, "pairing_offer");
+ encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts
index 356f8cf15..35d573e7b 100644
--- a/packages/server/src/server/checkout-diff-manager.test.ts
+++ b/packages/server/src/server/checkout-diff-manager.test.ts
@@ -1,137 +1,184 @@
-import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted(
- () => {
- const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = [];
- return {
- getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
- resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
- readdirMock: vi.fn(async (directory: string) => {
- if (directory === "/tmp/repo") {
- return [
- { name: "packages", isDirectory: () => true },
- { name: ".git", isDirectory: () => true },
- { name: "README.md", isDirectory: () => false },
- ];
- }
- if (directory === path.join("/tmp/repo", "packages")) {
- return [
- { name: "server", isDirectory: () => true },
- { name: "app", isDirectory: () => true },
- ];
- }
- if (directory === path.join("/tmp/repo", "packages", "server")) {
- return [{ name: "src", isDirectory: () => true }];
- }
- if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
- return [{ name: "server", isDirectory: () => true }];
- }
- return [];
- }),
- watchCalls: hoistedWatchCalls,
- };
- },
-);
-
-vi.mock("../utils/run-git-command.js", () => ({
- runGitCommand: vi.fn(async () => ({
- stdout: "/tmp/repo\n",
- stderr: "",
- truncated: false,
- exitCode: 0,
- signal: null,
+const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
+ getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
+ toCheckoutErrorMock: vi.fn((error: unknown) => ({
+ message: error instanceof Error ? error.message : String(error),
})),
}));
-vi.mock("node:fs/promises", async () => {
- const actual = await vi.importActual("node:fs/promises");
- return {
- ...actual,
- readdir: readdirMock,
- };
-});
-
-vi.mock("node:fs", async () => {
- const actual = await vi.importActual("node:fs");
- return {
- ...actual,
- watch: vi.fn((watchPath: string) => {
- const close = vi.fn();
- watchCalls.push({ path: watchPath, close });
- return {
- close,
- on: vi.fn().mockReturnThis(),
- } as any;
- }),
- };
-});
-
vi.mock("../utils/checkout-git.js", () => ({
getCheckoutDiff: getCheckoutDiffMock,
}));
vi.mock("./checkout-git-utils.js", () => ({
- READ_ONLY_GIT_ENV: {},
- resolveCheckoutGitDir: resolveCheckoutGitDirMock,
- toCheckoutError: vi.fn((error: unknown) => ({
- message: error instanceof Error ? error.message : String(error),
- })),
+ toCheckoutError: toCheckoutErrorMock,
}));
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
-describe("CheckoutDiffManager Linux watchers", () => {
- const originalPlatform = process.platform;
-
+describe("CheckoutDiffManager", () => {
beforeEach(() => {
- watchCalls.length = 0;
- getCheckoutDiffMock.mockClear();
- resolveCheckoutGitDirMock.mockClear();
- readdirMock.mockClear();
- Object.defineProperty(process, "platform", {
- configurable: true,
- value: "linux",
- });
+ vi.useFakeTimers();
+ getCheckoutDiffMock.mockReset();
+ getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
+ toCheckoutErrorMock.mockClear();
});
afterEach(() => {
- Object.defineProperty(process, "platform", {
- configurable: true,
- value: originalPlatform,
- });
+ vi.useRealTimers();
});
- test("watches nested repository directories on Linux", async () => {
+ function createManager(options?: {
+ repoRoot?: string | null;
+ getCheckoutDiffImplementation?: typeof getCheckoutDiffMock;
+ }) {
+ const unsubscribe = vi.fn();
+ let onChange: (() => void) | null = null;
+ const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => {
+ onChange = listener;
+ return {
+ repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot,
+ unsubscribe,
+ };
+ });
+
+ const workspaceGitService = {
+ subscribe: vi.fn(),
+ peekSnapshot: vi.fn(),
+ getSnapshot: vi.fn(),
+ refresh: vi.fn(),
+ scheduleRefreshForCwd: vi.fn(),
+ requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
+ dispose: vi.fn(),
+ };
+
+ if (options?.getCheckoutDiffImplementation) {
+ getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation);
+ }
+
const logger = {
child: () => logger,
warn: vi.fn(),
};
+
const manager = new CheckoutDiffManager({
logger: logger as any,
paseoHome: "/tmp/paseo-test",
+ workspaceGitService: workspaceGitService as any,
});
- const subscription = await manager.subscribe(
+ return {
+ manager,
+ workspaceGitService,
+ mockRequestWorkingTreeWatch,
+ unsubscribe,
+ getOnChange: () => onChange,
+ };
+ }
+
+ test("subscribe requests a working tree watch with the correct cwd", async () => {
+ const { manager, mockRequestWorkingTreeWatch } = createManager();
+
+ await manager.subscribe(
{
- cwd: path.join("/tmp/repo", "packages", "server"),
+ cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
- expect(subscription.initial.error).toBeNull();
- expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
- "/tmp/repo",
- "/tmp/repo/.git",
- "/tmp/repo/packages",
- "/tmp/repo/packages/app",
+ expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
"/tmp/repo/packages/server",
- "/tmp/repo/packages/server/src",
- "/tmp/repo/packages/server/src/server",
- ]);
+ expect.any(Function),
+ );
+ });
+
+ test("unsubscribe calls the working tree watch unsubscribe", async () => {
+ const { manager, unsubscribe } = createManager();
+
+ const subscription = await manager.subscribe(
+ {
+ cwd: "/tmp/repo/packages/server",
+ compare: { mode: "uncommitted" },
+ },
+ () => {},
+ );
subscription.unsubscribe();
- manager.dispose();
+
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ test("diffCwd uses repoRoot from the working tree watch result", async () => {
+ const { manager } = createManager({ repoRoot: "/tmp/repo" });
+
+ await manager.subscribe(
+ {
+ cwd: "/tmp/repo/packages/server",
+ compare: { mode: "uncommitted" },
+ },
+ () => {},
+ );
+
+ expect(getCheckoutDiffMock).toHaveBeenCalledWith(
+ "/tmp/repo",
+ expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
+ { paseoHome: "/tmp/paseo-test" },
+ );
+ });
+
+ test("diff refresh is triggered when the working tree watch callback fires", async () => {
+ getCheckoutDiffMock
+ .mockResolvedValueOnce({
+ diff: "",
+ structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
+ })
+ .mockResolvedValueOnce({
+ diff: "",
+ structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
+ });
+
+ const { manager, getOnChange } = createManager();
+ const listener = vi.fn();
+
+ await manager.subscribe(
+ {
+ cwd: "/tmp/repo/packages/server",
+ compare: { mode: "uncommitted" },
+ },
+ listener,
+ );
+
+ const onChange = getOnChange();
+ expect(onChange).toBeTypeOf("function");
+
+ onChange?.();
+ await vi.advanceTimersByTimeAsync(150);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({
+ cwd: "/tmp/repo/packages/server",
+ files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
+ error: null,
+ });
+ });
+
+ test("falls back to cwd when the working tree watch returns no repo root", async () => {
+ const { manager } = createManager({ repoRoot: null });
+
+ await manager.subscribe(
+ {
+ cwd: "/tmp/plain",
+ compare: { mode: "uncommitted" },
+ },
+ () => {},
+ );
+
+ expect(getCheckoutDiffMock).toHaveBeenCalledWith(
+ "/tmp/plain",
+ expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
+ { paseoHome: "/tmp/paseo-test" },
+ );
});
});
diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts
index 0274fff22..8e2029d96 100644
--- a/packages/server/src/server/checkout-diff-manager.ts
+++ b/packages/server/src/server/checkout-diff-manager.ts
@@ -1,15 +1,11 @@
-import { watch, type FSWatcher } from "node:fs";
-import { readdir } from "node:fs/promises";
-import { join } from "node:path";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
+import type { WorkspaceGitService } from "./workspace-git-service.js";
import { getCheckoutDiff } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
-import { runGitCommand } from "../utils/run-git-command.js";
-import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
+import { toCheckoutError } from "./checkout-git-utils.js";
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
-const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
@@ -31,27 +27,26 @@ type CheckoutDiffWatchTarget = {
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
- watchers: FSWatcher[];
- fallbackRefreshInterval: NodeJS.Timeout | null;
+ workingTreeWatchUnsubscribe: (() => void) | null;
debounceTimer: NodeJS.Timeout | null;
refreshPromise: Promise | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestFingerprint: string | null;
- watchedPaths: Set;
- repoWatchPath: string | null;
- linuxTreeRefreshPromise: Promise | null;
- linuxTreeRefreshQueued: boolean;
};
export class CheckoutDiffManager {
- private readonly logger: pino.Logger;
private readonly paseoHome: string;
+ private readonly workspaceGitService: WorkspaceGitService;
private readonly targets = new Map();
- constructor(options: { logger: pino.Logger; paseoHome: string }) {
- this.logger = options.logger.child({ module: "checkout-diff-manager" });
+ constructor(options: {
+ logger: pino.Logger;
+ paseoHome: string;
+ workspaceGitService: WorkspaceGitService;
+ }) {
this.paseoHome = options.paseoHome;
+ this.workspaceGitService = options.workspaceGitService;
}
async subscribe(
@@ -93,22 +88,16 @@ export class CheckoutDiffManager {
getMetrics(): CheckoutDiffMetrics {
let checkoutDiffSubscriptionCount = 0;
- let checkoutDiffWatcherCount = 0;
- let checkoutDiffFallbackRefreshTargetCount = 0;
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
- checkoutDiffWatcherCount += target.watchers.length;
- if (target.fallbackRefreshInterval) {
- checkoutDiffFallbackRefreshTargetCount += 1;
- }
}
return {
checkoutDiffTargetCount: this.targets.size,
checkoutDiffSubscriptionCount,
- checkoutDiffWatcherCount,
- checkoutDiffFallbackRefreshTargetCount,
+ checkoutDiffWatcherCount: 0,
+ checkoutDiffFallbackRefreshTargetCount: 0,
};
}
@@ -144,15 +133,8 @@ export class CheckoutDiffManager {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
- if (target.fallbackRefreshInterval) {
- clearInterval(target.fallbackRefreshInterval);
- target.fallbackRefreshInterval = null;
- }
- for (const watcher of target.watchers) {
- watcher.close();
- }
- target.watchers = [];
- target.watchedPaths.clear();
+ target.workingTreeWatchUnsubscribe?.();
+ target.workingTreeWatchUnsubscribe = null;
target.listeners.clear();
}
@@ -172,22 +154,6 @@ export class CheckoutDiffManager {
this.targets.delete(targetKey);
}
- private async resolveCheckoutWatchRoot(cwd: string): Promise {
- try {
- const { stdout } = await runGitCommand(
- ["rev-parse", "--path-format=absolute", "--show-toplevel"],
- {
- cwd,
- env: READ_ONLY_GIT_ENV,
- },
- );
- const root = stdout.trim();
- return root.length > 0 ? root : null;
- } catch {
- return null;
- }
- }
-
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -274,212 +240,27 @@ export class CheckoutDiffManager {
return existing;
}
- const watchRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: CheckoutDiffWatchTarget = {
key: targetKey,
cwd,
- diffCwd: watchRoot ?? cwd,
+ diffCwd: cwd,
compare,
listeners: new Set(),
- watchers: [],
- fallbackRefreshInterval: null,
+ workingTreeWatchUnsubscribe: null,
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestPayload: null,
latestFingerprint: null,
- watchedPaths: new Set(),
- repoWatchPath: null,
- linuxTreeRefreshPromise: null,
- linuxTreeRefreshQueued: false,
};
-
- const repoWatchPath = watchRoot ?? cwd;
- target.repoWatchPath = repoWatchPath;
- const watchPaths = new Set([repoWatchPath]);
- const gitDir = await resolveCheckoutGitDir(cwd);
- if (gitDir) {
- watchPaths.add(gitDir);
- }
-
- let hasRecursiveRepoCoverage = false;
- const allowRecursiveRepoWatch = process.platform !== "linux";
- if (process.platform === "linux") {
- hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
- }
- for (const watchPath of watchPaths) {
- if (process.platform === "linux" && watchPath === repoWatchPath) {
- continue;
- }
- const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
- const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
- if (watchPath === repoWatchPath && watcherIsRecursive) {
- hasRecursiveRepoCoverage = true;
- }
- }
-
- const missingRepoCoverage = !hasRecursiveRepoCoverage;
- if (target.watchers.length === 0 || missingRepoCoverage) {
- target.fallbackRefreshInterval = setInterval(() => {
- this.scheduleTargetRefresh(target);
- }, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
- this.logger.warn(
- {
- cwd,
- compare,
- intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
- reason:
- target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
- },
- "Checkout diff watchers unavailable; using timed refresh fallback",
- );
- }
+ const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
+ cwd,
+ () => this.scheduleTargetRefresh(target),
+ );
+ target.diffCwd = repoRoot ?? cwd;
+ target.workingTreeWatchUnsubscribe = unsubscribe;
this.targets.set(targetKey, target);
return target;
}
-
- private addWatcher(
- target: CheckoutDiffWatchTarget,
- watchPath: string,
- shouldTryRecursive: boolean,
- ): boolean {
- if (target.watchedPaths.has(watchPath)) {
- return false;
- }
-
- const { cwd, compare } = target;
- const onChange = () => {
- if (process.platform === "linux" && target.repoWatchPath) {
- void this.refreshLinuxRepoTreeWatchers(target);
- }
- this.scheduleTargetRefresh(target);
- };
- const createWatcher = (recursive: boolean): FSWatcher =>
- watch(watchPath, { recursive }, () => {
- onChange();
- });
-
- let watcher: FSWatcher | null = null;
- let watcherIsRecursive = false;
- try {
- if (shouldTryRecursive) {
- watcher = createWatcher(true);
- watcherIsRecursive = true;
- } else {
- watcher = createWatcher(false);
- }
- } catch (error) {
- if (shouldTryRecursive) {
- try {
- watcher = createWatcher(false);
- this.logger.warn(
- { err: error, watchPath, cwd, compare },
- "Checkout diff recursive watch unavailable; using non-recursive fallback",
- );
- } catch (fallbackError) {
- this.logger.warn(
- { err: fallbackError, watchPath, cwd, compare },
- "Failed to start checkout diff watcher",
- );
- }
- } else {
- this.logger.warn(
- { err: error, watchPath, cwd, compare },
- "Failed to start checkout diff watcher",
- );
- }
- }
-
- if (!watcher) {
- return false;
- }
-
- watcher.on("error", (error) => {
- this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
- });
- target.watchers.push(watcher);
- target.watchedPaths.add(watchPath);
- return watcherIsRecursive;
- }
-
- private async ensureLinuxRepoTreeWatchers(
- target: CheckoutDiffWatchTarget,
- rootPath: string,
- ): Promise {
- const directories = await this.listLinuxWatchDirectories(rootPath);
- let complete = true;
- for (const directory of directories) {
- const watcherWasRecursive = this.addWatcher(target, directory, false);
- if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
- complete = false;
- }
- }
- return complete && target.watchedPaths.has(rootPath);
- }
-
- private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise {
- if (process.platform !== "linux" || !target.repoWatchPath) {
- return;
- }
- const rootPath = target.repoWatchPath;
- if (target.linuxTreeRefreshPromise) {
- target.linuxTreeRefreshQueued = true;
- return;
- }
-
- target.linuxTreeRefreshPromise = (async () => {
- do {
- target.linuxTreeRefreshQueued = false;
- try {
- await this.ensureLinuxRepoTreeWatchers(target, rootPath);
- } catch (error) {
- this.logger.warn(
- {
- err: error,
- cwd: target.cwd,
- compare: target.compare,
- rootPath,
- },
- "Failed to refresh Linux checkout diff tree watchers",
- );
- }
- } while (target.linuxTreeRefreshQueued);
- })();
-
- try {
- await target.linuxTreeRefreshPromise;
- } finally {
- target.linuxTreeRefreshPromise = null;
- }
- }
-
- private async listLinuxWatchDirectories(rootPath: string): Promise {
- const directories: string[] = [];
- const pending = [rootPath];
-
- while (pending.length > 0) {
- const directory = pending.pop();
- if (!directory) {
- continue;
- }
- directories.push(directory);
-
- let entries;
- try {
- entries = await readdir(directory, { withFileTypes: true });
- } catch {
- continue;
- }
-
- for (const entry of entries) {
- if (!entry.isDirectory() || entry.name === ".git") {
- continue;
- }
- pending.push(join(directory, entry.name));
- }
- }
-
- return directories;
- }
}
diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts
index 02fc0cf8e..3115e63d7 100644
--- a/packages/server/src/server/config.ts
+++ b/packages/server/src/server/config.ts
@@ -11,11 +11,7 @@ import type {
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
-import {
- mergeAllowedHosts,
- parseAllowedHostsEnv,
- type AllowedHostsConfig,
-} from "./allowed-hosts.js";
+import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
@@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{
relayEnabled: boolean;
mcpEnabled: boolean;
mcpInjectIntoAgents: boolean;
- allowedHosts: AllowedHostsConfig;
+ hostnames: HostnamesConfig;
}>;
const OptionalVoiceLlmProviderSchema = z
@@ -133,10 +129,10 @@ export function loadConfig(
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
- const allowedHosts = mergeAllowedHosts([
- persisted.daemon?.allowedHosts,
- parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS),
- options?.cli?.allowedHosts,
+ const hostnames = mergeHostnames([
+ persisted.daemon?.hostnames,
+ parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
+ options?.cli?.hostnames,
]);
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
@@ -181,7 +177,7 @@ export function loadConfig(
corsAllowedOrigins: Array.from(
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
),
- allowedHosts,
+ hostnames,
mcpEnabled,
mcpInjectIntoAgents,
mcpDebug: env.MCP_DEBUG === "1",
diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts
index 80b464d3f..2c065c93c 100644
--- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts
@@ -8,6 +8,7 @@ import { Writable } from "node:stream";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
+import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function createCapturingLogger() {
@@ -22,18 +23,25 @@ function createCapturingLogger() {
return { logger, lines };
}
-function parseOfferUrlFromLogs(lines: string[]): string {
- for (const line of lines) {
- try {
- const obj = JSON.parse(line) as { msg?: string; url?: string };
- if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
- return obj.url;
- }
- } catch {
- // ignore non-JSON lines
- }
+async function getPairingOfferUrl(args: {
+ paseoHome: string;
+ relayEnabled?: boolean;
+ relayEndpoint?: string;
+ relayPublicEndpoint?: string;
+ appBaseUrl?: string;
+}): Promise {
+ const pairing = await generateLocalPairingOffer({
+ paseoHome: args.paseoHome,
+ relayEnabled: args.relayEnabled,
+ relayEndpoint: args.relayEndpoint,
+ relayPublicEndpoint: args.relayPublicEndpoint,
+ appBaseUrl: args.appBaseUrl,
+ includeQr: false,
+ });
+ if (!pairing.url) {
+ throw new Error("Expected relay pairing URL to be available");
}
- throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
+ return pairing.url;
}
function decodeOfferFromFragmentUrl(url: string): unknown {
@@ -72,7 +80,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
test("emits relay-only offer URL with stable serverId", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
- const { logger, lines } = createCapturingLogger();
+ const { logger } = createCapturingLogger();
const daemon = await createTestPaseoDaemon({
listen: "0.0.0.0",
@@ -81,7 +89,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
});
try {
- const offerUrl = parseOfferUrlFromLogs(lines);
+ const offerUrl = await getPairingOfferUrl({
+ paseoHome: daemon.paseoHome,
+ relayEnabled: daemon.config.relayEnabled,
+ relayEndpoint: daemon.config.relayEndpoint,
+ relayPublicEndpoint: daemon.config.relayPublicEndpoint,
+ appBaseUrl: daemon.config.appBaseUrl,
+ });
expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
const offer = decodeOfferFromFragmentUrl(offerUrl) as {
@@ -111,7 +125,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-"));
- const { logger: logger1, lines: lines1 } = createCapturingLogger();
+ const { logger: logger1 } = createCapturingLogger();
const daemon1 = await createTestPaseoDaemon({
listen: "0.0.0.0",
logger: logger1,
@@ -124,7 +138,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
let staticDir2: string | null = null;
try {
- const offerUrl1 = parseOfferUrlFromLogs(lines1);
+ const offerUrl1 = await getPairingOfferUrl({
+ paseoHome: daemon1.paseoHome,
+ relayEnabled: daemon1.config.relayEnabled,
+ relayEndpoint: daemon1.config.relayEndpoint,
+ relayPublicEndpoint: daemon1.config.relayPublicEndpoint,
+ appBaseUrl: daemon1.config.appBaseUrl,
+ });
const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as {
serverId: string;
daemonPublicKeyB64: string;
@@ -133,7 +153,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
await daemon1.close();
- const { logger: logger2, lines: lines2 } = createCapturingLogger();
+ const { logger: logger2 } = createCapturingLogger();
const daemon2 = await createTestPaseoDaemon({
listen: "0.0.0.0",
logger: logger2,
@@ -144,7 +164,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
staticDir2 = daemon2.staticDir;
try {
- const offerUrl2 = parseOfferUrlFromLogs(lines2);
+ const offerUrl2 = await getPairingOfferUrl({
+ paseoHome: daemon2.paseoHome,
+ relayEnabled: daemon2.config.relayEnabled,
+ relayEndpoint: daemon2.config.relayEndpoint,
+ relayPublicEndpoint: daemon2.config.relayPublicEndpoint,
+ appBaseUrl: daemon2.config.appBaseUrl,
+ });
const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as {
serverId: string;
daemonPublicKeyB64: string;
@@ -207,12 +233,6 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
stdoutLines.push(text);
for (const line of text.split("\n")) {
if (!line.trim()) continue;
- if (line.includes("pairing_offer")) {
- clearTimeout(timeout);
- reject(new Error("unexpected pairing_offer log when --no-relay is set"));
- return;
- }
-
try {
const parsed = JSON.parse(line) as { msg?: string };
if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue;
diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts
index ecc474c65..6241d7578 100644
--- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts
@@ -7,6 +7,7 @@ import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
+import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
@@ -26,19 +27,25 @@ function createCapturingLogger() {
return { logger, lines };
}
-function parseOfferUrlFromLogs(lines: string[]): string {
- for (const line of lines) {
- if (!line.includes("pairing_offer")) continue;
- try {
- const obj = JSON.parse(line) as { msg?: string; url?: string };
- if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
- return obj.url;
- }
- } catch {
- // ignore
- }
+async function getPairingOfferUrl(args: {
+ paseoHome: string;
+ relayEnabled?: boolean;
+ relayEndpoint?: string;
+ relayPublicEndpoint?: string;
+ appBaseUrl?: string;
+}): Promise {
+ const pairing = await generateLocalPairingOffer({
+ paseoHome: args.paseoHome,
+ relayEnabled: args.relayEnabled,
+ relayEndpoint: args.relayEndpoint,
+ relayPublicEndpoint: args.relayPublicEndpoint,
+ appBaseUrl: args.appBaseUrl,
+ includeQr: false,
+ });
+ if (!pairing.url) {
+ throw new Error("Expected relay pairing URL to be available");
}
- throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
+ return pairing.url;
}
function decodeOfferFromFragmentUrl(url: string): {
@@ -199,7 +206,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
});
try {
- const offerUrl = parseOfferUrlFromLogs(lines);
+ const offerUrl = await getPairingOfferUrl({
+ paseoHome: daemon.paseoHome,
+ relayEnabled: daemon.config.relayEnabled,
+ relayEndpoint: daemon.config.relayEndpoint,
+ relayPublicEndpoint: daemon.config.relayPublicEndpoint,
+ appBaseUrl: daemon.config.appBaseUrl,
+ });
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
@@ -314,7 +327,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
});
try {
- const offerUrl = parseOfferUrlFromLogs(lines);
+ const offerUrl = await getPairingOfferUrl({
+ paseoHome: daemon.paseoHome,
+ relayEnabled: daemon.config.relayEnabled,
+ relayEndpoint: daemon.config.relayEndpoint,
+ relayPublicEndpoint: daemon.config.relayPublicEndpoint,
+ appBaseUrl: daemon.config.appBaseUrl,
+ });
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
// Previously, the daemon would time out waiting for `hello` and reconnect every ~10s.
diff --git a/packages/server/src/server/hostnames.test.ts b/packages/server/src/server/hostnames.test.ts
new file mode 100644
index 000000000..88778f519
--- /dev/null
+++ b/packages/server/src/server/hostnames.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from "vitest";
+import { PersistedConfigSchema } from "./persisted-config.js";
+import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js";
+
+describe("hostnames (vite-style)", () => {
+ it("allows localhost by default", () => {
+ expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true);
+ });
+
+ it("allows subdomains of .localhost by default", () => {
+ expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true);
+ });
+
+ it("allows IP addresses by default", () => {
+ expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true);
+ expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true);
+ });
+
+ it("rejects non-default hosts when no allowlist is provided", () => {
+ expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false);
+ });
+
+ it("allows any host when set to true", () => {
+ expect(isHostnameAllowed("evil.com:6767", true)).toBe(true);
+ });
+
+ it("supports leading-dot patterns", () => {
+ const hostnames = [".example.com"];
+ expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true);
+ expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true);
+ expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true);
+ expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false);
+ });
+
+ it("merges arrays (append + de-dupe) and short-circuits on true", () => {
+ expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]);
+ expect(mergeHostnames([["a"], true, ["b"]])).toBe(true);
+ });
+
+ it("parses env var values", () => {
+ expect(parseHostnamesEnv(undefined)).toBeUndefined();
+ expect(parseHostnamesEnv("")).toBeUndefined();
+ expect(parseHostnamesEnv("true")).toBe(true);
+ expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
+ });
+
+ it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => {
+ const parsed = PersistedConfigSchema.parse({
+ daemon: {
+ allowedHosts: [".example.com"],
+ },
+ });
+
+ expect(parsed.daemon?.hostnames).toEqual([".example.com"]);
+ });
+});
diff --git a/packages/server/src/server/allowed-hosts.ts b/packages/server/src/server/hostnames.ts
similarity index 72%
rename from packages/server/src/server/allowed-hosts.ts
rename to packages/server/src/server/hostnames.ts
index 6186b1fe3..0253305b2 100644
--- a/packages/server/src/server/allowed-hosts.ts
+++ b/packages/server/src/server/hostnames.ts
@@ -1,6 +1,6 @@
import net from "node:net";
-export type AllowedHostsConfig = true | string[] | undefined;
+export type HostnamesConfig = true | string[] | undefined;
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase();
@@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null {
return normalizeHostname(trimmed.slice(0, colonIndex));
}
-function matchesAllowedHostPattern(hostname: string, pattern: string): boolean {
+function matchesHostnamePattern(hostname: string, pattern: string): boolean {
const normalizedPattern = normalizeHostname(pattern);
if (!normalizedPattern) return false;
@@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean {
}
/**
- * Vite-style allowed hosts check, adapted to raw Host headers.
+ * Vite-style hostname allowlist check, adapted to raw Host headers.
*
* Semantics:
- * - `allowedHosts === true` => allow any host.
- * - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs.
- * - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
+ * - `hostnames === true` => allow any host.
+ * - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs.
+ * - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
*/
-export function isHostAllowed(
+export function isHostnameAllowed(
hostHeader: string | undefined,
- allowedHosts: AllowedHostsConfig,
+ hostnames: HostnamesConfig,
): boolean {
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
if (!hostname) return false;
- if (allowedHosts === true) return true;
+ if (hostnames === true) return true;
// Defaults are always allowed.
if (isDefaultAllowedHostname(hostname)) return true;
- const patterns = allowedHosts ?? [];
+ const patterns = hostnames ?? [];
for (const pattern of patterns) {
- if (matchesAllowedHostPattern(hostname, pattern)) return true;
+ if (matchesHostnamePattern(hostname, pattern)) return true;
}
return false;
}
-export function mergeAllowedHosts(values: Array): AllowedHostsConfig {
+export function mergeHostnames(values: Array): HostnamesConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array): AllowedHos
return deduped;
}
-export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
+export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts
index 5fd0483a2..362369932 100644
--- a/packages/server/src/server/persisted-config.ts
+++ b/packages/server/src/server/persisted-config.ts
@@ -228,6 +228,7 @@ export const PersistedConfigSchema = z
daemon: z
.object({
listen: z.string().optional(),
+ hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
mcp: z
.object({
@@ -252,6 +253,10 @@ export const PersistedConfigSchema = z
.optional(),
})
.strict()
+ .transform(({ allowedHosts, ...daemon }) => {
+ const hostnames = daemon.hostnames ?? allowedHosts;
+ return hostnames === undefined ? daemon : { ...daemon, hostnames };
+ })
.optional(),
app: z
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index fcc9ffd99..62ccfb0a4 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -156,7 +156,6 @@ import {
getCheckoutDiff,
getCachedCheckoutShortstat,
getCheckoutStatus,
- getCheckoutStatusLite,
listBranchSuggestions,
commitChanges,
mergeToBase,
@@ -191,6 +190,7 @@ import {
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
+ registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession,
} from "./worktree-session.js";
const execAsync = promisify(exec);
@@ -1465,6 +1465,63 @@ export class Session {
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
}
+ private async buildProjectPlacement(cwd: string): Promise {
+ return buildProjectPlacementForCwdStandalone({
+ cwd,
+ workspaceGitService: this.workspaceGitService,
+ });
+ }
+
+ private buildPersistedProjectRecord(input: {
+ workspaceId: string;
+ placement: ProjectPlacementPayload;
+ createdAt: string;
+ updatedAt: string;
+ }): PersistedProjectRecord {
+ return createPersistedProjectRecord({
+ projectId: input.placement.projectKey,
+ rootPath: deriveProjectRootPath({
+ cwd: input.workspaceId,
+ checkout: input.placement.checkout,
+ }),
+ kind: deriveProjectKind(input.placement.checkout),
+ displayName: input.placement.projectName,
+ createdAt: input.createdAt,
+ updatedAt: input.updatedAt,
+ archivedAt: null,
+ });
+ }
+
+ private buildPersistedWorkspaceRecord(input: {
+ workspaceId: string;
+ placement: ProjectPlacementPayload;
+ createdAt: string;
+ updatedAt: string;
+ }): PersistedWorkspaceRecord {
+ return createPersistedWorkspaceRecord({
+ workspaceId: input.workspaceId,
+ projectId: input.placement.projectKey,
+ cwd: input.workspaceId,
+ kind: deriveWorkspaceKind(input.placement.checkout),
+ displayName: deriveWorkspaceDisplayName({
+ cwd: input.workspaceId,
+ checkout: input.placement.checkout,
+ }),
+ createdAt: input.createdAt,
+ updatedAt: input.updatedAt,
+ archivedAt: null,
+ });
+ }
+
+ private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise {
+ const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
+ (workspace) => workspace.projectId === projectId && !workspace.archivedAt,
+ );
+ if (siblingWorkspaces.length === 0) {
+ await this.projectRegistry.archive(projectId, archivedAt);
+ }
+ }
+
private async resolveWorkspaceByIdOrDirectory(
workspaceId: string,
): Promise {
@@ -1477,10 +1534,8 @@ export class Session {
private async resolveWorkspaceDirectory(cwd: string): Promise {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
- const checkout = await getCheckoutStatusLite(normalizedCwd, {
- paseoHome: this.paseoHome,
- });
- return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
+ const snapshot = await this.workspaceGitService.getSnapshot(normalizedCwd);
+ return normalizePersistedWorkspaceId(snapshot.git.repoRoot ?? normalizedCwd);
} catch {
return normalizedCwd;
}
@@ -1823,184 +1878,10 @@ export class Session {
await this.handleCheckoutPrStatusRequest(msg);
break;
- case "paseo_worktree_list_request":
- await this.handlePaseoWorktreeListRequest(msg);
- break;
-
- case "paseo_worktree_archive_request":
- await this.handlePaseoWorktreeArchiveRequest(msg);
- break;
-
- case "create_paseo_worktree_request":
- await this.handleCreatePaseoWorktreeRequest(msg);
- break;
-
- case "list_available_editors_request":
- await this.handleListAvailableEditorsRequest(msg);
- break;
-
- case "open_in_editor_request":
- await this.handleOpenInEditorRequest(msg);
- break;
-
- case "open_project_request":
- await this.handleOpenProjectRequest(msg);
- break;
-
- case "archive_workspace_request":
- await this.handleArchiveWorkspaceRequest(msg);
- break;
-
- case "file_explorer_request":
- await this.handleFileExplorerRequest(msg);
- break;
-
- case "project_icon_request":
- await this.handleProjectIconRequest(msg);
- break;
-
- case "file_download_token_request":
- await this.handleFileDownloadTokenRequest(msg);
- break;
-
- case "list_provider_models_request":
- await this.handleListProviderModelsRequest(msg);
- break;
-
- case "list_provider_modes_request":
- await this.handleListProviderModesRequest(msg);
- break;
-
- case "list_provider_features_request":
- await this.handleListProviderFeaturesRequest(msg);
- break;
-
- case "list_available_providers_request":
- await this.handleListAvailableProvidersRequest(msg);
- break;
-
- case "get_providers_snapshot_request":
- await this.handleGetProvidersSnapshotRequest(msg);
- break;
-
- case "refresh_providers_snapshot_request":
- await this.handleRefreshProvidersSnapshotRequest(msg);
- break;
-
- case "provider_diagnostic_request":
- await this.handleProviderDiagnosticRequest(msg);
- break;
-
- case "clear_agent_attention":
- await this.handleClearAgentAttention(msg.agentId, msg.requestId);
- break;
-
- case "client_heartbeat":
- this.handleClientHeartbeat(msg);
- break;
-
- case "ping": {
- const now = Date.now();
- this.emit({
- type: "pong",
- payload: {
- requestId: msg.requestId,
- clientSentAt: msg.clientSentAt,
- serverReceivedAt: now,
- serverSentAt: now,
- },
- });
- break;
- }
-
- case "list_commands_request":
- await this.handleListCommandsRequest(msg);
- break;
-
- case "register_push_token":
- this.handleRegisterPushToken(msg.token);
- break;
-
- case "subscribe_terminals_request":
- this.handleSubscribeTerminalsRequest(msg);
- break;
-
- case "unsubscribe_terminals_request":
- this.handleUnsubscribeTerminalsRequest(msg);
- break;
-
- case "list_terminals_request":
- await this.handleListTerminalsRequest(msg);
- break;
-
- case "create_terminal_request":
- await this.handleCreateTerminalRequest(msg);
- break;
-
- case "subscribe_terminal_request":
- await this.handleSubscribeTerminalRequest(msg);
- break;
-
- case "unsubscribe_terminal_request":
- this.handleUnsubscribeTerminalRequest(msg);
- break;
-
- case "terminal_input":
- this.handleTerminalInput(msg);
- break;
-
- case "kill_terminal_request":
- await this.handleKillTerminalRequest(msg);
- break;
-
- case "capture_terminal_request":
- await this.handleCaptureTerminalRequest(msg);
- break;
-
- case "chat/create":
- await this.handleChatCreateRequest(msg);
- break;
-
- case "chat/list":
- await this.handleChatListRequest(msg);
- break;
-
- case "chat/inspect":
- await this.handleChatInspectRequest(msg);
- break;
-
- case "chat/delete":
- await this.handleChatDeleteRequest(msg);
- break;
-
- case "chat/post":
- await this.handleChatPostRequest(msg);
- break;
-
- case "chat/read":
- await this.handleChatReadRequest(msg);
- break;
-
- case "chat/wait":
- await this.handleChatWaitRequest(msg);
- break;
-
case "github_search_request":
await this.handleGitHubSearchRequest(msg);
break;
- case "directory_suggestions_request":
- await this.handleDirectorySuggestionsRequest(msg);
- break;
-
- case "checkout_pr_create_request":
- await this.handleCheckoutPrCreateRequest(msg);
- break;
-
- case "checkout_pr_status_request":
- await this.handleCheckoutPrStatusRequest(msg);
- break;
-
case "paseo_worktree_list_request":
await this.handlePaseoWorktreeListRequest(msg);
break;
@@ -2074,7 +1955,7 @@ export class Session {
break;
case "clear_agent_attention":
- await this.handleClearAgentAttention(msg.agentId);
+ await this.handleClearAgentAttention(msg.agentId, msg.requestId);
break;
case "client_heartbeat":
@@ -3378,6 +3259,7 @@ export class Session {
{
paseoHome: this.paseoHome,
sessionLogger: this.sessionLogger,
+ workspaceGitService: this.workspaceGitService,
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
},
@@ -5030,7 +4912,6 @@ export class Session {
const { cwd, requestId } = msg;
try {
- await this.workspaceGitService.refresh(cwd, { priority: "high" });
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
this.emit({
type: "checkout_pr_status_response",
@@ -5788,7 +5669,19 @@ export class Session {
projectRecord?: PersistedProjectRecord | null,
): Promise {
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
- const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
+ let snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
+ if (!snapshot) {
+ try {
+ snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
+ } catch (error) {
+ this.sessionLogger.warn(
+ { err: error, cwd: workspace.cwd },
+ "Failed to load git snapshot for workspace",
+ );
+ return base;
+ }
+ }
+
if (!snapshot) {
return base;
}
@@ -6229,7 +6122,7 @@ export class Session {
const placement = await buildProjectPlacementForCwdStandalone({
cwd: normalizedCwd,
- paseoHome: this.paseoHome,
+ workspaceGitService: this.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const timestamp = new Date().toISOString();
@@ -6263,56 +6156,21 @@ export class Session {
branchName: string;
}): Promise {
await this.findOrCreateWorkspaceForDirectory(options.repoRoot);
- const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
- const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot);
- if (!basePlacement) {
- throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
- }
-
- const projectId = basePlacement.projectKey;
- const now = new Date().toISOString();
- const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
- if (!existingWorkspace) {
- const newRecord = createPersistedWorkspaceRecord({
- workspaceId: workspaceDirectory,
- projectId,
- cwd: workspaceDirectory,
- displayName: options.branchName,
- kind: "worktree",
- createdAt: now,
- updatedAt: now,
- });
- await this.workspaceRegistry.upsert(newRecord);
- await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
- return newRecord;
- }
-
- await this.workspaceRegistry.upsert(
- createPersistedWorkspaceRecord({
- workspaceId: existingWorkspace.workspaceId,
- projectId,
- cwd: workspaceDirectory,
- displayName: options.branchName,
- kind: "worktree",
- createdAt: existingWorkspace.createdAt,
- updatedAt: now,
- }),
+ return registerPendingWorktreeWorkspaceSession(
+ {
+ buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input),
+ buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
+ buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
+ findWorkspaceByDirectory: (directory) => this.findWorkspaceByDirectory(directory),
+ projectRegistry: this.projectRegistry,
+ syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
+ this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
+ workspaceRegistry: this.workspaceRegistry,
+ archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
+ this.archiveProjectRecordIfEmpty(projectId, archivedAt),
+ },
+ options,
);
- await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
-
- if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
- const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
- (workspace) =>
- workspace.projectId === existingWorkspace.projectId &&
- workspace.workspaceId !== existingWorkspace.workspaceId &&
- !workspace.archivedAt,
- );
- if (siblingWorkspaces.length === 0) {
- await this.projectRegistry.archive(existingWorkspace.projectId, now);
- }
- }
-
- return (await this.workspaceRegistry.get(existingWorkspace.workspaceId))!;
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise {
@@ -6790,10 +6648,13 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
+ workspaceGitService: this.workspaceGitService,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
+ syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
+ this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
},
diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts
index 302adc7a9..352292b0d 100644
--- a/packages/server/src/server/session.workspace-git-watch.test.ts
+++ b/packages/server/src/server/session.workspace-git-watch.test.ts
@@ -71,6 +71,8 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: ReturnType;
getSnapshot: ReturnType;
refresh: ReturnType;
+ requestWorkingTreeWatch: ReturnType;
+ scheduleRefreshForCwd: ReturnType;
dispose: ReturnType;
};
subscriptions: Array<{
@@ -111,6 +113,11 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
refresh: vi.fn(async () => {}),
+ requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({
+ repoRoot: cwd,
+ unsubscribe: vi.fn(),
+ })),
+ scheduleRefreshForCwd: vi.fn(),
dispose: vi.fn(),
};
@@ -351,62 +358,17 @@ describe("workspace git watch targets", () => {
});
});
- test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => {
+ test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => {
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
- let refreshed = false;
-
- workspaceGitService.refresh.mockImplementation(async () => {
- refreshed = true;
- });
- workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) =>
- createWorkspaceRuntimeSnapshot(cwd, {
- github: {
- pullRequest: refreshed
- ? {
- url: "https://github.com/acme/repo/pull/457",
- title: "After explicit refresh",
- state: "merged",
- baseRefName: "main",
- headRefName: "workspace-git-service",
- isMerged: true,
- }
- : {
- url: "https://github.com/acme/repo/pull/456",
- title: "Before explicit refresh",
- state: "open",
- baseRefName: "main",
- headRefName: "workspace-git-service",
- isMerged: false,
- },
- refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z",
- },
- }),
- );
await session.handleMessage({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
- requestId: "req-pr-refresh",
+ requestId: "req-pr-cached",
});
- expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", {
- priority: "high",
- });
- expect(
- emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
- ).toEqual({
- cwd: "/tmp/repo",
- status: {
- url: "https://github.com/acme/repo/pull/457",
- title: "After explicit refresh",
- state: "merged",
- baseRefName: "main",
- headRefName: "workspace-git-service",
- isMerged: true,
- },
- githubFeaturesEnabled: true,
- error: null,
- requestId: "req-pr-refresh",
- });
+ expect(workspaceGitService.refresh).not.toHaveBeenCalled();
+ expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
+ expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
});
});
diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts
index 1dc7d1b18..eca80beea 100644
--- a/packages/server/src/server/session.workspaces.test.ts
+++ b/packages/server/src/server/session.workspaces.test.ts
@@ -113,6 +113,11 @@ function createNoopWorkspaceGitService() {
},
}),
refresh: async () => {},
+ requestWorkingTreeWatch: async (cwd: string) => ({
+ repoRoot: cwd,
+ unsubscribe: () => {},
+ }),
+ scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
@@ -1177,7 +1182,6 @@ describe("workspace aggregation", () => {
test("create paseo worktree request returns a registered workspace descriptor", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
- const session = createSessionForWorkspaceTests() as any;
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, "paseo-home");
@@ -1188,6 +1192,45 @@ describe("workspace aggregation", () => {
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
+ const workspaceGitService = createNoopWorkspaceGitService();
+ workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
+ if (cwd === repoDir) {
+ return createWorkspaceRuntimeSnapshot(cwd, {
+ git: {
+ repoRoot: repoDir,
+ currentBranch: "main",
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ });
+ }
+
+ if (cwd.includes("worktree-123")) {
+ return createWorkspaceRuntimeSnapshot(cwd, {
+ git: {
+ repoRoot: cwd,
+ currentBranch: "worktree-123",
+ remoteUrl: null,
+ isPaseoOwnedWorktree: true,
+ mainRepoRoot: repoDir,
+ },
+ });
+ }
+
+ return createWorkspaceRuntimeSnapshot(cwd, {
+ git: {
+ repoRoot: cwd,
+ currentBranch: "main",
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ mainRepoRoot: null,
+ },
+ });
+ });
+ const session = createSessionForWorkspaceTests({
+ workspaceGitService,
+ }) as any;
const workspaces = new Map();
const projects = new Map();
diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts
index 902dcb0f8..c34fa0a3d 100644
--- a/packages/server/src/server/test-utils/paseo-daemon.ts
+++ b/packages/server/src/server/test-utils/paseo-daemon.ts
@@ -85,7 +85,7 @@ export async function createTestPaseoDaemon(
listen: `${listenHost}:0`,
paseoHome,
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
- allowedHosts: true,
+ hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index ff325460a..01551a61a 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -23,8 +23,8 @@ import {
wrapSessionMessage,
} from "./messages.js";
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
-import type { AllowedHostsConfig } from "./allowed-hosts.js";
-import { isHostAllowed } from "./allowed-hosts.js";
+import type { HostnamesConfig } from "./hostnames.js";
+import { isHostnameAllowed } from "./hostnames.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
@@ -63,11 +63,71 @@ type PendingConnection = {
type WebSocketServerConfig = {
allowedOrigins: Set;
- allowedHosts?: AllowedHostsConfig;
+ hostnames?: HostnamesConfig;
};
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
+function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl {
+ return {
+ subscribe: async ({ cwd }: { cwd: string }) => ({
+ initial: {
+ cwd,
+ git: {
+ isGit: false,
+ repoRoot: null,
+ mainRepoRoot: null,
+ currentBranch: null,
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ isDirty: null,
+ aheadBehind: null,
+ aheadOfOrigin: null,
+ behindOfOrigin: null,
+ diffStat: null,
+ },
+ github: {
+ featuresEnabled: false,
+ pullRequest: null,
+ error: null,
+ refreshedAt: null,
+ },
+ },
+ unsubscribe: () => {},
+ }),
+ peekSnapshot: () => null,
+ getSnapshot: async (cwd: string) => ({
+ cwd,
+ git: {
+ isGit: false,
+ repoRoot: null,
+ mainRepoRoot: null,
+ currentBranch: null,
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ isDirty: null,
+ aheadBehind: null,
+ aheadOfOrigin: null,
+ behindOfOrigin: null,
+ diffStat: null,
+ },
+ github: {
+ featuresEnabled: false,
+ pullRequest: null,
+ error: null,
+ refreshedAt: null,
+ },
+ }),
+ refresh: async () => {},
+ requestWorkingTreeWatch: async (cwd: string) => ({
+ repoRoot: cwd,
+ unsubscribe: () => {},
+ }),
+ scheduleRefreshForCwd: () => {},
+ dispose: () => {},
+ } as unknown as WorkspaceGitServiceImpl;
+}
+
function createNoopProjectRegistry(): ProjectRegistry {
return {
initialize: async () => {},
@@ -327,6 +387,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpPort?: () => number | null,
getDaemonTcpHost?: () => string | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
+ workspaceGitService?: WorkspaceGitServiceImpl,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -354,10 +415,7 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
- this.workspaceGitService = new WorkspaceGitServiceImpl({
- logger: this.logger,
- paseoHome,
- });
+ this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.daemonConfigStore = daemonConfigStore;
@@ -403,7 +461,7 @@ export class VoiceAssistantWebSocketServer {
});
});
- const { allowedOrigins, allowedHosts } = wsConfig;
+ const { allowedOrigins, hostnames } = wsConfig;
this.wss = new WebSocketServer({
server,
path: "/ws",
@@ -411,7 +469,7 @@ export class VoiceAssistantWebSocketServer {
const requestMetadata = extractSocketRequestMetadata(req);
const origin = requestMetadata.origin;
const requestHost = requestMetadata.host ?? null;
- if (requestHost && !isHostAllowed(requestHost, allowedHosts)) {
+ if (requestHost && !isHostnameAllowed(requestHost, hostnames)) {
this.incrementRuntimeCounter("hostRejected");
this.logger.warn(
{ ...requestMetadata, host: requestHost },
@@ -553,8 +611,8 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
- this.workspaceGitService.dispose();
this.checkoutDiffManager.dispose();
+ this.workspaceGitService.dispose();
this.pendingConnections.clear();
this.sessions.clear();
this.externalSessionsByKey.clear();
diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts
index 97baf930c..a792c1b6c 100644
--- a/packages/server/src/server/workspace-git-service.test.ts
+++ b/packages/server/src/server/workspace-git-service.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import path from "node:path";
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
import {
WorkspaceGitServiceImpl,
@@ -116,11 +117,28 @@ function createWatcher() {
};
}
+function createDirent(name: string, isDirectory: boolean) {
+ return {
+ name,
+ isDirectory: () => isDirectory,
+ };
+}
+
async function flushPromises(): Promise {
await Promise.resolve();
await Promise.resolve();
}
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
function createService(options?: {
getCheckoutStatus?: ReturnType;
getCheckoutShortstat?: ReturnType;
@@ -129,6 +147,8 @@ function createService(options?: {
resolveAbsoluteGitDir?: ReturnType;
hasOriginRemote?: ReturnType;
runGitFetch?: ReturnType;
+ runGitCommand?: ReturnType;
+ readdir?: ReturnType;
watch?: ReturnType;
now?: () => Date;
}) {
@@ -137,6 +157,7 @@ function createService(options?: {
paseoHome: "/tmp/paseo-test",
deps: {
watch: options?.watch ?? ((() => createWatcher()) as unknown as any),
+ readdir: options?.readdir ?? vi.fn(async () => []),
getCheckoutStatus:
options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat:
@@ -151,6 +172,15 @@ function createService(options?: {
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}),
+ runGitCommand:
+ options?.runGitCommand ??
+ vi.fn(async () => ({
+ stdout: "/tmp/repo\n",
+ stderr: "",
+ truncated: false,
+ exitCode: 0,
+ signal: null,
+ })),
now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")),
},
});
@@ -217,6 +247,48 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
+ test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
+ const checkoutStatusDeferred = createDeferred();
+ const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
+ const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
+ const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
+
+ const service = createService({
+ getCheckoutStatus,
+ getPullRequestStatus,
+ resolveAbsoluteGitDir,
+ });
+
+ const firstSnapshotPromise = service.getSnapshot("/tmp/repo");
+ const secondSnapshotPromise = service.getSnapshot("/tmp/repo/.");
+ await flushPromises();
+
+ expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
+ expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
+ expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
+ expect((service as any).workspaceTargets.size).toBe(0);
+ expect((service as any).workspaceTargetSetups.size).toBe(1);
+
+ checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
+
+ await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
+ createSnapshot("/tmp/repo"),
+ createSnapshot("/tmp/repo"),
+ ]);
+
+ expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
+ expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
+ expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
+ expect((service as any).workspaceTargets.size).toBe(1);
+ expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
+
+ await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
+ expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
+ expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
+
+ service.dispose();
+ });
+
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
@@ -432,4 +504,172 @@ describe("WorkspaceGitServiceImpl", () => {
subscription.unsubscribe();
service.dispose();
});
+
+ test("watches nested repository directories on Linux", async () => {
+ const originalPlatform = process.platform;
+ Object.defineProperty(process, "platform", {
+ configurable: true,
+ value: "linux",
+ });
+
+ const watchCalls: Array<{ path: string; close: ReturnType }> = [];
+ const watch = vi.fn((watchPath: string) => {
+ const watcher = createWatcher();
+ watchCalls.push({ path: watchPath, close: watcher.close });
+ return watcher as any;
+ });
+ const readdir = vi.fn(async (directory: string) => {
+ if (directory === "/tmp/repo") {
+ return [
+ createDirent("packages", true),
+ createDirent(".git", true),
+ createDirent("README.md", false),
+ ];
+ }
+ if (directory === path.join("/tmp/repo", "packages")) {
+ return [createDirent("server", true), createDirent("app", true)];
+ }
+ if (directory === path.join("/tmp/repo", "packages", "server")) {
+ return [createDirent("src", true)];
+ }
+ if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
+ return [createDirent("server", true)];
+ }
+ return [];
+ });
+
+ const service = createService({ watch, readdir });
+ const subscription = await service.requestWorkingTreeWatch(
+ path.join("/tmp/repo", "packages", "server"),
+ vi.fn(),
+ );
+
+ expect(subscription.repoRoot).toBe("/tmp/repo");
+ expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
+ "/tmp/repo",
+ "/tmp/repo/.git",
+ "/tmp/repo/packages",
+ "/tmp/repo/packages/app",
+ "/tmp/repo/packages/server",
+ "/tmp/repo/packages/server/src",
+ "/tmp/repo/packages/server/src/server",
+ ]);
+
+ subscription.unsubscribe();
+ service.dispose();
+ Object.defineProperty(process, "platform", {
+ configurable: true,
+ value: originalPlatform,
+ });
+ });
+
+ test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => {
+ const watchers = [createWatcher(), createWatcher()];
+ const watch = vi
+ .fn()
+ .mockReturnValueOnce(watchers[0] as any)
+ .mockReturnValueOnce(watchers[1] as any);
+ const service = createService({ watch });
+
+ const firstListener = vi.fn();
+ const secondListener = vi.fn();
+ const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener);
+ const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener);
+
+ expect(first.repoRoot).toBe("/tmp/repo");
+ expect(second.repoRoot).toBe("/tmp/repo");
+ expect(watch).toHaveBeenCalledTimes(2);
+
+ first.unsubscribe();
+ expect(watchers[0].close).not.toHaveBeenCalled();
+ expect(watchers[1].close).not.toHaveBeenCalled();
+
+ second.unsubscribe();
+ expect(watchers[0].close).toHaveBeenCalledTimes(1);
+ expect(watchers[1].close).toHaveBeenCalledTimes(1);
+
+ service.dispose();
+ });
+
+ test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => {
+ if (process.platform === "linux") {
+ // On Linux, recursive watch is never attempted — the service uses per-directory
+ // watchers from the start. This scenario only applies to macOS/Windows where
+ // recursive watch is tried first and may fail.
+ return;
+ }
+
+ const recursiveUnsupported = new Error("recursive unsupported");
+ const watch = vi
+ .fn()
+ .mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => {
+ if (options.recursive) {
+ throw recursiveUnsupported;
+ }
+ return createWatcher() as any;
+ })
+ .mockImplementationOnce(() => createWatcher() as any);
+
+ const service = createService({ watch });
+ const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
+ const target = (service as any).workingTreeWatchTargets.get("/tmp/repo");
+
+ expect(target?.fallbackRefreshInterval).not.toBeNull();
+
+ subscription.unsubscribe();
+ service.dispose();
+ });
+
+ test("non-git directories fall back to watching cwd with polling", async () => {
+ const watch = vi.fn(() => createWatcher() as any);
+ const runGitCommand = vi.fn(async () => {
+ throw new Error("not a git repository");
+ });
+ const resolveAbsoluteGitDir = vi.fn(async () => null);
+ const service = createService({
+ watch,
+ runGitCommand,
+ resolveAbsoluteGitDir,
+ });
+
+ const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn());
+ const target = (service as any).workingTreeWatchTargets.get("/tmp/plain");
+
+ expect(subscription.repoRoot).toBeNull();
+ const expectedRecursive = process.platform !== "linux";
+ expect(watch).toHaveBeenCalledWith(
+ "/tmp/plain",
+ { recursive: expectedRecursive },
+ expect.any(Function),
+ );
+ expect(target?.repoWatchPath).toBe("/tmp/plain");
+ expect(target?.fallbackRefreshInterval).not.toBeNull();
+
+ subscription.unsubscribe();
+ service.dispose();
+ });
+
+ test("working tree changes notify listeners and schedule workspace refresh", async () => {
+ const watchCallbacks: Array<() => void> = [];
+ const watch = vi.fn(
+ (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
+ watchCallbacks.push(callback);
+ return createWatcher() as any;
+ },
+ );
+ const service = createService({ watch });
+ const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh");
+ const listener = vi.fn();
+
+ const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener);
+ expect(watchCallbacks).toHaveLength(2);
+
+ watchCallbacks[0]?.();
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo");
+
+ subscription.unsubscribe();
+ service.dispose();
+ });
});
diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts
index 62508470a..2adf7d315 100644
--- a/packages/server/src/server/workspace-git-service.ts
+++ b/packages/server/src/server/workspace-git-service.ts
@@ -1,5 +1,5 @@
import { watch, type FSWatcher } from "node:fs";
-import { readFile } from "node:fs/promises";
+import { readFile, readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import type pino from "pino";
import type { CheckoutContext } from "../utils/checkout-git.js";
@@ -12,10 +12,12 @@ import {
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import { runGitCommand } from "../utils/run-git-command.js";
+import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
+const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
export type WorkspaceGitRuntimeSnapshot = {
cwd: string;
@@ -59,6 +61,11 @@ export interface WorkspaceGitService {
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
getSnapshot(cwd: string): Promise;
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise;
+ requestWorkingTreeWatch(
+ cwd: string,
+ onChange: () => void,
+ ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
+ scheduleRefreshForCwd(cwd: string): void;
dispose(): void;
}
@@ -66,6 +73,7 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo
interface WorkspaceGitServiceDependencies {
watch: typeof watch;
+ readdir: typeof readdir;
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getPullRequestStatus: typeof getPullRequestStatus;
@@ -73,6 +81,7 @@ interface WorkspaceGitServiceDependencies {
resolveAbsoluteGitDir: (cwd: string) => Promise;
hasOriginRemote: (cwd: string) => Promise;
runGitFetch: (cwd: string) => Promise;
+ runGitCommand: typeof runGitCommand;
now: () => Date;
}
@@ -102,6 +111,18 @@ interface RepoGitTarget {
fetchInFlight: boolean;
}
+interface WorkingTreeWatchTarget {
+ cwd: string;
+ repoRoot: string | null;
+ repoWatchPath: string | null;
+ watchers: FSWatcher[];
+ watchedPaths: Set;
+ fallbackRefreshInterval: NodeJS.Timeout | null;
+ linuxTreeRefreshPromise: Promise | null;
+ linuxTreeRefreshQueued: boolean;
+ listeners: Set<() => void>;
+}
+
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
@@ -109,12 +130,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly workspaceTargets = new Map();
private readonly repoTargets = new Map();
private readonly workspaceTargetSetups = new Map>();
+ private readonly workingTreeWatchTargets = new Map();
+ private readonly workingTreeWatchSetups = new Map>();
constructor(options: WorkspaceGitServiceOptions) {
this.logger = options.logger.child({ module: "workspace-git-service" });
this.paseoHome = options.paseoHome;
this.deps = {
- watch,
+ watch: options.deps?.watch ?? watch,
+ readdir: options.deps?.readdir ?? readdir,
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
@@ -122,6 +146,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
+ runGitCommand: options.deps?.runGitCommand ?? runGitCommand,
now: options.deps?.now ?? (() => new Date()),
};
}
@@ -151,7 +176,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (target?.latestSnapshot) {
return target.latestSnapshot;
}
- return this.refreshSnapshot(cwd);
+
+ const ensuredTarget = await this.ensureWorkspaceTarget(cwd);
+ return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd));
}
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
@@ -170,17 +197,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
await this.ensureWorkspaceTarget(cwd);
}
+ async requestWorkingTreeWatch(
+ cwd: string,
+ onChange: () => void,
+ ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
+ cwd = normalizeWorkspaceId(cwd);
+ const target = await this.ensureWorkingTreeWatchTarget(cwd);
+ target.listeners.add(onChange);
+
+ return {
+ repoRoot: target.repoRoot,
+ unsubscribe: () => {
+ this.removeWorkingTreeWatchListener(cwd, onChange);
+ },
+ };
+ }
+
+ scheduleRefreshForCwd(cwd: string): void {
+ cwd = normalizeWorkspaceId(cwd);
+ const target = this.workspaceTargets.get(cwd);
+ if (target) {
+ this.scheduleWorkspaceRefresh(target);
+ }
+ }
+
dispose(): void {
for (const target of this.workspaceTargets.values()) {
this.closeWorkspaceTarget(target);
}
this.workspaceTargets.clear();
+ this.workspaceTargetSetups.clear();
for (const target of this.repoTargets.values()) {
this.closeRepoTarget(target);
}
this.repoTargets.clear();
- this.workspaceTargetSetups.clear();
+
+ for (const target of this.workingTreeWatchTargets.values()) {
+ this.closeWorkingTreeWatchTarget(target);
+ }
+ this.workingTreeWatchTargets.clear();
+ this.workingTreeWatchSetups.clear();
}
private async ensureWorkspaceTarget(cwd: string): Promise {
@@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return setup;
}
+ private async ensureWorkingTreeWatchTarget(cwd: string): Promise {
+ const existingTarget = this.workingTreeWatchTargets.get(cwd);
+ if (existingTarget) {
+ return existingTarget;
+ }
+
+ const existingSetup = this.workingTreeWatchSetups.get(cwd);
+ if (existingSetup) {
+ return existingSetup;
+ }
+
+ const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => {
+ this.workingTreeWatchSetups.delete(cwd);
+ });
+ this.workingTreeWatchSetups.set(cwd, setup);
+ return setup;
+ }
+
private async createWorkspaceTarget(cwd: string): Promise {
const target: WorkspaceGitTarget = {
cwd,
@@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return target;
}
+ private async createWorkingTreeWatchTarget(cwd: string): Promise {
+ const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
+ const target: WorkingTreeWatchTarget = {
+ cwd,
+ repoRoot,
+ repoWatchPath: null,
+ watchers: [],
+ watchedPaths: new Set(),
+ fallbackRefreshInterval: null,
+ linuxTreeRefreshPromise: null,
+ linuxTreeRefreshQueued: false,
+ listeners: new Set(),
+ };
+
+ const repoWatchPath = repoRoot ?? cwd;
+ target.repoWatchPath = repoWatchPath;
+ const watchPaths = new Set([repoWatchPath]);
+ const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
+ if (gitDir) {
+ watchPaths.add(gitDir);
+ }
+
+ let hasRecursiveRepoCoverage = false;
+ const allowRecursiveRepoWatch = process.platform !== "linux";
+ if (process.platform === "linux") {
+ hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
+ }
+ for (const watchPath of watchPaths) {
+ if (process.platform === "linux" && watchPath === repoWatchPath) {
+ continue;
+ }
+ const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
+ const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive);
+ if (watchPath === repoWatchPath && watcherIsRecursive) {
+ hasRecursiveRepoCoverage = true;
+ }
+ }
+
+ const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage;
+ if (target.watchers.length === 0 || missingRepoCoverage) {
+ target.fallbackRefreshInterval = setInterval(() => {
+ this.scheduleWorkspaceRefresh(cwd);
+ for (const listener of target.listeners) {
+ listener();
+ }
+ }, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS);
+ this.logger.warn(
+ {
+ cwd,
+ intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS,
+ reason:
+ target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
+ },
+ "Working tree watchers unavailable; using timed refresh fallback",
+ );
+ }
+
+ this.workingTreeWatchTargets.set(cwd, target);
+ return target;
+ }
+
+ private async resolveCheckoutWatchRoot(cwd: string): Promise {
+ try {
+ const { stdout } = await this.deps.runGitCommand(
+ ["rev-parse", "--path-format=absolute", "--show-toplevel"],
+ {
+ cwd,
+ env: READ_ONLY_GIT_ENV,
+ },
+ );
+ const root = stdout.trim();
+ return root.length > 0 ? root : null;
+ } catch {
+ return null;
+ }
+ }
+
private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise {
try {
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
@@ -308,7 +460,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
void this.runRepoFetch(repoTarget);
}
- private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void {
+ private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void {
+ const target =
+ typeof targetOrCwd === "string"
+ ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
+ : targetOrCwd;
+ if (!target) {
+ return;
+ }
+
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
}
@@ -319,6 +479,149 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
}
+ private addWorkingTreeWatcher(
+ target: WorkingTreeWatchTarget,
+ watchPath: string,
+ shouldTryRecursive: boolean,
+ ): boolean {
+ if (target.watchedPaths.has(watchPath)) {
+ return false;
+ }
+
+ const { cwd } = target;
+ const onChange = () => {
+ if (process.platform === "linux" && target.repoWatchPath) {
+ void this.refreshLinuxRepoTreeWatchers(target);
+ }
+ this.scheduleWorkspaceRefresh(cwd);
+ for (const listener of target.listeners) {
+ listener();
+ }
+ };
+ const createWatcher = (recursive: boolean): FSWatcher =>
+ this.deps.watch(watchPath, { recursive }, () => {
+ onChange();
+ });
+
+ let watcher: FSWatcher | null = null;
+ let watcherIsRecursive = false;
+ try {
+ if (shouldTryRecursive) {
+ watcher = createWatcher(true);
+ watcherIsRecursive = true;
+ } else {
+ watcher = createWatcher(false);
+ }
+ } catch (error) {
+ if (shouldTryRecursive) {
+ try {
+ watcher = createWatcher(false);
+ this.logger.warn(
+ { err: error, watchPath, cwd },
+ "Working tree recursive watch unavailable; using non-recursive fallback",
+ );
+ } catch (fallbackError) {
+ this.logger.warn(
+ { err: fallbackError, watchPath, cwd },
+ "Failed to start working tree watcher",
+ );
+ }
+ } else {
+ this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher");
+ }
+ }
+
+ if (!watcher) {
+ return false;
+ }
+
+ watcher.on("error", (error) => {
+ this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error");
+ });
+ target.watchers.push(watcher);
+ target.watchedPaths.add(watchPath);
+ return watcherIsRecursive;
+ }
+
+ private async ensureLinuxRepoTreeWatchers(
+ target: WorkingTreeWatchTarget,
+ rootPath: string,
+ ): Promise {
+ const directories = await this.listLinuxWatchDirectories(rootPath);
+ let complete = true;
+ for (const directory of directories) {
+ const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false);
+ if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
+ complete = false;
+ }
+ }
+ return complete && target.watchedPaths.has(rootPath);
+ }
+
+ private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise {
+ if (process.platform !== "linux" || !target.repoWatchPath) {
+ return;
+ }
+ const rootPath = target.repoWatchPath;
+ if (target.linuxTreeRefreshPromise) {
+ target.linuxTreeRefreshQueued = true;
+ return;
+ }
+
+ target.linuxTreeRefreshPromise = (async () => {
+ do {
+ target.linuxTreeRefreshQueued = false;
+ try {
+ await this.ensureLinuxRepoTreeWatchers(target, rootPath);
+ } catch (error) {
+ this.logger.warn(
+ {
+ err: error,
+ cwd: target.cwd,
+ rootPath,
+ },
+ "Failed to refresh Linux working tree watchers",
+ );
+ }
+ } while (target.linuxTreeRefreshQueued);
+ })();
+
+ try {
+ await target.linuxTreeRefreshPromise;
+ } finally {
+ target.linuxTreeRefreshPromise = null;
+ }
+ }
+
+ private async listLinuxWatchDirectories(rootPath: string): Promise {
+ const directories: string[] = [];
+ const pending = [rootPath];
+
+ while (pending.length > 0) {
+ const directory = pending.pop();
+ if (!directory) {
+ continue;
+ }
+ directories.push(directory);
+
+ let entries;
+ try {
+ entries = await this.deps.readdir(directory, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name === ".git") {
+ continue;
+ }
+ pending.push(join(directory, entry.name));
+ }
+ }
+
+ return directories;
+ }
+
private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise {
if (target.refreshPromise) {
target.refreshQueued = true;
@@ -435,6 +738,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workspaceTargets.delete(target.cwd);
}
+ private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
+ const target = this.workingTreeWatchTargets.get(cwd);
+ if (!target) {
+ return;
+ }
+
+ target.listeners.delete(listener);
+ if (target.listeners.size > 0) {
+ return;
+ }
+
+ this.closeWorkingTreeWatchTarget(target);
+ this.workingTreeWatchTargets.delete(cwd);
+ }
+
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -448,6 +766,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.listeners.clear();
}
+ private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
+ if (target.fallbackRefreshInterval) {
+ clearInterval(target.fallbackRefreshInterval);
+ target.fallbackRefreshInterval = null;
+ }
+
+ for (const watcher of target.watchers) {
+ watcher.close();
+ }
+ target.watchers = [];
+ target.watchedPaths.clear();
+ target.listeners.clear();
+ }
+
private closeRepoTarget(target: RepoGitTarget): void {
if (target.intervalId) {
clearInterval(target.intervalId);
diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts
new file mode 100644
index 000000000..5f81aa3e2
--- /dev/null
+++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts
@@ -0,0 +1,232 @@
+import os from "node:os";
+import path from "node:path";
+import { mkdtempSync, rmSync } from "node:fs";
+
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+
+import { createTestLogger } from "../test-utils/test-logger.js";
+import { AgentStorage } from "./agent/agent-storage.js";
+import type { WorkspaceGitService } from "./workspace-git-service.js";
+import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
+import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
+
+function createNoopWorkspaceGitService(): WorkspaceGitService {
+ return {
+ subscribe: async (params) => ({
+ initial: {
+ cwd: params.cwd,
+ git: {
+ isGit: false,
+ repoRoot: null,
+ mainRepoRoot: null,
+ currentBranch: null,
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ isDirty: null,
+ aheadBehind: null,
+ aheadOfOrigin: null,
+ behindOfOrigin: null,
+ diffStat: null,
+ },
+ github: {
+ featuresEnabled: false,
+ pullRequest: null,
+ error: null,
+ refreshedAt: null,
+ },
+ },
+ unsubscribe: () => {},
+ }),
+ peekSnapshot: () => null,
+ getSnapshot: async (cwd) => ({
+ cwd,
+ git: {
+ isGit: false,
+ repoRoot: null,
+ mainRepoRoot: null,
+ currentBranch: null,
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ isDirty: null,
+ aheadBehind: null,
+ aheadOfOrigin: null,
+ behindOfOrigin: null,
+ diffStat: null,
+ },
+ github: {
+ featuresEnabled: false,
+ pullRequest: null,
+ error: null,
+ refreshedAt: null,
+ },
+ }),
+ refresh: async () => {},
+ requestWorkingTreeWatch: async () => ({
+ repoRoot: null,
+ unsubscribe: () => {},
+ }),
+ scheduleRefreshForCwd: () => {},
+ dispose: () => {},
+ };
+}
+
+describe("bootstrapWorkspaceRegistries", () => {
+ let tmpDir: string;
+ let paseoHome: string;
+ let agentStorage: AgentStorage;
+ let projectRegistry: FileBackedProjectRegistry;
+ let workspaceRegistry: FileBackedWorkspaceRegistry;
+ let workspaceGitService: WorkspaceGitService;
+ const logger = createTestLogger();
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
+ paseoHome = path.join(tmpDir, ".paseo");
+ agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
+ projectRegistry = new FileBackedProjectRegistry(
+ path.join(paseoHome, "projects", "projects.json"),
+ logger,
+ );
+ workspaceRegistry = new FileBackedWorkspaceRegistry(
+ path.join(paseoHome, "projects", "workspaces.json"),
+ logger,
+ );
+ workspaceGitService = createNoopWorkspaceGitService();
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ test("materializes workspace registries from non-archived agent records", async () => {
+ await agentStorage.initialize();
+ await agentStorage.upsert({
+ id: "agent-1",
+ provider: "codex",
+ cwd: "/tmp/non-git-project",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-02T00:00:00.000Z",
+ lastActivityAt: "2026-03-02T00:00:00.000Z",
+ lastUserMessageAt: null,
+ title: null,
+ labels: {},
+ lastStatus: "idle",
+ lastModeId: null,
+ config: null,
+ runtimeInfo: { provider: "codex", sessionId: null },
+ persistence: null,
+ archivedAt: null,
+ });
+ await agentStorage.upsert({
+ id: "agent-2",
+ provider: "codex",
+ cwd: "/tmp/non-git-project",
+ createdAt: "2026-03-01T01:00:00.000Z",
+ updatedAt: "2026-03-03T00:00:00.000Z",
+ lastActivityAt: "2026-03-03T00:00:00.000Z",
+ lastUserMessageAt: null,
+ title: null,
+ labels: {},
+ lastStatus: "running",
+ lastModeId: null,
+ config: null,
+ runtimeInfo: { provider: "codex", sessionId: null },
+ persistence: null,
+ archivedAt: null,
+ });
+ await agentStorage.upsert({
+ id: "agent-archived",
+ provider: "codex",
+ cwd: "/tmp/archived-project",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ lastActivityAt: "2026-03-01T00:00:00.000Z",
+ lastUserMessageAt: null,
+ title: null,
+ labels: {},
+ lastStatus: "idle",
+ lastModeId: null,
+ config: null,
+ runtimeInfo: { provider: "codex", sessionId: null },
+ persistence: null,
+ archivedAt: "2026-03-02T00:00:00.000Z",
+ });
+
+ await bootstrapWorkspaceRegistries({
+ paseoHome,
+ agentStorage,
+ projectRegistry,
+ workspaceRegistry,
+ workspaceGitService,
+ logger,
+ });
+
+ const workspaces = await workspaceRegistry.list();
+ expect(workspaces).toHaveLength(1);
+ expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project");
+ expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
+ expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
+
+ const projects = await projectRegistry.list();
+ expect(projects).toHaveLength(1);
+ expect(projects[0]?.projectId).toBe("/tmp/non-git-project");
+ expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
+ expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
+ });
+
+ test("does not rematerialize when registry files already exist", async () => {
+ await projectRegistry.initialize();
+ await workspaceRegistry.initialize();
+ await projectRegistry.upsert({
+ projectId: "/tmp/existing",
+ rootPath: "/tmp/existing",
+ kind: "non_git",
+ displayName: "existing",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ archivedAt: null,
+ });
+ await workspaceRegistry.upsert({
+ workspaceId: "/tmp/existing",
+ projectId: "/tmp/existing",
+ cwd: "/tmp/existing",
+ kind: "directory",
+ displayName: "existing",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ archivedAt: null,
+ });
+
+ await agentStorage.initialize();
+ await agentStorage.upsert({
+ id: "agent-1",
+ provider: "codex",
+ cwd: "/tmp/another-project",
+ createdAt: "2026-03-02T00:00:00.000Z",
+ updatedAt: "2026-03-02T00:00:00.000Z",
+ lastActivityAt: "2026-03-02T00:00:00.000Z",
+ lastUserMessageAt: null,
+ title: null,
+ labels: {},
+ lastStatus: "idle",
+ lastModeId: null,
+ config: null,
+ runtimeInfo: { provider: "codex", sessionId: null },
+ persistence: null,
+ archivedAt: null,
+ });
+
+ await bootstrapWorkspaceRegistries({
+ paseoHome,
+ agentStorage,
+ projectRegistry,
+ workspaceRegistry,
+ workspaceGitService,
+ logger,
+ });
+
+ expect(await projectRegistry.list()).toHaveLength(1);
+ expect(await workspaceRegistry.list()).toHaveLength(1);
+ expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing");
+ });
+});
diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts
index c9403f810..625334ffe 100644
--- a/packages/server/src/server/workspace-registry-bootstrap.ts
+++ b/packages/server/src/server/workspace-registry-bootstrap.ts
@@ -13,6 +13,7 @@ import {
deriveWorkspaceKind,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
+import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -53,6 +54,7 @@ export async function bootstrapWorkspaceRegistries(options: {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
+ workspaceGitService: WorkspaceGitService;
logger: Logger;
}): Promise {
const [projectsExists, workspacesExists] = await Promise.all([
@@ -79,7 +81,7 @@ export async function bootstrapWorkspaceRegistries(options: {
const normalizedCwd = normalizeWorkspaceId(record.cwd);
const placement = await buildProjectPlacementForCwd({
cwd: normalizedCwd,
- paseoHome: options.paseoHome,
+ workspaceGitService: options.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts
index 545095e71..8bce33279 100644
--- a/packages/server/src/server/workspace-registry-model.ts
+++ b/packages/server/src/server/workspace-registry-model.ts
@@ -1,7 +1,7 @@
import { resolve } from "node:path";
-import { getCheckoutStatusLite } from "../utils/checkout-git.js";
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
+import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
@@ -198,45 +198,15 @@ export async function detectStaleWorkspaces(
export async function buildProjectPlacementForCwd(input: {
cwd: string;
- paseoHome: string;
+ workspaceGitService: WorkspaceGitService;
}): Promise {
const normalizedCwd = normalizeWorkspaceId(input.cwd);
- const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
- .then((status): ProjectCheckoutLitePayload => {
- if (!status.isGit) {
- return {
- cwd: normalizedCwd,
- isGit: false,
- currentBranch: null,
- remoteUrl: null,
- worktreeRoot: null,
- isPaseoOwnedWorktree: false,
- mainRepoRoot: null,
- };
- }
-
- if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
- return {
- cwd: normalizedCwd,
- isGit: true,
- currentBranch: status.currentBranch,
- remoteUrl: status.remoteUrl,
- worktreeRoot: status.worktreeRoot,
- isPaseoOwnedWorktree: true,
- mainRepoRoot: status.mainRepoRoot,
- };
- }
-
- return {
- cwd: normalizedCwd,
- isGit: true,
- currentBranch: status.currentBranch,
- remoteUrl: status.remoteUrl,
- worktreeRoot: status.worktreeRoot,
- isPaseoOwnedWorktree: false,
- mainRepoRoot: null,
- };
- })
+ const checkout = await input.workspaceGitService
+ .getSnapshot(normalizedCwd)
+ .then(
+ (snapshot): ProjectCheckoutLitePayload =>
+ checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git),
+ )
.catch(
(): ProjectCheckoutLitePayload => ({
cwd: normalizedCwd,
diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts
index a020aaf0b..2c91c2e1b 100644
--- a/packages/server/src/server/worktree-session.ts
+++ b/packages/server/src/server/worktree-session.ts
@@ -17,10 +17,12 @@ import {
} from "./messages.js";
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
import type {
+ PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
+import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import {
applyWorktreeSetupProgressEvent,
@@ -33,7 +35,11 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
-import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
+import {
+ getCheckoutStatus,
+ getCurrentBranch,
+ resolveRepositoryDefaultBranch,
+} from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
computeWorktreePath,
@@ -51,8 +57,7 @@ import {
WorktreeSetupError,
} from "../utils/worktree.js";
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
-import { runGitCommand } from "../utils/run-git-command.js";
-import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
+import { toCheckoutError } from "./checkout-git-utils.js";
const execFileAsync = promisify(execFile);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
@@ -70,6 +75,7 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type BuildAgentSessionConfigDependencies = {
paseoHome?: string;
sessionLogger: Logger;
+ workspaceGitService?: WorkspaceGitService;
checkoutExistingBranch: (cwd: string, branch: string) => Promise;
createBranchFromBase: (params: {
cwd: string;
@@ -90,6 +96,18 @@ type ArchivePaseoWorktreeDependencies = {
};
type RegisterPendingWorktreeWorkspaceDependencies = {
+ buildPersistedProjectRecord: (input: {
+ workspaceId: string;
+ placement: ProjectPlacementPayload;
+ createdAt: string;
+ updatedAt: string;
+ }) => PersistedProjectRecord;
+ buildPersistedWorkspaceRecord: (input: {
+ workspaceId: string;
+ placement: ProjectPlacementPayload;
+ createdAt: string;
+ updatedAt: string;
+ }) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise;
findWorkspaceByDirectory: (directory: string) => Promise;
projectRegistry: Pick;
@@ -121,6 +139,7 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
+ workspaceGitService?: WorkspaceGitService;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise;
@@ -130,6 +149,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
worktreePath: string;
branchName: string;
}) => Promise;
+ syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise;
sessionLogger: Logger;
runWorktreeSetupInBackground: (options: {
requestCwd: string;
@@ -178,7 +198,11 @@ export async function buildAgentSessionConfig(
const baseBranch =
githubPrAttachment.baseRefName?.trim() ||
normalized.baseBranch ||
- (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
+ (await resolveGitCreateBaseBranch(
+ cwd,
+ dependencies.workspaceGitService,
+ dependencies.paseoHome,
+ ));
const worktreeSlug =
normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
const createdWorktree = await createGitHubPrWorktree({
@@ -208,11 +232,7 @@ export async function buildAgentSessionConfig(
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
- const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
- cwd,
- env: READ_ONLY_GIT_ENV,
- });
- targetBranch = stdout.trim();
+ targetBranch = (await getCurrentBranch(cwd)) ?? "";
}
if (!targetBranch) {
@@ -225,7 +245,12 @@ export async function buildAgentSessionConfig(
);
const baseBranch =
- normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
+ normalized.baseBranch ??
+ (await resolveGitCreateBaseBranch(
+ cwd,
+ dependencies.workspaceGitService,
+ dependencies.paseoHome,
+ ));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
@@ -237,7 +262,12 @@ export async function buildAgentSessionConfig(
worktreeBootstrap = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch =
- normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
+ normalized.baseBranch ??
+ (await resolveGitCreateBaseBranch(
+ cwd,
+ dependencies.workspaceGitService,
+ dependencies.paseoHome,
+ ));
await dependencies.createBranchFromBase({
cwd,
baseBranch,
@@ -322,13 +352,31 @@ export function assertSafeGitRef(ref: string, label: string): void {
}
}
-export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise {
- const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
- if (!checkout.isGit) {
- throw new Error("Cannot create a worktree outside a git repository");
- }
+export async function resolveGitCreateBaseBranch(
+ cwd: string,
+ workspaceGitService?: WorkspaceGitService,
+ paseoHome?: string,
+): Promise {
+ let repoRoot = cwd;
+ if (workspaceGitService) {
+ const snapshot = await workspaceGitService.getSnapshot(cwd);
+ if (!snapshot.git.isGit) {
+ throw new Error("Cannot create a worktree outside a git repository");
+ }
- const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
+ repoRoot = snapshot.git.isPaseoOwnedWorktree
+ ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd)
+ : (snapshot.git.repoRoot ?? cwd);
+ } else {
+ const checkout = await getCheckoutStatus(cwd, paseoHome ? { paseoHome } : undefined);
+ if (!checkout.isGit) {
+ throw new Error("Cannot create a worktree outside a git repository");
+ }
+
+ repoRoot = checkout.isPaseoOwnedWorktree
+ ? (checkout.mainRepoRoot ?? checkout.repoRoot ?? cwd)
+ : (checkout.repoRoot ?? cwd);
+ }
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
@@ -603,46 +651,47 @@ export async function registerPendingWorktreeWorkspace(
): Promise {
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
- const projectId = basePlacement.projectKey;
+ const placement: ProjectPlacementPayload = {
+ ...basePlacement,
+ checkout: {
+ cwd: workspaceDirectory,
+ isGit: true,
+ currentBranch: options.branchName,
+ remoteUrl: basePlacement.checkout.remoteUrl,
+ worktreeRoot: options.worktreePath,
+ isPaseoOwnedWorktree: true,
+ mainRepoRoot: options.repoRoot,
+ },
+ };
const now = new Date().toISOString();
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
- if (!existingWorkspace) {
- const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = {
- workspaceId: workspaceDirectory,
- projectId,
- cwd: workspaceDirectory,
- displayName: options.branchName,
- kind: "worktree",
- createdAt: now,
- updatedAt: now,
- archivedAt: null,
- };
- await dependencies.workspaceRegistry.upsert(newRecord);
- const workspace = await dependencies.workspaceRegistry.get(workspaceDirectory);
- if (!workspace) {
- throw new Error(`Workspace not found after upsert: ${workspaceDirectory}`);
- }
- await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
- return workspace;
- }
-
- await dependencies.workspaceRegistry.upsert({
- workspaceId: existingWorkspace.workspaceId,
- projectId,
- cwd: workspaceDirectory,
- displayName: options.branchName,
- kind: "worktree",
- createdAt: existingWorkspace.createdAt,
+ const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
+ const nextProjectRecord = dependencies.buildPersistedProjectRecord({
+ workspaceId: workspaceDirectory,
+ placement,
+ createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
- archivedAt: null,
});
+ const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
+ workspaceId: workspaceDirectory,
+ placement,
+ createdAt: existingWorkspace?.createdAt ?? now,
+ updatedAt: now,
+ });
+
+ await dependencies.projectRegistry.upsert(nextProjectRecord);
+ await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
- if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
+ if (
+ existingWorkspace &&
+ !existingWorkspace.archivedAt &&
+ existingWorkspace.projectId !== nextWorkspaceRecord.projectId
+ ) {
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
- return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!;
+ return (await dependencies.workspaceRegistry.get(nextWorkspaceRecord.workspaceId))!;
}
export async function handleCreatePaseoWorktreeRequest(
@@ -650,14 +699,29 @@ export async function handleCreatePaseoWorktreeRequest(
request: Extract,
): Promise {
try {
- const checkout = await getCheckoutStatusLite(request.cwd, {
- paseoHome: dependencies.paseoHome,
- });
- if (!checkout.isGit) {
- throw new Error("Create worktree requires a git repository");
- }
+ let repoRoot = request.cwd;
+ if (dependencies.workspaceGitService) {
+ const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd);
+ if (!snapshot.git.isGit) {
+ throw new Error("Create worktree requires a git repository");
+ }
- const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
+ repoRoot = snapshot.git.isPaseoOwnedWorktree
+ ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd)
+ : (snapshot.git.repoRoot ?? request.cwd);
+ } else {
+ const checkout = await getCheckoutStatus(
+ request.cwd,
+ dependencies.paseoHome ? { paseoHome: dependencies.paseoHome } : undefined,
+ );
+ if (!checkout.isGit) {
+ throw new Error("Create worktree requires a git repository");
+ }
+
+ repoRoot = checkout.isPaseoOwnedWorktree
+ ? (checkout.mainRepoRoot ?? checkout.repoRoot ?? request.cwd)
+ : (checkout.repoRoot ?? request.cwd);
+ }
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts
index 136748676..9b2f2ac27 100644
--- a/packages/server/src/utils/checkout-git.test.ts
+++ b/packages/server/src/utils/checkout-git.test.ts
@@ -20,11 +20,11 @@ import {
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCachedCheckoutShortstat,
+ getCurrentBranch,
getCheckoutDiff,
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutStatus,
- getCheckoutStatusLite,
listBranchSuggestions,
mergeToBase,
mergeFromBase,
@@ -91,6 +91,15 @@ describe("checkout git utilities", () => {
);
});
+ it("returns null for getCurrentBranch in a repo with no commits", async () => {
+ const emptyRepo = join(tempDir, "empty-repo");
+ execSync(`mkdir -p ${emptyRepo}`);
+ execSync("git init -b main", { cwd: emptyRepo });
+
+ const branch = await getCurrentBranch(emptyRepo);
+ expect(branch).toBeNull();
+ });
+
it("handles status/diff/commit in a normal repo", async () => {
writeFileSync(join(repoDir, "file.txt"), "updated\n");
@@ -170,13 +179,16 @@ const x = 1;
expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]);
});
- it("returns lightweight checkout status for normal repos", async () => {
- const status = await getCheckoutStatusLite(repoDir);
+ it("returns checkout root metadata for normal repos", async () => {
+ const status = await getCheckoutStatus(repoDir);
expect(status.isGit).toBe(true);
+ if (!status.isGit) {
+ return;
+ }
expect(status.currentBranch).toBe("main");
- expect(status.worktreeRoot).toBe(repoDir);
+ expect(status.repoRoot).toBe(repoDir);
expect(status.isPaseoOwnedWorktree).toBe(false);
- expect(status.mainRepoRoot).toBeNull();
+ expect(status.mainRepoRoot ?? null).toBeNull();
});
it("exposes hasRemote when origin is configured", async () => {
@@ -404,7 +416,7 @@ const x = 1;
expect(message).toBe("worktree update");
});
- it("returns lightweight checkout status for .paseo worktrees", async () => {
+ it("returns checkout root metadata for .paseo worktrees", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
@@ -413,9 +425,12 @@ const x = 1;
paseoHome,
});
- const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
+ const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
expect(status.isGit).toBe(true);
- expect(status.worktreeRoot).toBe(result.worktreePath);
+ if (!status.isGit) {
+ return;
+ }
+ expect(status.repoRoot).toBe(result.worktreePath);
expect(status.isPaseoOwnedWorktree).toBe(true);
expect(status.mainRepoRoot).toBe(repoDir);
});
diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts
index 2ea834458..1452b14e8 100644
--- a/packages/server/src/utils/checkout-git.ts
+++ b/packages/server/src/utils/checkout-git.ts
@@ -516,38 +516,6 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
-export type CheckoutStatusLiteNotGit = {
- isGit: false;
- currentBranch: null;
- remoteUrl: null;
- worktreeRoot: null;
- isPaseoOwnedWorktree: false;
- mainRepoRoot: null;
-};
-
-export type CheckoutStatusLiteGitNonPaseo = {
- isGit: true;
- currentBranch: string | null;
- remoteUrl: string | null;
- worktreeRoot: string;
- isPaseoOwnedWorktree: false;
- mainRepoRoot: null;
-};
-
-export type CheckoutStatusLiteGitPaseo = {
- isGit: true;
- currentBranch: string | null;
- remoteUrl: string | null;
- worktreeRoot: string;
- isPaseoOwnedWorktree: true;
- mainRepoRoot: string;
-};
-
-export type CheckoutStatusLiteResult =
- | CheckoutStatusLiteNotGit
- | CheckoutStatusLiteGitNonPaseo
- | CheckoutStatusLiteGitPaseo;
-
export interface CheckoutDiffResult {
diff: string;
structured?: ParsedDiffFile[];
@@ -591,12 +559,16 @@ async function requireGitRepo(cwd: string): Promise {
}
export async function getCurrentBranch(cwd: string): Promise {
- const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
- cwd,
- env: READ_ONLY_GIT_ENV,
- });
- const branch = stdout.trim();
- return branch.length > 0 ? branch : null;
+ try {
+ const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
+ cwd,
+ env: READ_ONLY_GIT_ENV,
+ });
+ const branch = stdout.trim();
+ return branch.length > 0 ? branch : null;
+ } catch {
+ return null;
+ }
}
async function getWorktreeRoot(cwd: string): Promise {
@@ -1190,43 +1162,6 @@ export async function getCheckoutStatus(
};
}
-export async function getCheckoutStatusLite(
- cwd: string,
- context?: CheckoutContext,
-): Promise {
- const inspected = await inspectCheckoutContext(cwd, context);
- if (!inspected) {
- return {
- isGit: false,
- currentBranch: null,
- remoteUrl: null,
- worktreeRoot: null,
- isPaseoOwnedWorktree: false,
- mainRepoRoot: null,
- };
- }
-
- if (inspected.configured.isPaseoOwnedWorktree) {
- return {
- isGit: true,
- currentBranch: inspected.currentBranch,
- remoteUrl: inspected.remoteUrl,
- worktreeRoot: inspected.worktreeRoot,
- isPaseoOwnedWorktree: true,
- mainRepoRoot: await getMainRepoRoot(cwd),
- };
- }
-
- return {
- isGit: true,
- currentBranch: inspected.currentBranch,
- remoteUrl: inspected.remoteUrl,
- worktreeRoot: inspected.worktreeRoot,
- isPaseoOwnedWorktree: false,
- mainRepoRoot: null,
- };
-}
-
export interface CheckoutShortstat {
additions: number;
deletions: number;
diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts
index a332dbac5..c4bf466d5 100644
--- a/packages/server/vitest.config.ts
+++ b/packages/server/vitest.config.ts
@@ -21,6 +21,6 @@ export default defineConfig({
maxForks: 1,
},
},
- exclude: ["**/node_modules/**", "**/dist/**"],
+ exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"],
},
});
diff --git a/packages/website/package.json b/packages/website/package.json
index 186ba4832..055c9800c 100644
--- a/packages/website/package.json
+++ b/packages/website/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
- "version": "0.1.55-rc.2",
+ "version": "0.1.56",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json
index a4e12119a..c9b74032a 100644
--- a/packages/website/public/schemas/paseo.config.v1.json
+++ b/packages/website/public/schemas/paseo.config.v1.json
@@ -14,7 +14,22 @@
"listen": {
"type": "string"
},
+ "hostnames": {
+ "anyOf": [
+ {
+ "type": "boolean",
+ "const": true
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
"allowedHosts": {
+ "description": "Deprecated: use hostnames instead",
"anyOf": [
{
"type": "boolean",
diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx
index 1aac1f2a6..368ae6157 100644
--- a/packages/website/src/routes/docs/configuration.tsx
+++ b/packages/website/src/routes/docs/configuration.tsx
@@ -51,8 +51,7 @@ function Configuration() {
CLI flags
- Lists append across sources (for example, allowedHosts{" "}
- and
+ Lists append across sources (for example, hostnames and
cors.allowedOrigins).
@@ -60,7 +59,7 @@ function Configuration() {
Example
- Minimal example that configures listening address, host allowlist, provider keys, and MCP:
+ Minimal example that configures listening address, hostnames, provider keys, and MCP:
{`{
@@ -71,11 +70,16 @@ function Configuration() {
},
"daemon": {
"listen": "127.0.0.1:6767",
- "allowedHosts": ["localhost", ".localhost"],
+ "hostnames": ["localhost", ".localhost"],
"mcp": { "enabled": true }
}
}`}
+
+ daemon.hostnames is the primary field. The old{" "}
+ daemon.allowedHosts name still works as a deprecated
+ alias for backward compatibility.
+
@@ -229,8 +233,12 @@ docker run --rm -i \\
daemon.listen
- PASEO_ALLOWED_HOSTS — override/extend{" "}
- daemon.allowedHosts
+ PASEO_HOSTNAMES — override/extend{" "}
+ daemon.hostnames
+
+
+ PASEO_ALLOWED_HOSTS — deprecated alias for{" "}
+ PASEO_HOSTNAMES
PASEO_LOG_CONSOLE_LEVEL — override{" "}
diff --git a/packages/website/src/routes/docs/security.tsx b/packages/website/src/routes/docs/security.tsx
index d148331cf..5b0d03f78 100644
--- a/packages/website/src/routes/docs/security.tsx
+++ b/packages/website/src/routes/docs/security.tsx
@@ -185,7 +185,7 @@ function Security() {
100.x.y.z:6767)
- Add your Tailscale hostname to allowedHosts and{" "}
+ Add your Tailscale hostname to hostnames and{" "}
cors.allowedOrigins
@@ -199,7 +199,7 @@ function Security() {
daemon reachable on all network interfaces, including public Wi-Fi and local networks.
This can expose your daemon to unauthorized access. If you must bind to all interfaces,
ensure you have proper firewall rules and review your{" "}
- allowedHosts configuration.
+ hostnames configuration.
@@ -216,7 +216,7 @@ function Security() {
on incoming requests. Requests with unrecognized hosts are rejected.
- Configure via daemon.allowedHosts in{" "}
+ Configure via daemon.hostnames in{" "}
config.json:
diff --git a/scripts/dev.ps1 b/scripts/dev.ps1
new file mode 100644
index 000000000..d86811c82
--- /dev/null
+++ b/scripts/dev.ps1
@@ -0,0 +1,65 @@
+$ErrorActionPreference = "Stop"
+
+# Ensure node_modules/.bin is in PATH
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$env:PATH = "$ScriptDir\..\node_modules\.bin;$env:PATH"
+
+# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise
+if (-not $env:PASEO_HOME) {
+ $GitDir = git rev-parse --git-dir 2>$null
+ $GitCommonDir = git rev-parse --git-common-dir 2>$null
+
+ if ($GitDir -and $GitCommonDir -and ($GitDir -ne $GitCommonDir)) {
+ # Inside a worktree — derive a stable home from the worktree name
+ $WorktreeRoot = git rev-parse --show-toplevel
+ $WorktreeName = (Split-Path -Leaf $WorktreeRoot).ToLower() -replace '[^a-z0-9-]', '-' -replace '-+', '-' -replace '^-|-$', ''
+ $env:PASEO_HOME = "$env:USERPROFILE\.paseo-$WorktreeName"
+ New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
+ } else {
+ $env:PASEO_HOME = Join-Path ([System.IO.Path]::GetTempPath()) "paseo-dev-$([System.Guid]::NewGuid().ToString('N').Substring(0,6))"
+ New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null
+ # Register cleanup on exit
+ $TempPaseoHome = $env:PASEO_HOME
+ Register-EngineEvent PowerShell.Exiting -Action {
+ Remove-Item -Recurse -Force $TempPaseoHome -ErrorAction SilentlyContinue
+ } | Out-Null
+ }
+}
+
+# Share speech models with the main install to avoid duplicate downloads
+if (-not $env:PASEO_LOCAL_MODELS_DIR) {
+ $env:PASEO_LOCAL_MODELS_DIR = "$env:USERPROFILE\.paseo\models\local-speech"
+ New-Item -ItemType Directory -Force -Path $env:PASEO_LOCAL_MODELS_DIR | Out-Null
+}
+
+Write-Host @"
+======================================================
+ Paseo Dev (Windows)
+======================================================
+ Home: $($env:PASEO_HOME)
+ Models: $($env:PASEO_LOCAL_MODELS_DIR)
+ Daemon: localhost:6767
+======================================================
+"@
+
+# Allow any origin in dev so Electron on random ports all work.
+# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
+# the daemon binds to localhost and this script is never used for production.
+# Build dependencies required by the daemon (they only ship dist/)
+Write-Host "Building @getpaseo/highlight..."
+npm run build --workspace=@getpaseo/highlight
+Write-Host "Building @getpaseo/relay..."
+npm run build --workspace=@getpaseo/relay
+
+$env:PASEO_CORS_ORIGINS = "*"
+
+# Configure the app to auto-connect to this daemon on localhost
+$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767"
+$env:BROWSER = "none"
+
+# Run both with concurrently
+concurrently `
+ --names "daemon,metro" `
+ --prefix-colors "cyan,magenta" `
+ "npm run dev:server" `
+ "cd packages/app && npx expo start"
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..c39f1daeb
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ exclude: ["**/.claude/**"],
+ },
+});