merge: integrate main into dev

Resolve 9 merge conflicts preserving both sides' intent:
- nix hash, bootstrap hostnames rename, websocket-server wiring
- session.ts handler dedup, snapshot fallback, worktree registration
- worktree-session git service + script/setup features
- sidebar-workspace-list setup navigation, session-context cleanup
- workspace-registry-bootstrap test kept (code still exists)
- checkout-git test additions from both sides

Post-merge fixes: isPaneFocused prop rename, server type rebuild.
This commit is contained in:
Mohamed Boudra
2026-04-15 16:07:39 +07:00
73 changed files with 2288 additions and 1126 deletions
+6
View File
@@ -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 <file> --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 <file> --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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 }
+10 -6
View File
@@ -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 = {
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-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).
+19 -19
View File
@@ -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",
+3 -1
View File
@@ -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",
+50 -38
View File
@@ -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<OfferPayload> {
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<void>((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);
+4 -4
View File
@@ -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",
@@ -43,12 +43,17 @@ export function BranchSwitcher({
queryClient,
});
if (!currentBranchName) {
return (
const titleContent = (
<>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
);
</>
);
if (!currentBranchName) {
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
}
return (
@@ -63,10 +68,7 @@ export function BranchSwitcher({
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
</Pressable>
<Combobox
@@ -117,7 +119,10 @@ const styles = StyleSheet.create((theme) => ({
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,
+7 -7
View File
@@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface ComposerProps {
agentId: string;
serverId: string;
isInputActive: boolean;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** 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}
@@ -264,6 +264,7 @@ export function FilePane({
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
refetchOnMount: true,
});
return (
+23 -4
View File
@@ -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<MessageInputRef, MessageInputProps>(funct
autoFocus = false,
autoFocusKey,
disabled = false,
isInputActive = true,
isPaneFocused = true,
leftContent,
beforeVoiceContent,
rightContent,
@@ -247,7 +249,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(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<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
@@ -441,7 +445,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isInputActive },
autoStopWhenHidden: { isVisible: isPaneFocused },
enableDuration: true,
});
@@ -1025,10 +1029,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(funct
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
<Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
</Text>
) : null}
</View>
{/* 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,
@@ -279,7 +279,7 @@ export function WorkspaceSetupDialog() {
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isInputActive={true}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
@@ -59,7 +59,7 @@ export function useAgentAttentionClear({
return;
}
deferredFocusEntryClearRef.current = false;
client.clearAgentAttention(resolvedAgentId);
client.clearAgentAttention(resolvedAgentId).catch(() => {});
},
[agentId, attentionReason, client, isConnected, requiresAttention],
);
@@ -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,
@@ -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",
+1 -1
View File
@@ -965,7 +965,7 @@ function ChatAgentContent({
<Composer
agentId={agentId}
serverId={serverId}
isInputActive={isPaneFocused}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
images={agentInputDraft.images}
@@ -1195,7 +1195,7 @@ function DraftAgentScreenContent({
<Composer
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isInputActive={isFocused}
isPaneFocused={isFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
@@ -287,7 +287,7 @@ export function NewWorkspaceScreen({
<Composer
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
serverId={serverId}
isInputActive={true}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
hasExternalContent={selectedGithubItem !== null}
allowEmptySubmit={true}
@@ -285,7 +285,7 @@ export function WorkspaceDraftAgentTab({
<Composer
agentId={tabId}
serverId={serverId}
isInputActive={isPaneFocused}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.55-rc.2",
"version": "0.1.56",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.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",
+21 -4
View File
@@ -1,4 +1,4 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
@@ -124,10 +124,27 @@ export function createCli(): Command {
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(withOutput(runDaemonRestartCommand));
.addOption(new Option("--allowed-hosts <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());
+21 -4
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(withOutput(runRestartCommand));
.addOption(new Option("--allowed-hosts <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;
}
@@ -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);
+1 -1
View File
@@ -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) {
+13 -5
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'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 <hosts>").hideHelp())
.action(async (options: RawStartCommandOptions) => {
await runStart({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
}
+17 -9
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "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,
});
});
}
@@ -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: {
+4 -3
View File
@@ -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"
+36
View File
@@ -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`""
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.55-rc.2",
"version": "0.1.56",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -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": {
+3 -3
View File
@@ -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",
+43 -3
View File
@@ -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<void> {
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<void> {
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<void> {
@@ -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,
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
logger,
});
let allowedHosts: string[] | undefined;
let mcpAllowedHosts: string[] | undefined;
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
const createAgentMcpTransport = async (callerAgentId?: string) => {
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
agentMcpTransports.delete(sessionId);
},
enableDnsRebindingProtection: true,
...(allowedHosts ? { allowedHosts } : {}),
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
});
transport.onclose = () => {
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
});
});
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
mcpAllowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
const url = `http://127.0.0.1:${port}/mcp/agents`;
return {
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<void>();
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,
});
});
});
@@ -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([
@@ -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<OpencodeClient, "session">;
sessionId: string;
directory: string;
logger: Logger;
}): Promise<void> {
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<string>();
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<void> {
this.abortController?.abort();
await reconcileOpenCodeSessionClose({
client: this.client,
sessionId: this.sessionId,
directory: this.config.cwd,
logger: this.logger,
});
this.subscribers.clear();
this.activeForegroundTurnId = 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"]);
});
});
@@ -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);
+15 -6
View File
@@ -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<string, StreamableHTTPServerTransport>;
@@ -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({
@@ -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<typeof vi.fn> }> = [];
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<typeof import("node:fs/promises")>("node:fs/promises");
return {
...actual,
readdir: readdirMock,
};
});
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("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" },
);
});
});
@@ -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<void> | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestFingerprint: string | null;
watchedPaths: Set<string>;
repoWatchPath: string | null;
linuxTreeRefreshPromise: Promise<void> | null;
linuxTreeRefreshQueued: boolean;
};
export class CheckoutDiffManager {
private readonly logger: pino.Logger;
private readonly paseoHome: string;
private readonly workspaceGitService: WorkspaceGitService;
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
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<string | null> {
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<string>(),
repoWatchPath: null,
linuxTreeRefreshPromise: null,
linuxTreeRefreshQueued: false,
};
const repoWatchPath = watchRoot ?? cwd;
target.repoWatchPath = repoWatchPath;
const watchPaths = new Set<string>([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<boolean> {
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<void> {
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<string[]> {
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;
}
}
+7 -11
View File
@@ -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",
@@ -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<string> {
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;
@@ -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<string> {
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.
@@ -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"]);
});
});
@@ -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>): AllowedHostsConfig {
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): 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;
@@ -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
+93 -232
View File
@@ -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<ProjectPlacementPayload> {
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<void> {
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<PersistedWorkspaceRecord | null> {
@@ -1477,10 +1534,8 @@ export class Session {
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
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<WorkspaceDescriptorPayload> {
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<PersistedWorkspaceRecord> {
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<void> {
@@ -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),
},
@@ -71,6 +71,8 @@ function createSessionForWorkspaceGitWatchTests(): {
peekSnapshot: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
requestWorkingTreeWatch: ReturnType<typeof vi.fn>;
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
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();
});
});
@@ -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();
@@ -85,7 +85,7 @@ export async function createTestPaseoDaemon(
listen: `${listenHost}:0`,
paseoHome,
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
+68 -10
View File
@@ -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<string>;
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();
@@ -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<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createService(options?: {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
@@ -129,6 +147,8 @@ function createService(options?: {
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
hasOriginRemote?: ReturnType<typeof vi.fn>;
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
readdir?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>;
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<CheckoutStatusGit>();
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<typeof vi.fn> }> = [];
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();
});
});
@@ -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<WorkspaceGitRuntimeSnapshot>;
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
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<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (cwd: string) => Promise<void>;
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<string>;
fallbackRefreshInterval: NodeJS.Timeout | null;
linuxTreeRefreshPromise: Promise<void> | 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<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
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<WorkspaceGitTarget> {
@@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return setup;
}
private async ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
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<WorkspaceGitTarget> {
const target: WorkspaceGitTarget = {
cwd,
@@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return target;
}
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: WorkingTreeWatchTarget = {
cwd,
repoRoot,
repoWatchPath: null,
watchers: [],
watchedPaths: new Set<string>(),
fallbackRefreshInterval: null,
linuxTreeRefreshPromise: null,
linuxTreeRefreshQueued: false,
listeners: new Set(),
};
const repoWatchPath = repoRoot ?? cwd;
target.repoWatchPath = repoWatchPath;
const watchPaths = new Set<string>([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<string | null> {
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<string> {
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<boolean> {
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<void> {
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<string[]> {
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<void> {
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);
@@ -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");
});
});
@@ -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<void> {
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: [] };
@@ -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<ProjectPlacementPayload> {
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,
+119 -55
View File
@@ -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<void>;
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<ProjectPlacementPayload>;
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "archive">;
@@ -121,6 +139,7 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
workspaceGitService?: WorkspaceGitService;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
@@ -130,6 +149,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
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<string> {
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<string> {
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<PersistedWorkspaceRecord> {
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<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const checkout = await getCheckoutStatusLite(request.cwd, {
paseoHome: dependencies.paseoHome,
});
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
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");
+23 -8
View File
@@ -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);
});
+10 -75
View File
@@ -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<void> {
}
export async function getCurrentBranch(cwd: string): Promise<string | null> {
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<string | null> {
@@ -1190,43 +1162,6 @@ export async function getCheckoutStatus(
};
}
export async function getCheckoutStatusLite(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutStatusLiteResult> {
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;
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
maxForks: 1,
},
},
exclude: ["**/node_modules/**", "**/dist/**"],
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"],
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.55-rc.2",
"version": "0.1.56",
"private": true,
"type": "module",
"scripts": {
@@ -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",
@@ -51,8 +51,7 @@ function Configuration() {
<li>CLI flags</li>
</ol>
<p className="text-white/60 leading-relaxed">
Lists append across sources (for example, <code className="font-mono">allowedHosts</code>{" "}
and
Lists append across sources (for example, <code className="font-mono">hostnames</code> and
<code className="font-mono">cors.allowedOrigins</code>).
</p>
</section>
@@ -60,7 +59,7 @@ function Configuration() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Example</h2>
<p className="text-white/60 leading-relaxed">
Minimal example that configures listening address, host allowlist, provider keys, and MCP:
Minimal example that configures listening address, hostnames, provider keys, and MCP:
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
@@ -71,11 +70,16 @@ function Configuration() {
},
"daemon": {
"listen": "127.0.0.1:6767",
"allowedHosts": ["localhost", ".localhost"],
"hostnames": ["localhost", ".localhost"],
"mcp": { "enabled": true }
}
}`}
</pre>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">daemon.hostnames</code> is the primary field. The old{" "}
<code className="font-mono">daemon.allowedHosts</code> name still works as a deprecated
alias for backward compatibility.
</p>
</section>
<section className="space-y-4">
@@ -229,8 +233,12 @@ docker run --rm -i \\
<code className="font-mono">daemon.listen</code>
</li>
<li>
<code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend{" "}
<code className="font-mono">daemon.allowedHosts</code>
<code className="font-mono">PASEO_HOSTNAMES</code> — override/extend{" "}
<code className="font-mono">daemon.hostnames</code>
</li>
<li>
<code className="font-mono">PASEO_ALLOWED_HOSTS</code> — deprecated alias for{" "}
<code className="font-mono">PASEO_HOSTNAMES</code>
</li>
<li>
<code className="font-mono">PASEO_LOG_CONSOLE_LEVEL</code> — override{" "}
@@ -185,7 +185,7 @@ function Security() {
<code className="font-mono">100.x.y.z:6767</code>)
</li>
<li>
Add your Tailscale hostname to <code className="font-mono">allowedHosts</code> and{" "}
Add your Tailscale hostname to <code className="font-mono">hostnames</code> and{" "}
<code className="font-mono">cors.allowedOrigins</code>
</li>
<li>
@@ -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{" "}
<code className="font-mono">allowedHosts</code> configuration.
<code className="font-mono">hostnames</code> configuration.
</div>
</section>
@@ -216,7 +216,7 @@ function Security() {
on incoming requests. Requests with unrecognized hosts are rejected.
</p>
<p className="text-white/60 leading-relaxed">
Configure via <code className="font-mono">daemon.allowedHosts</code> in{" "}
Configure via <code className="font-mono">daemon.hostnames</code> in{" "}
<code className="font-mono">config.json</code>:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
+65
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/.claude/**"],
},
});