Compare commits

...

34 Commits

Author SHA1 Message Date
Mohamed Boudra
4141c76258 chore(release): cut 0.1.73 2026-05-10 22:58:07 +07:00
Mohamed Boudra
7f44323686 chore: changelog for 0.1.73 2026-05-10 22:56:55 +07:00
Mohamed Boudra
152b07b599 fix(server): address OpenCode recovery review findings (#904)
* fix(server): pin OpenCode SDK version

* fix(server): tolerate transient OpenCode recovery poll errors

* fix(server): address OpenCode recovery findings 2 and 4

* fix(server): address OpenCode recovery finding 1

* fix(server): address OpenCode recovery finding 3
2026-05-10 23:40:34 +08:00
Mohamed Boudra
84f36d2e20 fix(server): recover OpenCode turns when 1.14.42+ SSE drops early (#902)
OpenCode 1.14.42+ closes the /event SSE stream cleanly right after
server.connected, breaking the entire turn lifecycle: prompts get
queued and run, but Paseo never sees session.idle / message deltas /
tool calls / questions. Every turn either fails with a generic stream
EOF or hangs waiting for events that never arrive.

Switch the post-EOF recovery to the canonical messages REST endpoint
(via the upgraded SDK) and poll incrementally so tool calls and
clarifying questions surface live during the SSE gap. Bound the wait
with a completion cap and a separate liveness cap so silent rejections
fail fast instead of hanging until the cap. Cap session.abort similarly
so explicit cancels land within seconds.

The recovery path is gated on the for-await loop exiting without a
terminal event, so healthy turns never enter it - if upstream restores
SSE delivery, this code becomes dead and is mechanically removable via
the COMPAT(opencodeEofRecovery) and COMPAT(opencodeSlowAbort) tags.

Also tighten the type of projectSettingsRoute on WorktreeSetupCalloutPolicy
so router.navigate accepts it (was widened to string, broke app
typecheck on main).

Refs: getpaseo/paseo#861, anomalyco/opencode#26697,
anomalyco/opencode#26635
2026-05-10 22:43:25 +08:00
João Sousa Andrade
25d4c5023a Harden file explorer symlink handling (#847) 2026-05-10 13:14:50 +00:00
João Sousa Andrade
3f5acfff31 Restrict desktop external URL schemes (#845) 2026-05-10 21:02:47 +08:00
Mohamed Boudra
b9940e285c Fix Codex sub-agent child tool failure status (#899) 2026-05-10 20:27:23 +08:00
nikuscs
9993c6c6c3 fix(app): avoid bottom sheet text input on web (#898) 2026-05-10 12:18:27 +00:00
Mohamed Boudra
3b7971a463 Fix Windows git command console flashing (#897) 2026-05-10 19:46:25 +08:00
nikuscs
d75d2d857d Fix macOS tab jump shortcut conflict (#859) 2026-05-10 11:34:14 +00:00
Mohamed Boudra
cab42985a5 Fix old relay pairing URL TLS compat (#896) 2026-05-10 19:29:56 +08:00
Mohamed Boudra
ef892bd27d Revert "fix(server): wait for opencode completion after EOF"
This reverts commit 2a84b08129.
2026-05-10 18:28:53 +07:00
Link
3014576c4c fix(server): recover completed opencode turns after SSE EOF (#895)
* fix(server): recover completed opencode turns after SSE EOF (#861)

* fix(server): wait for opencode completion after EOF

The original EOF recovery only checked OpenCode storage once after the SSE stream ended. That missed the real failure mode from #861: OpenCode can drop /event while the turn continues behind the scenes, then persist the assistant completion a little later.

Poll the persisted session for the active turn before failing EOF, while still requiring strong completion evidence and ignoring messages that predate the turn. This preserves failure behavior when there is no persisted completion.

Add behavioral coverage for delayed completion after EOF, partial streamed text plus persisted completion without duplication, stale old completions, and the no-evidence failure path.

---------

Co-authored-by: pluto <plutofog@proton.me>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-10 11:27:14 +00:00
João Sousa Andrade
b8c77bf0e3 Redact MCP debug request logs (#842) 2026-05-10 09:33:29 +00:00
Mohamed Boudra
17073fe8ff refactor(server): exercise codex features through fake app-server (#887) 2026-05-10 09:27:53 +00:00
Mohamed Boudra
6220b47073 refactor(server): extract codex app-server fake (#873) 2026-05-10 17:01:50 +08:00
Mohamed Boudra
bf7f8f686b refactor(cli): inject local daemon launch runtime (#874)
* refactor(cli): inject local daemon launch runtime

* test(app/e2e): target mobile sidebar toggle state
2026-05-10 08:38:21 +00:00
Mohamed Boudra
93cd4734ce refactor(app): extract workspace terminal lifecycle (#881) 2026-05-10 08:24:19 +00:00
Mohamed Boudra
e4acd6cb7a refactor(server): extract task document persistence (#883)
* refactor(server): extract task document persistence

* test(cli): wait for loop list visibility
2026-05-10 08:23:38 +00:00
Mohamed Boudra
ca11fc667b Refactor daemon connection probe tests (#886) 2026-05-10 08:20:16 +00:00
Mohamed Boudra
36e54a097e Refactor worktree create request parsing (#885) 2026-05-10 08:18:00 +00:00
Mohamed Boudra
ecd3137d34 Extract sidebar callout state (#884) 2026-05-10 08:09:17 +00:00
Mohamed Boudra
f881f9ae32 Refactor relay transport socket tests (#882) 2026-05-10 07:53:54 +00:00
Mohamed Boudra
3f6b84899a Extract worktree setup callout policy (#878) 2026-05-10 07:44:44 +00:00
Mohamed Boudra
5e64a1340c Unslop workspace git watch tests (#880) 2026-05-10 07:42:49 +00:00
Mohamed Boudra
2d0ed004e2 Refactor workspace layout id generation (#876) 2026-05-10 07:34:11 +00:00
Mohamed Boudra
183cda2b66 Extract agent archive projection (#877) 2026-05-10 07:32:14 +00:00
Mohamed Boudra
ed2a97fda8 Extract websocket runtime metrics (#875) 2026-05-10 07:28:48 +00:00
可乐小猫
2fed0f09bb Fix infinite recursion in web crypto randomUUID polyfill (#858)
On web (browser/Electron), expo-crypto's randomUUID() and getRandomValues()
just forward to globalThis.crypto.* (see expo-crypto/src/ExpoCrypto.web.ts).
The previous polyfill installed `g.crypto.randomUUID = () => ExpoCrypto.randomUUID()`,
which on web reads back through the same `globalThis.crypto.randomUUID` it
just installed, recursing until the stack overflows. The same trap exists
for getRandomValues, but it rarely triggers because the native version is
almost always present.

Capture a bound reference to the native getRandomValues *before* installing
the polyfill, and generate UUID v4 in JS from 16 random bytes (RFC 4122
version + variant bits). The fallback path stays on ExpoCrypto.getRandomValues
(non-recursive on native), and ExpoCrypto.randomUUID is no longer used.
2026-05-10 07:07:59 +00:00
Mohamed Boudra
478aa4b70e ci: cancel superseded PR runs to free runner capacity
cancel-in-progress is gated to pull_request events so merge_group and
push runs always complete — only redundant CI from rapid PR pushes
gets cancelled.
2026-05-10 14:12:56 +07:00
Mohamed Boudra
fd74abcdca ci: trigger required checks on merge_group events (#879)
Without merge_group on the workflow, PRs entering the merge queue would
fail because no required check ever reports a status against the
merge_group ref.
2026-05-10 15:07:39 +08:00
Mohamed Boudra
444e265275 refactor(server): inject push notification sender (#872) 2026-05-10 06:49:38 +00:00
Mohamed Boudra
2ee9329663 chore(release): cut 0.1.72 2026-05-10 13:28:02 +07:00
Mohamed Boudra
b30aafc2bd docs(changelog): draft 0.1.72 entry 2026-05-10 13:27:08 +07:00
85 changed files with 6890 additions and 3571 deletions

View File

@@ -5,8 +5,13 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
format:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,30 @@
# Changelog
## 0.1.73 - 2026-05-10
### Fixed
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
## 0.1.72 - 2026-05-10
### Fixed
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
## 0.1.71 - 2026-05-09
### Added
@@ -23,6 +48,7 @@
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
### Security

43
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.71",
"version": "0.1.73",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.71",
"version": "0.1.73",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -9397,12 +9397,6 @@
"node": ">=20.0"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -38854,7 +38848,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38980,10 +38974,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.71",
"@getpaseo/server": "0.1.73",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39026,7 +39020,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.71",
"version": "0.1.73",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -39075,7 +39069,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.71",
"version": "0.1.73",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39111,7 +39105,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -39137,7 +39131,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39152,18 +39146,18 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.71",
"@getpaseo/highlight": "0.1.73",
"@getpaseo/relay": "0.1.73",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@opencode-ai/sdk": "1.14.46",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
@@ -39344,6 +39338,15 @@
"url": "https://opencollective.com/express"
}
},
"packages/server/node_modules/@opencode-ai/sdk": {
"version": "1.14.46",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.46.tgz",
"integrity": "sha512-7KOMuoCkNI+bLOw3GCg0nWZ5m7A/MzNsyLfTbZYmE/DIaUqkV2LNRULtrW6PHL1WtYVmJEFPws4dbw/4dVxjzA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -39691,7 +39694,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.71",
"version": "0.1.73",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.71",
"version": "0.1.73",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click();
await page.getByRole("button", { name: "Open menu" }).click();
}
// force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click({ force: true });
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
}
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.71",
"version": "0.1.73",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -19,7 +19,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
if (isMobile) {
if (isMobile && isNative) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}

View File

@@ -12,7 +12,7 @@ import {
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
@@ -443,7 +443,7 @@ function ProviderSearchInput({
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};

View File

@@ -38,7 +38,7 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -148,7 +148,7 @@ export function SearchInput({
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
});
});
});

View File

@@ -0,0 +1,85 @@
import type { PaseoConfigRaw } from "@server/shared/messages";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
interface ReadProjectConfigResult {
ok: boolean;
config?: PaseoConfigRaw | null;
}
export interface WorktreeSetupCalloutPolicy {
id: string;
dismissalKey: string;
priority: number;
title: string;
description: string;
actionLabel: string;
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
testID: string;
}
export function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorktreeSetupWorkspaceInput,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
}
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}

View File

@@ -1,279 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
const activeSelection = vi.hoisted(() => ({
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
serverId: string;
workspaceId: string;
} | null,
}));
const activeWorkspace = vi.hoisted(() => ({
value: {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
} as Record<string, unknown> | null,
}));
const client = vi.hoisted(() => ({
readProjectConfig: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({
useWorkspaceFields: (
serverId: string | null,
workspaceId: string | null,
project: (workspace: Record<string, unknown>) => unknown,
) => {
if (
!activeWorkspace.value ||
serverId !== activeSelection.value?.serverId ||
workspaceId !== activeWorkspace.value.id
) {
return null;
}
return project(activeWorkspace.value);
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
function readOk(config: Record<string, unknown>) {
return {
ok: true,
config,
revision: { exists: true, mtimeMs: 1, size: 2 },
};
}
function readError() {
return {
ok: false,
error: { code: "project_not_found", message: "Project not found" },
};
}
function Harness({ queryClient }: { queryClient: QueryClient }) {
return (
<QueryClientProvider client={queryClient}>
<SidebarCalloutProvider>
<WorktreeSetupCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
</QueryClientProvider>
);
}
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
await act(async () => {
root.render(<Harness queryClient={queryClient} />);
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
async function findByTestId(testID: string): Promise<HTMLElement | null> {
let element: HTMLElement | null = null;
for (let index = 0; index < 10 && !element; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
}
return element;
}
describe("WorktreeSetupCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
};
client.readProjectConfig.mockReset();
client.readProjectConfig.mockResolvedValue(readOk({}));
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
}
queryClient?.clear();
queryClient = null;
root = null;
container?.remove();
container = null;
});
it("registers a callout for an active git workspace with missing setup", async () => {
await renderHarness(root!, queryClient!);
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
expect(container?.textContent).toContain("Set up worktree scripts");
expect(container?.textContent).toContain("Open project settings");
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
});
it("does not register a callout for a non-git workspace", async () => {
activeWorkspace.value = {
id: "workspace-1",
projectId: "project-1",
projectKind: "local",
projectRootPath: "/repo/project-1",
};
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when setup is present", async () => {
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("does not register a callout without an active workspace", async () => {
activeSelection.value = null;
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
expect(client.readProjectConfig).not.toHaveBeenCalled();
});
it("does not register a callout when reading paseo.json fails", async () => {
client.readProjectConfig.mockResolvedValue(readError());
await renderHarness(root!, queryClient!);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
it("opens project settings from the callout action", async () => {
await renderHarness(root!, queryClient!);
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
});
it("persists dismissal for the project", async () => {
await renderHarness(root!, queryClient!);
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["worktree-setup-missing:project-1"]),
);
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
});
});

View File

@@ -1,48 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { useRouter } from "expo-router";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
repoRoot: string;
}
function selectActiveGitWorkspaceProject(
serverId: string,
workspace: WorkspaceDescriptor,
): ActiveGitWorkspaceProject | null {
if (workspace.projectKind !== "git") {
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
return setup.trim().length > 0;
}
if (Array.isArray(setup)) {
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
}
return false;
}
import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
} from "./worktree-setup-callout-policy";
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
@@ -58,7 +26,7 @@ export function WorktreeSetupCalloutSource() {
if (!activeProject) {
return;
}
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
});
const readQuery = useQuery({
@@ -73,29 +41,31 @@ export function WorktreeSetupCalloutSource() {
retry: false,
});
const shouldShow =
activeProject !== null &&
readQuery.data?.ok === true &&
!hasSetupCommands(readQuery.data.config ?? {});
const calloutPolicy = useMemo(
() =>
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
? buildWorktreeSetupCalloutPolicy(activeProject)
: null,
[activeProject, readQuery.data],
);
useEffect(() => {
if (!shouldShow || !activeProject) {
if (!calloutPolicy) {
return;
}
return callouts.show({
id: `worktree-setup-missing:${activeProject.projectKey}`,
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
id: calloutPolicy.id,
dismissalKey: calloutPolicy.dismissalKey,
priority: calloutPolicy.priority,
title: calloutPolicy.title,
description: calloutPolicy.description,
actions: [
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" },
],
testID: `worktree-setup-callout-${activeProject.projectKey}`,
testID: calloutPolicy.testID,
});
}, [activeProject, callouts, openProjectSettings, shouldShow]);
}, [calloutPolicy, callouts, openProjectSettings]);
return null;
}

View File

@@ -1,224 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import {
SidebarCalloutProvider,
type SidebarCalloutsApi,
SidebarCalloutViewport,
useSidebarCallouts,
} from "./sidebar-callout-context";
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
function handleApi(nextApi: SidebarCalloutsApi): void {
apiSink.current = nextApi;
}
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
const api = useSidebarCallouts();
onApi(api);
return null;
}
describe("SidebarCalloutProvider", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let api: SidebarCalloutsApi | null = null;
beforeEach(async () => {
api = null;
apiSink.current = null;
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
api = apiSink.current;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
api = null;
});
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
act(() => {
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
api?.show({ id: "update", priority: 200, title: "Update available" });
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).not.toContain("Set up scripts");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).toContain("Set up scripts");
expect(container?.textContent).not.toContain("Update available");
});
it("replaces a callout by id without duplicating the queue item", () => {
act(() => {
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
});
expect(container?.textContent).toContain("New daemon");
expect(container?.textContent).toContain("v2");
expect(container?.textContent).not.toContain("Old daemon");
});
it("keeps API consumers from rerendering when callout state changes", () => {
const renders = vi.fn();
function Producer() {
const callouts = useSidebarCallouts();
renders(callouts);
useEffect(() => {
callouts.show({ id: "initial", title: "Initial" });
}, [callouts]);
return null;
}
act(() => {
root?.render(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
api = apiSink.current;
const firstApi = renders.mock.calls[0]?.[0];
act(() => {
api?.show({ id: "later", priority: 10, title: "Later" });
});
expect(renders).toHaveBeenCalledTimes(1);
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
});
it("unregisters only the registration returned by show", () => {
let unregisterOld: (() => void) | null = null;
act(() => {
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
api?.show({ id: "update", title: "New" });
});
act(() => {
unregisterOld?.();
});
expect(container?.textContent).toContain("New");
});
it("persists dismissals by dismissal key", () => {
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
});
});
expect(container?.textContent).toContain("Update available");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).not.toContain("Update available");
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
});
});
expect(container?.textContent).not.toContain("Dismissed update");
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
});
});
expect(container?.textContent).toContain("New update");
});
});

View File

@@ -8,27 +8,24 @@ import {
useRef,
useState,
} from "react";
import {
SidebarCallout,
type SidebarCalloutAction,
type SidebarCalloutProps,
type SidebarCalloutVariant,
} from "@/components/sidebar-callout";
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
export interface SidebarCalloutsApi {
show: (callout: SidebarCalloutOptions) => () => void;
@@ -36,96 +33,48 @@ export interface SidebarCalloutsApi {
clear: () => void;
}
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
(error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
},
);
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
void AsyncStorage.setItem(
DISMISSED_CALLOUTS_STORAGE_KEY,
serializeDismissedCalloutKeys(keys),
).catch((error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
const dismissedKeysRef = useRef<Set<string>>(new Set());
const orderRef = useRef(0);
const tokenRef = useRef(0);
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
const stateRef = useRef<SidebarCalloutState>(state);
function commitState(next: SidebarCalloutState): void {
stateRef.current = next;
setState(next);
}
useEffect(() => {
let mounted = true;
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
.then((value) => {
if (!mounted) {
return;
}
const nextKeys = parseDismissedCalloutKeys(value);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
return;
})
.catch((error) => {
async function loadDismissedKeys(): Promise<void> {
let dismissedKeys: ReadonlySet<string>;
try {
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
dismissedKeys = parseDismissedCalloutKeys(value);
} catch (error) {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
})
.finally(() => {
if (mounted) {
setDismissalStorageLoaded(true);
}
});
dismissedKeys = stateRef.current.dismissedKeys;
}
if (mounted) {
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
}
}
void loadDismissedKeys();
return () => {
mounted = false;
@@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => {
tokenRef.current += 1;
const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
const result = showSidebarCallout(stateRef.current, callout);
commitState(result.state);
return () => {
const updated = calloutsRef.current.filter(
(entry) => entry.id !== callout.id || entry.token !== token,
commitState(
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
);
calloutsRef.current = updated;
setCallouts(updated);
};
});
const dismiss = useStableEvent((id: string) => {
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
const result = dismissSidebarCallout(stateRef.current, id);
commitState(result.state);
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
if (dismissalKey) {
const nextKeys = new Set(dismissedKeysRef.current);
nextKeys.add(dismissalKey);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
persistDismissedCalloutKeys(nextKeys);
if (result.dismissalKey) {
persistDismissedCalloutKeys(result.state.dismissedKeys);
}
dismissed?.onDismiss?.();
result.dismissedCallout?.onDismiss?.();
});
const clear = useStableEvent(() => {
calloutsRef.current = [];
setCallouts([]);
commitState(clearSidebarCallouts(stateRef.current));
});
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return (
<SidebarCalloutApiContext.Provider value={api}>

View File

@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import {
clearSidebarCallouts,
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
describe("sidebar callout state", () => {
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "onboarding",
priority: 10,
title: "Set up scripts",
}).state;
state = showSidebarCallout(state, {
id: "update",
priority: 200,
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
state = dismissSidebarCallout(state, "update").state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
});
it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "daemon",
title: "Old daemon",
description: "v1",
}).state;
state = showSidebarCallout(state, {
id: "daemon",
title: "New daemon",
description: "v2",
}).state;
expect(state.callouts).toMatchObject([
{
id: "daemon",
title: "New daemon",
description: "v2",
},
]);
});
it("unregisters only the registration returned by show", () => {
let state = createSidebarCalloutState();
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
state = oldRegistration.state;
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
});
it("persists dismissals by dismissal key and hides matching future callouts", () => {
const onDismiss = vi.fn();
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
onDismiss,
}).state;
const result = dismissSidebarCallout(state, "update");
state = result.state;
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
JSON.stringify(["desktop-update:available:1.2.3"]),
);
expect(onDismiss).not.toHaveBeenCalled();
result.dismissedCallout?.onDismiss?.();
expect(onDismiss).toHaveBeenCalledOnce();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
});
it("waits for dismissal storage before showing dismissible callouts", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
}).state;
expect(selectActiveSidebarCallout(state)).toBeNull();
state = loadDismissedCalloutKeys(state, new Set());
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
});
it("parses stored dismissal keys defensively", () => {
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
});
it("clears visible callouts without dropping dismissal state", () => {
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
state = clearSidebarCallouts(state);
expect(state.callouts).toEqual([]);
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
});
});

View File

@@ -0,0 +1,162 @@
import type { ReactNode } from "react";
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
order: number;
priority: number;
token: number;
}
export interface SidebarCalloutState {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
nextOrder: number;
nextToken: number;
}
export function createSidebarCalloutState(): SidebarCalloutState {
return {
callouts: [],
dismissedKeys: new Set(),
dismissalStorageLoaded: false,
nextOrder: 0,
nextToken: 0,
};
}
export function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
return JSON.stringify([...keys]);
}
export function loadDismissedCalloutKeys(
state: SidebarCalloutState,
dismissedKeys: ReadonlySet<string>,
): SidebarCalloutState {
return {
...state,
dismissedKeys: new Set(dismissedKeys),
dismissalStorageLoaded: true,
};
}
export function showSidebarCallout(
state: SidebarCalloutState,
callout: SidebarCalloutOptions,
): { state: SidebarCalloutState; token: number } {
const token = state.nextToken + 1;
const existing = state.callouts.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? state.nextOrder + 1,
token,
};
const callouts = existing
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...state.callouts, nextEntry];
return {
state: {
...state,
callouts,
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
nextToken: token,
},
token,
};
}
export function unregisterSidebarCallout(
state: SidebarCalloutState,
input: { id: string; token: number },
): SidebarCalloutState {
const callouts = state.callouts.filter(
(entry) => entry.id !== input.id || entry.token !== input.token,
);
return callouts.length === state.callouts.length ? state : { ...state, callouts };
}
export function dismissSidebarCallout(
state: SidebarCalloutState,
id: string,
): {
state: SidebarCalloutState;
dismissedCallout: SidebarCalloutEntry | null;
dismissalKey: string | null;
} {
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
const callouts = state.callouts.filter((entry) => entry.id !== id);
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
const dismissedKeys = dismissalKey
? new Set([...state.dismissedKeys, dismissalKey])
: state.dismissedKeys;
return {
state: {
...state,
callouts,
dismissedKeys,
},
dismissedCallout,
dismissalKey,
};
}
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
return { ...state, callouts: [] };
}
export function selectActiveSidebarCallout(
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}

View File

@@ -148,9 +148,16 @@ describe("keyboard-shortcuts", () => {
payload: { index: 2 },
},
{
name: "matches tab index jump on desktop via Alt+digit",
name: "matches tab index jump on mac desktop via Cmd+Alt+digit",
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
context: { isMac: true, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
{
name: "matches tab index jump on non-mac desktop via Alt+digit",
event: { key: "2", code: "Digit2", altKey: true },
context: { isDesktop: true },
context: { isMac: false, isDesktop: true },
action: "workspace.tab.navigate.index",
payload: { index: 2 },
},
@@ -333,6 +340,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isMac: true },
},
{
name: "keeps mac Option+digit available for international text input",
event: { key: "@", code: "Digit2", altKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
{
name: "does not match Ctrl+K for command center on non-mac in terminal",
event: { key: "k", code: "KeyK", ctrlKey: true },
@@ -477,16 +489,17 @@ describe("keyboard-shortcut help sections", () => {
"new-agent": ["mod", "shift", "O"],
"workspace-tab-new": ["mod", "T"],
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
},
{
name: "shows Ctrl+W close tab for non-mac desktop",
name: "uses non-mac desktop defaults for tab jump and close tab",
context: { isMac: false, isDesktop: true },
expectedKeys: {
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["ctrl", "W"],
},
},

View File

@@ -292,11 +292,24 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Tab index jump ---
{
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
action: "workspace.tab.navigate.index",
combo: "Cmd+Alt+Digit",
when: { mac: true, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
label: "Jump to tab",
keys: ["mod", "alt", "1-9"],
},
},
{
id: "workspace-tab-navigate-index-alt-digit-desktop",
action: "workspace.tab.navigate.index",
combo: "Alt+Digit",
when: { desktop: true, commandCenter: false },
when: { mac: false, desktop: true, commandCenter: false },
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",

View File

@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const expoCryptoMock = vi.hoisted(() => ({
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
randomUUID: vi.fn(() => {
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
}),
}));
vi.mock("expo-crypto", () => expoCryptoMock);
describe("polyfillCrypto", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
expoCryptoMock.getRandomValues.mockClear();
expoCryptoMock.randomUUID.mockClear();
});
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
const sourceBytes = Uint8Array.from([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff,
]);
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
if (array && ArrayBuffer.isView(array)) {
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
}
return array;
});
vi.stubGlobal("crypto", { getRandomValues });
const { polyfillCrypto } = await import("./crypto");
polyfillCrypto();
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
expect(getRandomValues).toHaveBeenCalledTimes(1);
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
});
});

View File

@@ -13,8 +13,26 @@ interface MutableGlobal {
crypto?: Crypto;
}
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
const bytes = fillRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
}
export function polyfillCrypto(): void {
const g = globalThis as unknown as MutableGlobal;
const nativeGetRandomValues =
typeof g.crypto?.getRandomValues === "function"
? g.crypto.getRandomValues.bind(g.crypto)
: null;
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
// Hermes may not provide them in all configurations.
@@ -47,17 +65,21 @@ export function polyfillCrypto(): void {
g.crypto = {} as Crypto;
}
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
if (nativeGetRandomValues) {
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
}
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
if (typeof g.crypto.randomUUID !== "function") {
g.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
}
if (typeof g.crypto.getRandomValues !== "function") {
g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array === null) return array;
return ExpoCrypto.getRandomValues(
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
) as unknown as T;
};
g.crypto.getRandomValues = fillRandomValues;
}
}

View File

@@ -203,6 +203,15 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
};
}
function encodeOfferUrl(payload: unknown): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
return `https://app.paseo.sh/#offer=${encoded}`;
}
function makeDeps(
latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[],
@@ -1734,6 +1743,41 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
const oldPairingUrl = encodeOfferUrl({
v: 2,
serverId: "srv_offer",
daemonPublicKeyB64: "pk_test_offer",
relay: { endpoint: "relay.paseo.sh:443" },
});
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
{
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_test_offer",
},
]);
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {

View File

@@ -1506,10 +1506,12 @@ export class HostRuntimeStore {
}
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
upsertCreatedTerminalPayload,
type ListTerminalsPayload,
} from "@/screens/workspace/terminals/state";
import type { CreateTerminalResponse } from "@server/shared/messages";
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
return { id, name: id, title: id };
}
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
return { id, name: id, cwd: "/repo", title: id };
}
describe("workspace terminal state", () => {
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
const pending = new Map([
["older-than-list", 10],
["now-live", 20],
["still-pending", 30],
]);
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
});
it("returns the same pending map when reconciliation changes nothing", () => {
const pending = new Map([["still-pending", 30]]);
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
expect(reconciled).toBe(pending);
});
it("combines live and pending terminal ids without duplicating script terminals", () => {
const pendingScriptTerminalIds = new Map([
["script-pending", 10],
["terminal-1", 10],
]);
expect(
collectKnownTerminalIds({
liveTerminalIds: ["terminal-1", "terminal-2"],
pendingScriptTerminalIds,
}),
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
expect(
collectScriptTerminalIds({
pendingScriptTerminalIds,
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
}),
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
expect(
collectStandaloneTerminalIds({
terminals: [
listedTerminal("terminal-1"),
listedTerminal("terminal-2"),
listedTerminal("script-live"),
],
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
}),
).toEqual(["terminal-2"]);
});
it("updates terminal cache entries for created and closed terminals", () => {
const current: ListTerminalsPayload = {
cwd: "/repo",
requestId: "existing",
terminals: [listedTerminal("terminal-1")],
};
expect(
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal("terminal-2"),
workspaceDirectory: "/repo",
}),
).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [
listedTerminal("terminal-1"),
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
],
});
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
cwd: "/repo",
requestId: "existing",
terminals: [],
});
});
});

View File

@@ -0,0 +1,106 @@
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
export const TERMINALS_QUERY_STALE_TIME = 5_000;
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
type TerminalEntry = ListTerminalsPayload["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
return ["terminals", serverId, workspaceDirectory] as const;
}
export function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
export function collectKnownTerminalIds(input: {
liveTerminalIds: string[];
pendingScriptTerminalIds: Map<string, number>;
}): string[] {
const terminalIds = new Set(input.liveTerminalIds);
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}
export function collectScriptTerminalIds(input: {
pendingScriptTerminalIds: Map<string, number>;
scripts: Array<{ terminalId?: string | null }>;
}): Set<string> {
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
for (const script of input.scripts) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}
export function collectStandaloneTerminalIds(input: {
terminals: TerminalEntry[];
scriptTerminalIds: Set<string>;
}): string[] {
return input.terminals
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id);
}
export function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
export function upsertCreatedTerminalPayload(input: {
current: ListTerminalsPayload | undefined;
terminal: CreatedTerminal;
workspaceDirectory: string | null;
}): ListTerminalsPayload {
const nextTerminals = upsertTerminalListEntry({
terminals: input.current?.terminals ?? [],
terminal: input.terminal,
});
const cwd = input.current?.cwd ?? input.workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
};
}

View File

@@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildTerminalsQueryKey,
canCreateWorkspaceTerminal,
collectKnownTerminalIds,
collectScriptTerminalIds,
collectStandaloneTerminalIds,
reconcilePendingScriptTerminals,
removeTerminalFromPayload,
TERMINALS_QUERY_STALE_TIME,
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state";
interface PendingTerminalCreateInput {
paneId?: string;
}
interface UseWorkspaceTerminalsInput {
client: DaemonClient | null;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean;
isMissingWorkspaceExecutionAuthority: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void;
onTerminalCreateQueued: () => void;
}
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const {
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated,
onScriptTerminalSelected,
onWorkspacePathUnavailable,
onTerminalCreateQueued,
} = input;
const queryClient = useQueryClient();
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
null,
);
const canCreateNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const queryKey = useMemo(
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
[normalizedServerId, workspaceDirectory],
);
const query = useQuery({
queryKey,
enabled: canCreateNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
});
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const dataUpdatedAt = query.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
}, [liveTerminalIds, dataUpdatedAt]);
const knownTerminalIds = useMemo(
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
[liveTerminalIds, pendingScriptTerminalIds],
);
const scriptTerminalIds = useMemo(
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
[pendingScriptTerminalIds, workspaceScripts],
);
const standaloneTerminalIds = useMemo(
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
[scriptTerminalIds, terminals],
);
const createMutation = useMutation({
mutationFn: async (_input?: PendingTerminalCreateInput) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, createInput) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
upsertCreatedTerminalPayload({
current,
terminal: createdTerminal,
workspaceDirectory,
}),
);
}
void queryClient.invalidateQueries({ queryKey });
if (createdTerminal) {
onTerminalCreated({
terminalId: createdTerminal.id,
paneId: createInput?.paneId,
});
}
},
});
const killMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
});
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
useEffect(() => {
if (!pendingCreateInput) {
return;
}
if (canCreateNow && !createMutation.isPending) {
const pendingInput = pendingCreateInput;
setPendingCreateInput(null);
createMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingCreateInput(null);
onWorkspacePathUnavailable();
}
}, [
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onWorkspacePathUnavailable,
pendingCreateInput,
]);
const createTerminal = useCallback(
(createInput?: PendingTerminalCreateInput) => {
if (createMutation.isPending || pendingCreateInput) {
return;
}
if (canCreateNow) {
createMutation.mutate(createInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
onWorkspacePathUnavailable();
return;
}
setPendingCreateInput(createInput ?? {});
onTerminalCreateQueued();
},
[
canCreateNow,
createMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreateQueued,
onWorkspacePathUnavailable,
pendingCreateInput,
],
);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
return nextTerminalIds;
});
onScriptTerminalSelected(terminalId);
void queryClient.invalidateQueries({ queryKey });
},
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
onScriptTerminalSelected(terminalId);
},
[onScriptTerminalSelected],
);
const removeTerminalFromCache = useCallback(
(terminalId: string) => {
queryClient.setQueryData<ListTerminalsPayload>(
queryKey,
removeTerminalFromPayload(terminalId),
);
},
[queryClient, queryKey],
);
const invalidateTerminals = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
canCreateNow,
createMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput,
query,
queryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
};
}

View File

@@ -11,7 +11,7 @@ import {
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
@@ -94,8 +94,6 @@ import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -158,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
@@ -1028,8 +1028,6 @@ function WorkspaceHeaderTitleBar({
);
}
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type PaneDirection = "left" | "right" | "up" | "down";
function parsePaneDirection(actionId: string): PaneDirection | null {
@@ -1180,39 +1178,6 @@ function resolveWorkspaceAuthorityState(
};
}
function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
};
}
function removeTerminalFromPayload(terminalId: string) {
return function updatePayload(
current: ListTerminalsPayload | undefined,
): ListTerminalsPayload | undefined {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
};
}
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback;
@@ -1359,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(input: {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
@@ -1377,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
return `${serverId}:${workspaceId}`;
}
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
toast: {
error: (message: string) => void;
show: (message: string) => void;
};
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
}
function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
if (!persistenceKey) {
return;
}
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error("Workspace path is not available yet");
}, [toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show("Preparing workspace, opening terminal when ready...");
}, [toast]);
return {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
};
}
function useWorkspaceCheckoutStatus(input: {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error("Host is not connected");
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
return { checkoutQuery, isCheckoutStatusLoading };
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -1405,7 +1461,6 @@ function WorkspaceScreenContent({
scopeKey: workspaceTerminalScopeKey,
});
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceAuthority = useMemo(
@@ -1430,12 +1485,19 @@ function WorkspaceScreenContent({
useProvidersSnapshot(normalizedServerId, {
enabled: isRouteFocused,
});
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
paneId?: string;
} | null>(null);
const canCreateTerminalNow = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const workspaceAgentVisibility = useStoreWithEqualityFn(
@@ -1449,167 +1511,56 @@ function WorkspaceScreenContent({
workspaceAgentVisibilityEqual,
);
const terminalsQueryKey = useMemo(
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
[normalizedServerId, workspaceDirectory],
);
const terminalsQuery = useQuery({
queryKey: terminalsQueryKey,
enabled: canCreateTerminalNow,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.listTerminals(workspaceDirectory);
},
staleTime: TERMINALS_QUERY_STALE_TIME,
const {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
} = useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
toast,
});
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
useEffect(() => {
setPendingScriptTerminalIds(
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
);
}, [liveTerminalIds, terminalsDataUpdatedAt]);
const knownTerminalIds = useMemo(() => {
const terminalIds = new Set(liveTerminalIds);
for (const terminalId of pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}, [liveTerminalIds, pendingScriptTerminalIds]);
const scriptTerminalIds = useMemo(() => {
const terminalIds = new Set(pendingScriptTerminalIds.keys());
for (const script of workspaceDescriptor?.scripts ?? []) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({
mutationFn: async (_input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.createTerminal(workspaceDirectory);
},
onSuccess: (payload, input) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
if (createdTerminal) {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
if (!workspaceKey) {
return;
}
if (input?.paneId) {
focusWorkspacePane(workspaceKey, input.paneId);
}
useWorkspaceLayoutStore
.getState()
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
}
},
});
const killTerminalMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
const {
createMutation: createTerminalMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation: killTerminalMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput: pendingTerminalCreateInput,
query: terminalsQuery,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
} = useWorkspaceTerminals({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
onTerminalCreated: handleTerminalCreated,
onScriptTerminalSelected: handleScriptTerminalSelected,
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
onTerminalCreateQueued: handleTerminalCreateQueued,
});
const { archiveAgent } = useArchiveAgent();
useEffect(() => {
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
return;
}
const unsubscribeChanged = client.on("terminals_changed", (message) => {
if (message.payload.cwd !== workspaceDirectory) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd: workspaceDirectory });
return () => {
unsubscribeChanged();
client.unsubscribeTerminals({ cwd: workspaceDirectory });
};
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
const isCheckoutQueryEnabled = useMemo(
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.getCheckoutStatus(workspaceDirectory);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
);
@@ -1618,30 +1569,6 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
const pendingInput = pendingTerminalCreateInput;
setPendingTerminalCreateInput(null);
createTerminalMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingTerminalCreateInput(null);
toast.error("Workspace path is not available yet");
}
}, [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
]);
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading,
isError: checkoutQuery.isError,
@@ -1738,15 +1665,6 @@ function WorkspaceScreenContent({
return () => handler.remove();
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
);
@@ -1761,7 +1679,6 @@ function WorkspaceScreenContent({
[workspaceLayout],
);
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground,
);
@@ -1777,39 +1694,6 @@ function WorkspaceScreenContent({
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -2200,24 +2084,7 @@ function WorkspaceScreenContent({
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
);
const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow) {
createTerminalMutation.mutate(input);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
toast.error("Workspace path is not available yet");
return;
}
setPendingTerminalCreateInput(input ?? {});
toast.show("Preparing workspace, opening terminal when ready...");
});
const handleCreateTerminal = useStableEvent(createTerminal);
const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => {
@@ -2284,10 +2151,7 @@ function WorkspaceScreenContent({
return;
}
queryClient.setQueryData<ListTerminalsPayload>(
terminalsQueryKey,
removeTerminalFromPayload(terminalId),
);
removeTerminalFromCache(terminalId);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
@@ -2297,18 +2161,16 @@ function WorkspaceScreenContent({
});
}
void killTerminalAsync(terminalId).catch(() => {
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
});
void killTerminalAsync(terminalId).catch(invalidateTerminals);
});
},
[
closeTab,
closeWorkspaceTabWithCleanup,
invalidateTerminals,
killTerminalAsync,
persistenceKey,
queryClient,
terminalsQueryKey,
removeTerminalFromCache,
],
);

View File

@@ -1,5 +1,7 @@
import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
@@ -86,7 +88,7 @@ interface InsertSplitInternalInput {
targetPaneId: string;
tabId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
}
interface InsertSplitInternalResult {
@@ -142,7 +144,7 @@ interface SplitPaneInLayoutInput {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
maxTreeDepth: number;
}
@@ -155,7 +157,7 @@ interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
maxTreeDepth: number;
}
@@ -232,14 +234,6 @@ function normalizeTabIds(list: unknown): string[] {
return next;
}
function generateNodeId(prefix: "pane" | "group"): string {
const randomValue =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}_${randomValue}`;
}
function createPaneNode(input: {
id: string;
tabs?: WorkspaceTab[];
@@ -996,13 +990,16 @@ export function insertSplit(
targetPaneId: string,
tabId: string,
position: "left" | "right" | "top" | "bottom",
createNodeId: (
prefix: WorkspaceLayoutNodeIdPrefix,
) => string = defaultWorkspaceLayoutIds.createNodeId,
): SplitNode {
return insertSplitInternal({
root: asInternalNode(root),
targetPaneId,
tabId,
position,
createNodeId: generateNodeId,
createNodeId,
}).root;
}

View File

@@ -0,0 +1,17 @@
export type WorkspaceLayoutNodeIdPrefix = "pane" | "group";
export interface WorkspaceLayoutIdSource {
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
createFocusRestorationToken: () => string;
}
function createRandomIdValue(): string {
return typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = {
createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`,
createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`,
};

View File

@@ -20,6 +20,7 @@ import {
buildWorkspaceTabPersistenceKey,
collectAllPanes,
collectAllTabs,
createWorkspaceLayoutStore,
createDefaultLayout,
findPaneById,
findPaneContainingTab,
@@ -28,7 +29,6 @@ import {
insertSplit,
removePaneFromTree,
removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
} from "@/stores/workspace-layout-store";
@@ -36,6 +36,40 @@ import {
const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main";
function createDeterministicWorkspaceLayoutIds() {
let values: string[] = [];
let fallbackIndex = 0;
function nextValue(): string {
const value = values.shift();
if (value) {
return value;
}
fallbackIndex += 1;
return `generated-${fallbackIndex}`;
}
return {
useValues: (nextValues: string[]) => {
values = nextValues.slice();
fallbackIndex = 0;
},
reset: () => {
values = [];
fallbackIndex = 0;
},
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
};
}
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
function useWorkspaceLayoutIds(...values: string[]) {
workspaceLayoutIds.useValues(values);
}
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
return {
tabId,
@@ -168,13 +202,14 @@ describe("workspace-layout-store helpers", () => {
describe("workspace-layout-store tree transforms", () => {
beforeEach(() => {
vi.restoreAllMocks();
workspaceLayoutIds.reset();
});
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
);
const root: SplitNode = {
kind: "group",
@@ -189,7 +224,7 @@ describe("workspace-layout-store tree transforms", () => {
},
};
const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId);
const nextGroup = expectGroup(nextRoot);
const nestedGroup = expectGroup(nextGroup.group.children[1]);
@@ -270,22 +305,20 @@ describe("workspace-layout-store tree transforms", () => {
describe("workspace-layout-store actions", () => {
beforeEach(() => {
useWorkspaceLayoutStore.setState({
workspaceLayoutIds.reset();
workspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
});
vi.restoreAllMocks();
});
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
);
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -308,7 +341,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/b.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
@@ -322,14 +355,14 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground inserts a tab without stealing focus", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const setupTabId = store.openTabInBackground(workspaceKey, {
kind: "setup",
workspaceId: "ws-main",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layout.root, "main")!;
expect(agentTabId).toBe("agent_agent-1");
@@ -341,7 +374,7 @@ describe("workspace-layout-store actions", () => {
it("openTabInBackground on an existing target is a no-op", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -355,7 +388,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/a.ts",
});
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneById(layoutAfter.root, "main")!;
expect(duplicateTabId).toBe(firstTabId);
@@ -365,27 +398,25 @@ describe("workspace-layout-store actions", () => {
it("unfocuses and restores the previous focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const token = store.unfocusPane(workspaceKey);
expect(token).toBeTruthy();
expect(
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull();
store.restorePaneFocus(workspaceKey, token!);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main",
);
});
it("does not restore stale focus after another pane is focused", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.splitPane(workspaceKey, {
@@ -399,14 +430,14 @@ describe("workspace-layout-store actions", () => {
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
store.restorePaneFocus(workspaceKey, token!);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
});
it("waits for nested focus restorations before restoring", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const outerToken = store.unfocusPane(workspaceKey);
@@ -414,22 +445,22 @@ describe("workspace-layout-store actions", () => {
store.restorePaneFocus(workspaceKey, outerToken!);
expect(
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
).toBeNull();
store.restorePaneFocus(workspaceKey, innerToken!);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
"main",
);
});
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("draft-2");
@@ -450,11 +481,9 @@ describe("workspace-layout-store actions", () => {
});
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777",
);
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const newPaneId = store.splitPaneEmpty(workspaceKey, {
@@ -465,7 +494,7 @@ describe("workspace-layout-store actions", () => {
kind: "draft",
draftId: "draft-split",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
expect(draftTabId).toBe("draft-split");
@@ -476,11 +505,9 @@ describe("workspace-layout-store actions", () => {
});
it("focusTab moves workspace focus to the pane containing the tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
);
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const fileTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -497,22 +524,20 @@ describe("workspace-layout-store actions", () => {
});
store.focusTab(workspaceKey, fileTabId!);
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
});
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212",
);
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
@@ -523,7 +548,7 @@ describe("workspace-layout-store actions", () => {
});
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const splitPane = findPaneById(layout.root, splitPaneId);
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
@@ -540,7 +565,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab keeps a draft tab in place while updating its target", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -550,7 +575,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/retargeted.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(draftTabId).toBe("draft-retarget");
expect(nextTabId).toBe(draftTabId);
@@ -565,11 +590,9 @@ describe("workspace-layout-store actions", () => {
});
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"55555555-5555-5555-5555-555555555555",
);
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const existingFileTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -590,7 +613,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/existing.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
expect(draftTabId).toBe("draft-dup");
@@ -606,7 +629,7 @@ describe("workspace-layout-store actions", () => {
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstDraftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -625,7 +648,7 @@ describe("workspace-layout-store actions", () => {
kind: "agent",
agentId: "agent-1",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).toBe(firstDraftTabId);
@@ -641,7 +664,7 @@ describe("workspace-layout-store actions", () => {
it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const firstTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -657,7 +680,7 @@ describe("workspace-layout-store actions", () => {
});
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")).toEqual({
id: "main",
@@ -684,11 +707,9 @@ describe("workspace-layout-store actions", () => {
});
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"34343434-3434-3434-3434-343434343434",
);
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.openTabFocused(workspaceKey, {
@@ -712,7 +733,7 @@ describe("workspace-layout-store actions", () => {
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main");
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
expect(layout.focusedPaneId).toBe("main");
@@ -736,11 +757,9 @@ describe("workspace-layout-store actions", () => {
});
it("focusPane switches workspace focus to a different pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"56565656-5656-5656-5656-565656565656",
);
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -754,22 +773,20 @@ describe("workspace-layout-store actions", () => {
});
store.focusPane(workspaceKey, "main");
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId);
});
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc",
);
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -783,7 +800,7 @@ describe("workspace-layout-store actions", () => {
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
expect(layout.focusedPaneId).toBe("main");
@@ -791,18 +808,19 @@ describe("workspace-layout-store actions", () => {
});
it("splitPane enforces the maximum depth of four", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
useWorkspaceLayoutIds(
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444",
"55555555-5555-5555-5555-555555555555",
"66666666-6666-6666-6666-666666666666",
"77777777-7777-7777-7777-777777777777",
"88888888-8888-8888-8888-888888888888",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
@@ -831,7 +849,7 @@ describe("workspace-layout-store actions", () => {
position: "bottom",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
@@ -840,11 +858,9 @@ describe("workspace-layout-store actions", () => {
});
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"dddddddd-dddd-dddd-dddd-dddddddddddd",
);
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const leftTabId = store.openTabFocused(workspaceKey, {
kind: "file",
@@ -861,7 +877,7 @@ describe("workspace-layout-store actions", () => {
});
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
@@ -872,13 +888,14 @@ describe("workspace-layout-store actions", () => {
});
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
useWorkspaceLayoutIds(
"78787878-7878-7878-7878-787878787878",
"89898989-8989-8989-8989-898989898989",
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -901,7 +918,7 @@ describe("workspace-layout-store actions", () => {
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
@@ -937,11 +954,9 @@ describe("workspace-layout-store actions", () => {
});
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab",
);
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTabFocused(workspaceKey, {
@@ -959,7 +974,7 @@ describe("workspace-layout-store actions", () => {
kind: "file",
path: "/repo/worktree/b.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId);
@@ -971,13 +986,14 @@ describe("workspace-layout-store actions", () => {
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
useWorkspaceLayoutIds(
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
"ffffffff-ffff-ffff-ffff-ffffffffffff",
"11111111-1111-1111-1111-111111111111",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
@@ -995,12 +1011,12 @@ describe("workspace-layout-store actions", () => {
position: "right",
});
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const splitGroup = expectGroup(splitRoot);
const nestedGroup = expectGroup(splitGroup.group.children[1]);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
const resizedGroup = expectGroup(resizedRoot);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
@@ -1014,11 +1030,11 @@ describe("workspace-layout-store actions", () => {
it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createDefaultLayout());
});
@@ -1032,12 +1048,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(workspaceKey, "agent-1");
store.pinAgent(otherWorkspaceKey as string, "agent-2");
let state = useWorkspaceLayoutStore.getState();
let state = workspaceLayoutStore.getState();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
@@ -1045,13 +1061,13 @@ describe("workspace-layout-store actions", () => {
store.unpinAgent(workspaceKey, "agent-1");
state = useWorkspaceLayoutStore.getState();
state = workspaceLayoutStore.getState();
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
]);
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
@@ -1068,12 +1084,12 @@ describe("workspace-layout-store actions", () => {
expect(otherWorkspaceKey).toBeTruthy();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(workspaceKey, "agent-1");
store.hideAgent(otherWorkspaceKey as string, "agent-2");
let state = useWorkspaceLayoutStore.getState();
let state = workspaceLayoutStore.getState();
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
@@ -1081,13 +1097,13 @@ describe("workspace-layout-store actions", () => {
store.unhideAgent(workspaceKey, "agent-1");
state = useWorkspaceLayoutStore.getState();
state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
"agent-2",
]);
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
@@ -1096,11 +1112,9 @@ describe("workspace-layout-store actions", () => {
});
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"67676767-6767-6767-6767-676767676767",
);
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const draftTabId = store.openTabFocused(workspaceKey, {
kind: "draft",
@@ -1114,7 +1128,7 @@ describe("workspace-layout-store actions", () => {
});
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
expect(nextTabId).toBe("agent_agent-1");
@@ -1126,7 +1140,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.setState((state) => ({
workspaceLayoutStore.setState((state) => ({
...state,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -1169,7 +1183,7 @@ describe("workspace-layout-store actions", () => {
},
}));
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
@@ -1179,7 +1193,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = collectAllTabs(layout.root);
expect(tabs.map((tab) => tab.tabId)).toEqual([
@@ -1200,14 +1214,14 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.setState((state) => ({
workspaceLayoutStore.setState((state) => ({
...state,
hiddenAgentIdsByWorkspace: {
[workspaceKey]: new Set<string>(["agent-1"]),
},
}));
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
@@ -1217,13 +1231,13 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
@@ -1234,7 +1248,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
useWorkspaceLayoutStore
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1243,7 +1257,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1258,7 +1272,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
useWorkspaceLayoutStore
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1267,7 +1281,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
@@ -1282,7 +1296,7 @@ describe("workspace-layout-store actions", () => {
});
expect(
useWorkspaceLayoutStore
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
@@ -1291,7 +1305,7 @@ describe("workspace-layout-store actions", () => {
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "child-agent");
store.reconcileTabs(workspaceKey, {
@@ -1304,11 +1318,11 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_child-agent",
@@ -1317,7 +1331,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
const scriptTabId = store.openTabFocused(workspaceKey, {
kind: "terminal",
@@ -1335,8 +1349,8 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
});
@@ -1344,7 +1358,7 @@ describe("workspace-layout-store actions", () => {
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
@@ -1355,46 +1369,44 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("explicitly opening an agent tab clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
});
it("pinning an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
expect(
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
).toBeDefined();
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
store.pinAgent(workspaceKey, "agent-1");
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
});
it("retargeting a tab to an agent clears hidden intent", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const store = workspaceLayoutStore.getState();
store.hideAgent(workspaceKey, "agent-1");
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
const state = useWorkspaceLayoutStore.getState();
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,142 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
class FakeDaemonClient implements DaemonProbeClient {
readonly lastError: string | null;
class MockDaemonClient {
public lastError: string | null = nextLastError;
private lastServerInfo = {
status: "server_info" as const,
serverId: "srv_probe_test",
hostname: "probe-host" as string | null,
version: "0.0.0",
};
constructor(
private readonly probe: FakeDaemonProbe,
readonly config: DaemonClientConfig,
) {
this.lastError = probe.nextLastError;
}
constructor(config: { clientId?: string; url?: string; password?: string }) {
createdConfigs.push(config);
}
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(): () => void {
return () => undefined;
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
getLastServerInfoMessage() {
return this.lastServerInfo;
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
async connect(): Promise<void> {
if (this.probe.nextConnectError) {
throw this.probe.nextConnectError;
}
}
return {
MockDaemonClient,
createdConfigs,
setNextConnectFailure: (error: Error, lastError: string | null) => {
nextConnectError = error;
nextLastError = lastError;
getLastServerInfoMessage() {
return {
serverId: "srv_probe_test",
hostname: "probe-host",
};
}
async close(): Promise<void> {
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
nextConnectError: Error | null = null;
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
},
reset: () => {
createdConfigs.length = 0;
nextConnectError = null;
nextLastError = null;
resolveAppVersion: () => null,
createLocalTransportFactory: () => null,
buildLocalTransportUrl: ({ transportType, transportPath }) =>
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
createClient: (config) => {
const client = new FakeDaemonClient(this, config);
this.createdClients.push(client);
return client;
},
};
});
const clientIdMock = vi.hoisted(() => ({
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
}));
failNextConnection(error: Error, lastError: string | null): void {
this.nextConnectError = error;
this.nextLastError = lastError;
}
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
buildLocalDaemonTransportUrl: vi.fn(
({
transportType,
transportPath,
}: {
transportType: "socket" | "pipe";
transportPath: string;
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
),
}));
createdConfigs(): DaemonClientConfig[] {
return this.createdClients.map((client) => client.config);
}
}
describe("test-daemon-connection connectToDaemon", () => {
let probe: FakeDaemonProbe;
beforeEach(() => {
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false);
probe = new FakeDaemonProbe();
});
it("reuses the app clientId for direct connections", async () => {
const mod = await import("./test-daemon-connection");
const first = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
const { connectToDaemon } = await import("./test-daemon-connection");
const first = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
probe.deps,
);
await first.client.close();
const second = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
});
const second = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
probe.deps,
);
await second.client.close();
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
const [firstConfig, secondConfig] = probe.createdConfigs();
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
expect(probe.clientIdsRequested).toBe(2);
});
it("encodes the local socket target into the client config", async () => {
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
});
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "socket:/tmp/paseo.sock",
type: "directSocket",
path: "/tmp/paseo.sock",
},
undefined,
probe.deps,
);
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
});
it("passes direct TCP connection passwords into the client config", async () => {
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
});
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
probe.deps,
);
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
});
it("uses relay TLS from the stored connection", async () => {
const mod = await import("./test-daemon-connection");
const tlsResult = await mod.connectToDaemon(
const { connectToDaemon } = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
{
id: "relay:wss:[::1]:443",
type: "relay",
@@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey",
},
{ serverId: "srv_probe_test" },
probe.deps,
);
await tlsResult.client.close();
const plainResult = await mod.connectToDaemon(
const plainResult = await connectToDaemon(
{
id: "relay:relay.paseo.sh:443",
type: "relay",
@@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => {
daemonPublicKeyB64: "pubkey",
},
{ serverId: "srv_probe_test" },
probe.deps,
);
await plainResult.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
});
it("surfaces auth rejection as an incorrect password", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(
new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)",
);
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
}),
connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
},
undefined,
probe.deps,
),
).rejects.toMatchObject({
message: "Incorrect password",
});
});
it("keeps generic transport failures generic when a password was supplied", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
const { connectToDaemon } = await import("./test-daemon-connection");
probe.failNextConnection(new Error("Transport error"), "Transport error");
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
}),
connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
},
undefined,
probe.deps,
),
).rejects.toMatchObject({
message: "Transport error",
});

View File

@@ -13,6 +13,34 @@ import {
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient {
readonly lastError: string | null;
connect(): Promise<void>;
close(): Promise<void>;
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
}
interface LocalTransportUrlInput {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
getClientId: getOrCreateClientId,
resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const clientId = await deps.getClientId();
const localTransportFactory = deps.createLocalTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
appVersion: deps.resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...((connection.type === "directSocket" || connection.type === "directPipe") &&
@@ -87,7 +119,7 @@ export async function buildClientConfig(
if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: buildLocalDaemonTransportUrl({
url: deps.buildLocalTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
@@ -120,10 +152,23 @@ export async function buildClientConfig(
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const client = new DaemonClient(config);
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectAndProbe<TClient extends DaemonProbeClient>(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export function connectAndProbe(
config: DaemonClientConfig,
timeoutMs: number,
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
"createClient"
> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const client = deps.createClient(config);
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
(resolve, reject) => {
const timer = setTimeout(() => {
void client.close().catch(() => undefined);
@@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
return connection.type === "relay" ? 10_000 : 6_000;
}
export function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
export function connectToDaemon<TClient extends DaemonProbeClient>(
connection: HostConnection,
options: ProbeOptions | undefined,
deps: DaemonConnectionDependencies<TClient>,
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
export async function connectToDaemon(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId);
return connectAndProbe(config, resolveTimeout(connection, options));
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId, deps);
return connectAndProbe(config, resolveTimeout(connection, options), deps);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.71",
"version": "0.1.73",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.71",
"@getpaseo/server": "0.1.73",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,32 +1,67 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
import {
type DaemonLaunchRuntime,
type DetachedDaemonProcess,
startLocalDaemonDetached,
startLocalDaemonForeground,
} from "./local-daemon.js";
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
type RecordedDaemonLaunch =
| {
mode: "detached";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
}
| {
mode: "foreground";
command: string;
args: string[];
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
};
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
pid = 4242;
unref = vi.fn();
wasUnreferenced = false;
unref(): void {
this.wasUnreferenced = true;
}
}
class FakeDaemonRuntime implements DaemonLaunchRuntime {
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
readonly daemonProcess = new FakeDaemonProcess();
foregroundStatus = 0;
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
resolveRunnerEntry(): string {
return this.runnerEntry;
}
resolveHome(env: NodeJS.ProcessEnv): string {
return env.PASEO_HOME ?? "/tmp/paseo";
}
spawnDetached(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
): DetachedDaemonProcess {
this.recordedLaunches.push({ mode: "detached", command, args, options });
return this.daemonProcess;
}
spawnForeground(
command: string,
args: string[],
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
) {
this.recordedLaunches.push({ mode: "foreground", command, args, options });
return { status: this.foregroundStatus, error: undefined };
}
}
function expectSupervisorLaunch(argv: string[]): void {
@@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void {
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const runtime = new FakeDaemonRuntime();
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const runtime = new FakeDaemonRuntime();
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
const resultPromise = startLocalDaemonDetached(
{ home: "/tmp/paseo-test", mcp: false },
runtime,
);
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("detached");
expect(launch?.command).toBe(process.execPath);
expectSupervisorLaunch(launch?.args ?? []);
expect(launch?.args).toContain("--no-mcp");
});
test("relay TLS flag is passed to the supervised daemon", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const runtime = new FakeDaemonRuntime();
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({
home: "/tmp/paseo-test",
relayUseTls: true,
});
const status = startLocalDaemonForeground(
{
home: "/tmp/paseo-test",
relayUseTls: true,
},
runtime,
);
expect(status).toBe(0);
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
string,
string[],
{ env?: NodeJS.ProcessEnv },
];
expect(argv).toContain("--relay-use-tls");
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
const launch = runtime.recordedLaunches[0];
expect(launch?.mode).toBe("foreground");
expect(launch?.args).toContain("--relay-use-tls");
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
});
});

View File

@@ -1,4 +1,4 @@
import { spawnSync } from "node:child_process";
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
@@ -68,6 +68,28 @@ interface ProcessExitDetails {
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
export interface ForegroundDaemonProcessResult {
status: number | null;
error?: Error;
}
export interface DaemonLaunchRuntime {
resolveRunnerEntry(): string;
resolveHome(env: NodeJS.ProcessEnv): string;
spawnDetached(
command: string,
args: string[],
options: Parameters<typeof spawnProcess>[2],
): DetachedDaemonProcess;
spawnForeground(
command: string,
args: string[],
options: Parameters<typeof spawnSync>[2],
): ForegroundDaemonProcessResult;
}
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log";
@@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
spawnDetached: spawnProcess,
spawnForeground: spawnSync,
};
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
@@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
export async function startLocalDaemonDetached(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const paseoHome = resolvePaseoHome(childEnv);
const paseoHome = runtime.resolveHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const child = spawnProcess(
const child = runtime.spawnDetached(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
@@ -461,14 +491,17 @@ export async function startLocalDaemonDetached(
};
}
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
export function startLocalDaemonForeground(
options: DaemonStartOptions,
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
): number {
if (options.listen && options.port) {
throw new Error("Cannot use --listen and --port together");
}
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const daemonRunnerEntry = runtime.resolveRunnerEntry();
const childEnv = buildChildEnv(options);
const result = spawnSync(
const result = runtime.spawnForeground(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{

View File

@@ -1,21 +1,17 @@
import { describe, expect, it } from "vitest";
import {
buildCreateWorktreeInput,
toDaemonCreateInput,
type WorktreeCreateOptions,
} from "./create.js";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
const REPO = "/tmp/repo";
function build(options: WorktreeCreateOptions): unknown {
try {
return buildCreateWorktreeInput(options, REPO);
return buildCreateWorktreeRequest(options, REPO);
} catch (err) {
return err;
}
}
describe("buildCreateWorktreeInput", () => {
describe("buildCreateWorktreeRequest", () => {
it("requires --mode", () => {
expect(build({})).toMatchObject({ code: "MISSING_MODE" });
});
@@ -28,17 +24,20 @@ describe("buildCreateWorktreeInput", () => {
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
});
it("branch-off parses with new branch only", () => {
it("branch-off builds a daemon request with a new branch", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("branch-off parses with base ref", () => {
it("branch-off includes the base ref when provided", () => {
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
@@ -46,10 +45,11 @@ describe("buildCreateWorktreeInput", () => {
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
});
it("checkout-branch parses with branch", () => {
it("checkout-branch builds a checkout request for the branch", () => {
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
action: "checkout",
refName: "feat/x",
});
});
@@ -69,62 +69,8 @@ describe("buildCreateWorktreeInput", () => {
});
});
it("checkout-pr parses positive integers", () => {
it("checkout-pr builds a checkout request for the pull request", () => {
expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
});
});
});
describe("toDaemonCreateInput", () => {
it("maps branch-off without base", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
});
});
it("maps branch-off with base ref", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
}),
).toEqual({
cwd: REPO,
worktreeSlug: "feature-x",
action: "branch-off",
refName: "main",
});
});
it("maps checkout-branch to action=checkout + refName", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-branch", branch: "feat/x" },
}),
).toEqual({
cwd: REPO,
action: "checkout",
refName: "feat/x",
});
});
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
expect(
toDaemonCreateInput({
cwd: REPO,
target: { mode: "checkout-pr", prNumber: 42 },
}),
).toEqual({
cwd: REPO,
action: "checkout",
githubPrNumber: 42,

View File

@@ -0,0 +1,104 @@
import type { DaemonClient } from "@getpaseo/server";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
type CreatePaseoWorktreeRequest = Parameters<DaemonClient["createPaseoWorktree"]>[0];
export function buildCreateWorktreeRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off":
return buildBranchOffRequest(options, cwd);
case "checkout-branch":
return buildCheckoutBranchRequest(options, cwd);
case "checkout-pr":
return buildCheckoutPrRequest(options, cwd);
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
function buildBranchOffRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
worktreeSlug: options.newBranch,
action: "branch-off",
...(options.base ? { refName: options.base } : {}),
};
}
function buildCheckoutBranchRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return {
cwd,
action: "checkout",
refName: options.branch,
};
}
function buildCheckoutPrRequest(
options: WorktreeCreateOptions,
cwd: string,
): CreatePaseoWorktreeRequest {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return {
cwd,
action: "checkout",
githubPrNumber: prNumber,
};
}
function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message };
}

View File

@@ -2,12 +2,8 @@ import path from "node:path";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandError,
CommandOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
export interface WorktreeCreateResult {
name: string;
@@ -24,110 +20,6 @@ export const createSchema: OutputSchema<WorktreeCreateResult> = {
],
};
export interface WorktreeCreateOptions extends CommandOptions {
host?: string;
cwd?: string;
mode?: string;
newBranch?: string;
base?: string;
branch?: string;
prNumber?: string;
}
export type WorktreeCreateTarget =
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
export interface ParsedWorktreeCreateInput {
cwd: string;
target: WorktreeCreateTarget;
}
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
export function buildCreateWorktreeInput(
options: WorktreeCreateOptions,
cwd: string,
): ParsedWorktreeCreateInput {
const mode = options.mode;
if (!mode) {
throw cmdError(
"MISSING_MODE",
"--mode is required",
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
switch (mode) {
case "branch-off": {
if (!options.newBranch) {
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
}
return {
cwd,
target: {
mode: "branch-off",
newBranch: options.newBranch,
...(options.base ? { base: options.base } : {}),
},
};
}
case "checkout-branch": {
if (!options.branch) {
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
}
return { cwd, target: { mode: "checkout-branch", branch: options.branch } };
}
case "checkout-pr": {
if (options.prNumber === undefined || options.prNumber === "") {
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
}
const parsed = Number(options.prNumber);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw cmdError(
"INVALID_PR_NUMBER",
`Invalid --pr-number: ${options.prNumber}`,
"Expected a positive integer",
);
}
return { cwd, target: { mode: "checkout-pr", prNumber: parsed } };
}
default:
throw cmdError(
"INVALID_MODE",
`Invalid --mode: ${mode}`,
`Expected one of: ${VALID_MODES.join(", ")}`,
);
}
}
export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
switch (parsed.target.mode) {
case "branch-off":
return {
cwd: parsed.cwd,
worktreeSlug: parsed.target.newBranch,
action: "branch-off" as const,
...(parsed.target.base ? { refName: parsed.target.base } : {}),
};
case "checkout-branch":
return {
cwd: parsed.cwd,
action: "checkout" as const,
refName: parsed.target.branch,
};
case "checkout-pr":
return {
cwd: parsed.cwd,
action: "checkout" as const,
githubPrNumber: parsed.target.prNumber,
};
default:
throw new Error("unreachable");
}
}
function cmdError(code: string, message: string, details?: string): CommandError {
return details ? { code, message, details } : { code, message };
}
@@ -137,7 +29,7 @@ export async function runCreateCommand(
_command: Command,
): Promise<SingleResult<WorktreeCreateResult>> {
const cwd = options.cwd ?? process.cwd();
const parsed = buildCreateWorktreeInput(options, cwd);
const request = buildCreateWorktreeRequest(options, cwd);
const host = getDaemonHost({ host: options.host });
let client: DaemonClient;
@@ -153,7 +45,7 @@ export async function runCreateCommand(
}
try {
const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
const response = await client.createPaseoWorktree(request);
const workspace = response.workspace;
if (!workspace || response.error) {

View File

@@ -8,6 +8,26 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForLoopInList(
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
id: string,
) {
for (let attempt = 0; attempt < 20; attempt++) {
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
if (listedJson.some((item: { id: string }) => item.id === id)) {
return listedJson;
}
await sleep(250);
}
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
return JSON.parse(listed.stdout);
}
console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
@@ -143,13 +163,10 @@ try {
const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop");
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
const listedJson = await waitForLoopInList(ctx, runJson.id);
assert(
listedJson.some((item: { id: string }) => item.id === runJson.id),
listed.stdout,
JSON.stringify(listedJson),
);
async function pollStatus(attempt: number): Promise<string> {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.71",
"version": "0.1.73",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -0,0 +1,53 @@
import { ipcMain, shell } from "electron";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener";
vi.mock("electron", () => ({
ipcMain: { handle: vi.fn() },
shell: { openExternal: vi.fn() },
}));
function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise<void> {
registerOpenerHandlers();
const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
return channel === "paseo:opener:openUrl";
})?.[1];
if (typeof handler !== "function") {
throw new Error("open URL handler was not registered");
}
return handler as (_event: unknown, url: unknown) => Promise<void>;
}
describe("desktop opener", () => {
beforeEach(() => {
vi.mocked(ipcMain.handle).mockReset();
vi.mocked(shell.openExternal).mockReset();
});
it("allows only http and https external URLs", () => {
expect(isAllowedExternalUrl("https://example.com/path")).toBe(true);
expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true);
expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
expect(isAllowedExternalUrl("paseo://settings")).toBe(false);
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
expect(isAllowedExternalUrl(null)).toBe(false);
});
it("opens allowed URLs through Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await handler({}, "https://example.com");
expect(shell.openExternal).toHaveBeenCalledWith("https://example.com");
});
it("rejects blocked URLs before invoking Electron shell", async () => {
const handler = getRegisteredOpenUrlHandler();
await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,25 @@
import { shell, ipcMain } from "electron";
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
export function isAllowedExternalUrl(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
try {
const url = new URL(value);
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
} catch {
return false;
}
}
export function registerOpenerHandlers(): void {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => {
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => {
if (!isAllowedExternalUrl(url)) {
throw new Error("Unsupported external URL");
}
await shell.openExternal(url);
});
}

View File

@@ -62,6 +62,7 @@ function resolveShellEnv(): Record<string, string> | undefined {
const result = spawnSync(shell, [...shellArgs, command], {
encoding: "utf8",
timeout: RESOLVE_TIMEOUT_MS,
windowsHide: true,
env: {
...shellEnv,
ELECTRON_RUN_AS_NODE: "1",

View File

@@ -191,6 +191,7 @@ if (forcedUserDataDir) {
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim();
devWorktreeName = path.basename(topLevel);
// Main checkout (e.g. "paseo") gets default userData — only worktrees diverge.
@@ -200,6 +201,7 @@ if (forcedUserDataDir) {
cwd: topLevel,
encoding: "utf-8",
timeout: 3000,
windowsHide: true,
}).trim(),
);
const isWorktree = path.resolve(topLevel, ".git") !== commonDir;

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.71",
"version": "0.1.73",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.71",
"version": "0.1.73",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.71",
"version": "0.1.73",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.71",
"version": "0.1.73",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,14 +58,14 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.71",
"@getpaseo/relay": "0.1.71",
"@getpaseo/highlight": "0.1.73",
"@getpaseo/relay": "0.1.73",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@opencode-ai/sdk": "1.14.46",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",

View File

@@ -0,0 +1,70 @@
import { expect, test } from "vitest";
import { buildArchivedAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord } from "./agent-storage.js";
const BASE_RECORD: StoredAgentRecord = {
id: "agent-1",
provider: "codex",
cwd: "/workspace/project",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
labels: {},
lastStatus: "idle",
config: null,
};
test("archives a stored agent without changing terminal statuses", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["idle", "error", "closed"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe(status);
expect(archived.archivedAt).toBe("2025-01-03T00:00:00.000Z");
expect(archived.updatedAt).toBe(BASE_RECORD.updatedAt);
}
});
test("archives busy stored agents as idle", () => {
const statuses: Array<StoredAgentRecord["lastStatus"]> = ["initializing", "running"];
for (const status of statuses) {
const archived = buildArchivedAgentRecord(
{ ...BASE_RECORD, lastStatus: status },
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived.lastStatus).toBe("idle");
}
});
test("clears persisted attention when archiving", () => {
const archived = buildArchivedAgentRecord(
{
...BASE_RECORD,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: "2025-01-02T12:00:00.000Z",
},
{ archivedAt: "2025-01-03T00:00:00.000Z" },
);
expect(archived).toMatchObject({
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
});
test("can stamp updatedAt to the archive timestamp", () => {
const archived = buildArchivedAgentRecord(BASE_RECORD, {
archivedAt: "2025-01-03T00:00:00.000Z",
updatedAt: "2025-01-03T00:00:00.000Z",
});
expect(archived.updatedAt).toBe("2025-01-03T00:00:00.000Z");
});

View File

@@ -0,0 +1,30 @@
import type { StoredAgentRecord } from "./agent-storage.js";
export type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface BuildArchivedAgentRecordOptions {
archivedAt?: string;
updatedAt?: string;
}
export function buildArchivedAgentRecord(
record: StoredAgentRecord,
options?: BuildArchivedAgentRecordOptions,
): ArchivedStoredAgentRecord {
const archivedAt = options?.archivedAt ?? new Date().toISOString();
return {
...record,
archivedAt,
updatedAt: options?.updatedAt ?? record.updatedAt,
lastStatus: normalizeArchivedStatus(record.lastStatus),
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
}
function normalizeArchivedStatus(
status: StoredAgentRecord["lastStatus"],
): StoredAgentRecord["lastStatus"] {
return status === "running" || status === "initializing" ? "idle" : status;
}

View File

@@ -35,6 +35,7 @@ import type {
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import {
InMemoryAgentTimelineStore,
@@ -66,7 +67,6 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
};
type TimeoutResult = "completed" | "timed_out";
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
interface TimeoutOptions {
operation: Promise<void>;
@@ -1071,19 +1071,7 @@ export class AgentManager {
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry();
const archivedAt = new Date().toISOString();
const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const archivedRecord: ArchivedStoredAgentRecord = {
...record,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt });
await registry.upsert(archivedRecord);
@@ -1259,19 +1247,7 @@ export class AgentManager {
throw new Error(`Agent not found: ${agentId}`);
}
const normalizedStatus =
record.lastStatus === "running" || record.lastStatus === "initializing"
? "idle"
: record.lastStatus;
const nextRecord: StoredAgentRecord = {
...record,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
const nextRecord = buildArchivedAgentRecord(record, { archivedAt });
await registry.upsert(nextRecord);
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);

View File

@@ -230,6 +230,7 @@ export function findExecutable(name: string): string | null {
const result = execFileSync(cmd, [trimmed], {
encoding: "utf8",
env: createProviderEnv({ baseEnv: process.env }),
windowsHide: true,
}).trim();
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
const candidate = lines.at(-1)?.trim() ?? null;

View File

@@ -1,7 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex";
@@ -27,39 +31,7 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
},
];
interface CodexRequestFn {
(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
interface CodexClientLike {
request: CodexRequestFn;
}
interface CodexSessionTestAccess {
client: CodexClientLike | null;
connected: boolean;
currentThreadId: string | null;
serviceTier: "fast" | null;
planModeEnabled: boolean;
cachedRuntimeInfo: unknown;
ensureThreadLoaded: () => Promise<void>;
ensureThread: () => Promise<void>;
buildUserInput: (...args: unknown[]) => Promise<unknown>;
resolveSlashCommandInvocation: (...args: unknown[]) => Promise<unknown>;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
}
type CodexFeaturesTestSession = AgentSession & {
connected: boolean;
currentThreadId: string | null;
collaborationModes: CollaborationModeRecord[];
refreshResolvedCollaborationMode(): void;
};
function asInternals(session: CodexFeaturesTestSession): CodexSessionTestAccess {
return session as unknown as CodexSessionTestAccess;
}
type CodexFeaturesTestSession = AgentSession;
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
@@ -71,28 +43,36 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
};
}
function createSession(
configOverrides: Partial<AgentSessionConfig> = {},
): CodexFeaturesTestSession {
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
} {
const config = createConfig(configOverrides);
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER },
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as unknown as CodexFeaturesTestSession;
session.connected = true;
session.currentThreadId = "test-thread";
session.collaborationModes = TEST_COLLABORATION_MODES;
session.refreshResolvedCollaborationMode();
return session;
async () => appServer.child,
) as CodexFeaturesTestSession;
return { session, appServer };
}
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer;
}> {
const harness = createSessionHarness(configOverrides);
await harness.session.connect();
harness.appServer.assertNoErrors();
return harness;
}
describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => {
const session = createSession();
const { session } = await createConnectedSession();
expect(session.features).toEqual([
{
@@ -140,8 +120,8 @@ describe("Codex app-server provider features", () => {
]);
});
test("features returns only plan toggle when model does not support fast mode", () => {
const session = createSession({ model: "gpt-3.5-turbo" });
test("features returns only plan toggle when model does not support fast mode", async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([
{
@@ -157,49 +137,56 @@ describe("Codex app-server provider features", () => {
});
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const session = createSession();
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
expect(asInternals(session).serviceTier).toBe("fast");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
});
});
test("setFeature('fast_mode', false) clears serviceTier to null", async () => {
const session = createSession({
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true },
});
await session.setFeature?.("fast_mode", false);
await session.startTurn("hello");
expect(asInternals(session).serviceTier).toBeNull();
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
test("setFeature invalidates cachedRuntimeInfo", async () => {
const session = createSession();
test("setFeature invalidates runtime info", async () => {
const { session } = await createConnectedSession();
await session.getRuntimeInfo();
expect(asInternals(session).cachedRuntimeInfo).not.toBeNull();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
extra: { collaborationMode: "Plan" },
});
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("plan_mode", true);
expect(asInternals(session).cachedRuntimeInfo).toBeNull();
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
extra: { collaborationMode: "Plan" },
});
});
test("setFeature throws for unknown feature ids", async () => {
const session = createSession();
const { session } = createSessionHarness();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature",
);
});
test("constructor restores feature flags from config.featureValues", () => {
const session = createSession({
test("constructor restores feature flags from config.featureValues", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true, plan_mode: true },
});
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(true);
expect(session.features).toEqual([
{
type: "toggle",
@@ -220,41 +207,29 @@ describe("Codex app-server provider features", () => {
value: true,
},
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
collaborationMode: expect.objectContaining({
mode: "plan",
}),
});
});
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
serviceTier: "fast",
}),
expect.any(Number),
);
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "fast",
});
});
test("setModel clears fast mode when switching to an unsupported model", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo");
@@ -270,41 +245,23 @@ describe("Codex app-server provider features", () => {
value: false,
},
]);
expect(asInternals(session).serviceTier).toBeNull();
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.not.objectContaining({
serviceTier: expect.anything(),
}),
expect.any(Number),
);
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
asInternals(session).client = { request };
asInternals(session).connected = true;
asInternals(session).currentThreadId = "thread-123";
asInternals(session).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
asInternals(session).ensureThread = vi.fn().mockResolvedValue(undefined);
asInternals(session).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
asInternals(session).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("plan_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
expect.any(Number),
);
});
});
});

View File

@@ -18,6 +18,10 @@ import {
CodexAppServerAgentClient,
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
waitForNextPermission,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
@@ -98,122 +102,6 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")");
}
function createChildProcessStub(): ChildProcessWithoutNullStreams {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createScriptedCodexPeer(
child: ChildProcessWithoutNullStreams,
handlers: Record<string, (params: unknown) => unknown>,
) {
const messages: Record<string, unknown>[] = [];
const errors: Error[] = [];
const waiters = new Set<{
predicate: (message: Record<string, unknown>) => boolean;
resolve: (message: Record<string, unknown>) => void;
}>();
let buffer = "";
const processMessage = (message: Record<string, unknown>) => {
messages.push(message);
for (const waiter of Array.from(waiters)) {
if (waiter.predicate(message)) {
waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = handlers[message.method];
if (!handler) {
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
return undefined;
})
.catch((error) => {
child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
};
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
processMessage(parsed as Record<string, unknown>);
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
});
return {
assertNoErrors() {
if (errors.length > 0) {
throw errors[0];
}
},
waitForMessage(
predicate: (message: Record<string, unknown>) => boolean,
label: string,
): Promise<Record<string, unknown>> {
const existing = messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: Record<string, unknown>) => {
clearTimeout(timeout);
resolve(message);
},
};
waiters.add(waiter);
});
},
};
}
describe("Codex app-server provider", () => {
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
@@ -301,8 +189,7 @@ describe("Codex app-server provider", () => {
});
test("round-trips server-initiated command approvals through the real app-server transport", async () => {
const child = createChildProcessStub();
const peer = createScriptedCodexPeer(child, {
const appServer = createFakeCodexAppServer({
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
@@ -311,55 +198,22 @@ describe("Codex app-server provider", () => {
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => child,
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
peer.assertNoErrors();
appServer.assertNoErrors();
const permissionRequested = new Promise<
Extract<AgentStreamEvent, { type: "permission_requested" }>
>((resolve, reject) => {
const existing = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
if (existing) {
resolve(existing);
return;
}
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
const permissionRequested = waitForNextPermission(session);
appServer.requestCommandApproval({
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
});
child.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: 41,
method: "item/commandExecution/requestApproval",
params: {
itemId: "exec-approval-1",
threadId: "thread-1",
turnId: "turn-1",
command: "git restore README.md",
cwd: "/workspace/project",
reason: "requires escalated permissions",
},
})}\n`,
);
const permissionEvent = await permissionRequested;
expect(permissionEvent.request).toMatchObject({
id: "permission-exec-approval-1",
@@ -381,19 +235,10 @@ describe("Codex app-server provider", () => {
await session.respondToPermission(permissionEvent.request.id, { behavior: "allow" });
await expect(
peer.waitForMessage(
(message) =>
message.id === 41 &&
!("method" in message) &&
JSON.stringify(message.result) === JSON.stringify({ decision: "accept" }),
"command approval response",
),
).resolves.toMatchObject({
id: 41,
result: { decision: "accept" },
await expect(appServer.waitForCommandApprovalDecision("exec-approval-1")).resolves.toEqual({
decision: "accept",
});
peer.assertNoErrors();
appServer.assertNoErrors();
await session.close();
});
@@ -1058,6 +903,52 @@ describe("Codex app-server provider", () => {
});
});
test("keeps the parent sub-agent running when a child command fails during the child turn", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-child-command-failure",
tool: "spawnAgent",
status: "completed",
prompt: "Fix the regression test-first.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "running", message: null },
},
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
item: {
type: "commandExecution",
id: "child-failing-command",
status: "failed",
command: "npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts",
aggregatedOutput: "expected false to be true",
exitCode: 1,
error: { message: "Command failed" },
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Fix the regression test-first.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];

View File

@@ -3786,10 +3786,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(
callId,
timelineItem.type === "tool_call" && timelineItem.status === "failed" ? "failed" : "running",
);
this.emitSubAgentActivityUpdate(callId, "running");
}
private shouldSkipCompletedThreadItem(

View File

@@ -1,25 +1,12 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
function createChildProcessStub(): ChildProcessWithoutNullStreams {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = new PassThrough() as ChildProcessWithoutNullStreams["stdin"];
child.stdout = new PassThrough() as ChildProcessWithoutNullStreams["stdout"];
child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"];
child.exitCode = null;
child.signalCode = null;
child.kill = (() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
}
describe("Codex app-server transport", () => {
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createChildProcessStub();
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("model/list", {});
@@ -38,7 +25,7 @@ describe("Codex app-server transport", () => {
"item/tool/requestUserInput",
"tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => {
const child = createChildProcessStub();
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => {

View File

@@ -0,0 +1,228 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
};
export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
requestCommandApproval(params: {
itemId: string;
threadId: string;
turnId: string;
command: string;
cwd: string;
reason: string;
}): void;
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
}
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
export function createFakeCodexAppServer(
handlers: Record<string, FakeCodexAppServerHandler> = {},
): FakeCodexAppServer {
const child = createCodexAppServerChildProcess();
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"config/read": () => ({ config: {} }),
getUserSavedConfig: () => ({ config: {} }),
"model/list": () => ({
data: [
{
id: "gpt-5.4",
isDefault: true,
defaultReasoningEffort: "medium",
},
],
}),
"skills/list": () => ({ data: [] }),
"thread/start": () => ({ thread: { id: "thread-1" } }),
"thread/loaded/list": () => ({ data: [] }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
...handlers,
};
const messages: JsonObject[] = [];
const errors: Error[] = [];
const approvalRequestIds = new Map<string, number>();
const waiters = new Set<{
predicate: (message: JsonObject) => boolean;
resolve: (message: JsonObject) => void;
}>();
let buffer = "";
let nextServerRequestId = 1;
function processMessage(message: JsonObject): void {
messages.push(message);
for (const waiter of Array.from(waiters)) {
if (waiter.predicate(message)) {
waiters.delete(waiter);
waiter.resolve(message);
}
}
if (typeof message.id !== "number" || typeof message.method !== "string") {
return;
}
const handler = responseHandlers[message.method];
if (!handler) {
errors.push(new Error(`Unexpected Codex app-server request: ${message.method}`));
return;
}
Promise.resolve(handler(message.params))
.then((result) => {
child.stdout.write(`${JSON.stringify({ id: message.id, result })}\n`);
return undefined;
})
.catch((error) => {
child.stdout.write(
`${JSON.stringify({
id: message.id,
error: { message: error instanceof Error ? error.message : String(error) },
})}\n`,
);
return undefined;
});
}
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
processMessage(parsed as JsonObject);
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
});
function waitForMessage(
predicate: (message: JsonObject) => boolean,
label: string,
): Promise<JsonObject> {
const existing = messages.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
waiters.delete(waiter);
reject(new Error(`Timed out waiting for ${label}`));
}, 1000);
const waiter = {
predicate,
resolve: (message: JsonObject) => {
clearTimeout(timeout);
resolve(message);
},
};
waiters.add(waiter);
});
}
return {
child,
assertNoErrors() {
if (errors.length > 0) {
throw errors[0];
}
},
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",
"turn start request",
);
return toJsonObject(message.params);
},
requestCommandApproval(params) {
const requestId = nextServerRequestId;
nextServerRequestId += 1;
approvalRequestIds.set(params.itemId, requestId);
child.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: requestId,
method: "item/commandExecution/requestApproval",
params,
})}\n`,
);
},
async waitForCommandApprovalDecision(itemId) {
const requestId = approvalRequestIds.get(itemId);
if (requestId === undefined) {
throw new Error(`No pending fake Codex app-server approval for ${itemId}`);
}
const message = await waitForMessage(
(candidate) =>
candidate.id === requestId && !("method" in candidate) && "result" in candidate,
"command approval response",
);
return message.result;
},
};
}
function toJsonObject(value: unknown): JsonObject {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonObject;
}
return {};
}
export function waitForNextPermission(
session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
});
});
}

View File

@@ -74,6 +74,22 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
// COMPAT(opencodeEofRecovery): added in v0.1.73 to compensate for OpenCode 1.14.42+
// closing the /event SSE stream cleanly after `server.connected`. Drop this whole
// recovery path once OpenCode upstream restores live event delivery and the floor
// version reflects that.
// Upstream: anomalyco/opencode#26697 (SSE /event closes immediately after
// server.connected) and anomalyco/opencode#26635 (prompt_async silently discards
// requests; SSE path broken).
const OPENCODE_EOF_RECOVERY_TIMEOUT_MS = 5 * 60 * 1000;
const OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS = 1_000;
const OPENCODE_RECOVERY_ABORT_TIMEOUT_MS = 2_000;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
// If OpenCode silently rejects the prompt (invalid model/mode/auth), no assistant
// message is ever persisted. Bound the wait so the turn fails in seconds instead
// of hanging until the completion cap. Valid models normally persist their first
// message within a second of LLM start, so 10s leaves comfortable headroom.
const OPENCODE_EOF_RECOVERY_LIVENESS_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [
{
@@ -651,6 +667,19 @@ function mergeOpenCodeStepFinishUsage(
}
}
function formatOpenCodeAssistantErrorMessage(
error: NonNullable<OpenCodeAssistantMessage["error"]>,
): string {
const data = (error as { data?: unknown }).data;
if (data && typeof data === "object" && "message" in data) {
const message = (data as { message?: unknown }).message;
if (typeof message === "string" && message.trim().length > 0) {
return message.trim();
}
}
return error.name;
}
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
return [
usage.inputTokens,
@@ -818,18 +847,32 @@ async function readOpenCodeSessionTimeline(
}
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const parts = await readOpenCodeStoredParts(storageRoot, messageId);
return readOpenCodeTextFromParts(parts);
}
async function readOpenCodeStoredParts(
storageRoot: string,
messageId: string,
): Promise<OpenCodeStoredPart[]> {
const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed?.type === "text" && typeof parsed.text === "string") {
if (parsed) {
parts.push(parsed);
}
}
return parts.sort(
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
);
}
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
return parts
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n");
@@ -898,8 +941,15 @@ export const __openCodeInternals = {
},
};
interface OpenCodeRecoveryOptions {
timeoutMs: number;
pollIntervalMs: number;
livenessMs: number;
}
interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime;
recovery?: OpenCodeRecoveryOptions;
}
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
@@ -931,6 +981,7 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
private readonly recovery: OpenCodeRecoveryOptions;
constructor(
logger: Logger,
@@ -941,6 +992,11 @@ export class OpenCodeAgentClient implements AgentClient {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.recovery = deps.recovery ?? {
timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
};
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
@@ -984,9 +1040,11 @@ export class OpenCodeAgentClient implements AgentClient {
client,
session.id,
this.logger,
this.storageRoot,
new Map(this.modelContextWindows),
acquisition.release,
options?.persistSession,
this.recovery,
);
} catch (error) {
acquisition.release();
@@ -1025,8 +1083,11 @@ export class OpenCodeAgentClient implements AgentClient {
client,
handle.sessionId,
this.logger,
this.storageRoot,
new Map(this.modelContextWindows),
acquisition.release,
undefined,
this.recovery,
);
} catch (error) {
acquisition.release();
@@ -2122,6 +2183,18 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
const OPENCODE_TRACE_ENABLED = process.env.PASEO_OPENCODE_TRACE === "1";
function traceOpenCode(tag: string, data: Record<string, unknown> = {}): void {
if (!OPENCODE_TRACE_ENABLED) return;
const line = JSON.stringify({ ts: new Date().toISOString(), tag, ...data }, (_k, v) => {
if (v instanceof Error) return { name: v.name, message: v.message, stack: v.stack };
if (typeof v === "bigint") return v.toString();
return v;
});
process.stderr.write(`[opencode-trace] ${line}\n`);
}
class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
@@ -2130,10 +2203,12 @@ class OpenCodeAgentSession implements AgentSession {
private readonly client: OpencodeClient;
private readonly sessionId: string;
private readonly logger: Logger;
private readonly storageRoot: string;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null;
private accumulatedUsage: AgentUsage = {};
private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null;
@@ -2157,23 +2232,41 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null;
private readonly persistSession: boolean;
private deletedFromProvider = false;
private foregroundAssistantMessageEmitted = false;
private foregroundAssistantText = "";
private foregroundUsageUpdated = false;
private foregroundKnownMessageIds = new Set<string>();
private foregroundEmittedQuestionIds = new Set<string>();
private foregroundEmittedPermissionIds = new Set<string>();
private foregroundEmittedReasoningTextLengthByPartId = new Map<string, number>();
private foregroundEmittedToolCallSignatureByCallId = new Map<string, string>();
private foregroundTurnStartedAt: number | null = null;
private readonly recovery: OpenCodeRecoveryOptions;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
persistSession = true,
recovery: OpenCodeRecoveryOptions = {
timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
},
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger;
this.storageRoot = storageRoot;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null;
this.persistSession = persistSession;
this.recovery = recovery;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model,
);
@@ -2223,9 +2316,18 @@ class OpenCodeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController;
turnAbortController?.abort();
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
// the running tool actually stops, which can be tens of seconds for
// long-running tools. Cap the wait so the user-visible cancel lands
// quickly while still giving OpenCode a chance to confirm the abort
// cleanly. Drop the timeout once upstream returns abort acknowledgement
// before tool teardown.
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId },
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
);
});
if (turnId) {
this.finishForegroundTurn(
@@ -2235,6 +2337,46 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
const abortPromise = this.client.session
.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
})
.then(() => undefined)
.catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId, reason },
"OpenCode session.abort rejected",
);
});
const trackedAbortPromise = abortPromise.finally(() => {
if (this.pendingAbortPromise === trackedAbortPromise) {
this.pendingAbortPromise = null;
}
});
this.pendingAbortPromise = trackedAbortPromise;
return trackedAbortPromise;
}
private async awaitPendingAbortBeforeStartingTurn(): Promise<void> {
const pendingAbortPromise = this.pendingAbortPromise;
if (!pendingAbortPromise) {
return;
}
await withTimeout(
pendingAbortPromise,
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
"OpenCode pending session.abort",
).catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId },
"OpenCode session.abort was still pending before starting the next turn",
);
});
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
@@ -2242,11 +2384,21 @@ class OpenCodeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
await this.awaitPendingAbortBeforeStartingTurn();
this.foregroundTurnStartedAt = Date.now();
this.runningToolCalls.clear();
this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear();
this.pendingChildToolPartsBySessionId.clear();
this.foregroundAssistantMessageEmitted = false;
this.foregroundAssistantText = "";
this.foregroundUsageUpdated = false;
this.foregroundEmittedQuestionIds.clear();
this.foregroundEmittedPermissionIds.clear();
this.foregroundEmittedReasoningTextLengthByPartId.clear();
this.foregroundEmittedToolCallSignatureByCallId.clear();
this.foregroundKnownMessageIds = await this.readPersistedSessionMessageIds();
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
@@ -2380,6 +2532,14 @@ class OpenCodeAgentSession implements AgentSession {
// SDK input validation) is caught alongside async rejections. A plain
// `.then().catch()` chain would let a sync throw escape unhandled.
void (async () => {
traceOpenCode("promptAsync.start", {
turnId,
sessionId: this.sessionId,
model,
effectiveMode,
effectiveVariant,
partTypes: parts.map((p) => p.type),
});
try {
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
@@ -2398,6 +2558,12 @@ class OpenCodeAgentSession implements AgentSession {
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
traceOpenCode("promptAsync.response", {
turnId,
hasError: promptResponse.error !== undefined,
error: promptResponse.error,
data: promptResponse.data,
});
if (promptResponse.error) {
this.finishForegroundTurn(
{
@@ -2409,6 +2575,13 @@ class OpenCodeAgentSession implements AgentSession {
);
}
} catch (error) {
traceOpenCode("promptAsync.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2436,21 +2609,44 @@ class OpenCodeAgentSession implements AgentSession {
turnAbortController: AbortController,
subscriptionReady: Deferred<void>,
): Promise<void> {
traceOpenCode("subscribe.start", { turnId, sessionId: this.sessionId, cwd: this.config.cwd });
try {
const result = await this.client.event.subscribe(
{ directory: this.config.cwd },
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
{ signal: turnAbortController.signal },
);
traceOpenCode("subscribe.ready", { turnId, sessionId: this.sessionId });
subscriptionReady.resolve();
let eventCount = 0;
for await (const event of result.stream) {
eventCount += 1;
traceOpenCode("event.raw", {
turnId,
n: eventCount,
type: (event as { type?: string }).type,
properties: (event as { properties?: unknown }).properties,
});
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
break;
}
const translated = await this.translateEvent(event);
traceOpenCode("event.translated", {
turnId,
n: eventCount,
count: translated.length,
types: translated.map((t) => t.type),
});
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
return;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
@@ -2458,6 +2654,7 @@ class OpenCodeAgentSession implements AgentSession {
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
this.finishForegroundTurn(terminalEvent, turnId);
return;
}
@@ -2465,7 +2662,20 @@ class OpenCodeAgentSession implements AgentSession {
}
}
traceOpenCode("stream.eof", {
turnId,
eventCount,
aborted: turnAbortController.signal.aborted,
stillActive: this.activeForegroundTurnId === turnId,
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
const recovered = await this.recoverTurnFromPersistedCompletion(turnId);
traceOpenCode("recovery.result", { turnId, recovered });
if (recovered) {
return;
}
traceOpenCode("turn.fail.eof", { turnId, eventCount });
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2476,6 +2686,11 @@ class OpenCodeAgentSession implements AgentSession {
);
}
} catch (error) {
traceOpenCode("subscribe.error", {
turnId,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
});
subscriptionReady.reject(error);
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
@@ -2504,10 +2719,448 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private async recoverTurnFromPersistedCompletion(turnId: string): Promise<boolean> {
traceOpenCode("recovery.start", {
turnId,
foregroundTurnStartedAt: this.foregroundTurnStartedAt,
knownMessageIds: Array.from(this.foregroundKnownMessageIds),
sessionId: this.sessionId,
timeoutMs: this.recovery.timeoutMs,
pollIntervalMs: this.recovery.pollIntervalMs,
});
const startedAt = this.foregroundTurnStartedAt;
if (startedAt === null) {
traceOpenCode("recovery.no-start-time", { turnId });
return false;
}
const completionDeadline = Date.now() + this.recovery.timeoutMs;
const livenessDeadline = Date.now() + this.recovery.livenessMs;
let attempt = 0;
let observedActivity = false;
while (true) {
if (this.activeForegroundTurnId !== turnId) {
traceOpenCode("recovery.cancelled", { turnId, attempt });
return true;
}
attempt += 1;
const emittedPromptIds = await this.pollPendingQuestionsAndPermissions(turnId);
if (emittedPromptIds > 0) {
observedActivity = true;
}
const outcome = await this.fetchAssistantOutcomeFromMessagesApi(startedAt);
traceOpenCode("recovery.poll", {
turnId,
attempt,
kind: outcome?.kind ?? "none",
messageId: outcome?.messageId,
emittedPromptIds,
});
if (outcome?.kind === "failure") {
this.foregroundKnownMessageIds.add(outcome.messageId);
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: outcome.error,
},
turnId,
);
return true;
}
if (outcome?.kind === "completion") {
this.emitIncrementalAssistantParts(outcome.parts, turnId);
return this.applyRecoveredAssistantCompletion(outcome, turnId);
}
if (outcome?.kind === "in-progress") {
observedActivity = true;
this.emitIncrementalAssistantParts(outcome.parts, turnId);
}
const now = Date.now();
if (!observedActivity && now >= livenessDeadline) {
const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(
turnId,
attempt,
"liveness",
);
if (deferred) {
continue;
}
return true;
}
if (now >= completionDeadline) {
const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(
turnId,
attempt,
"completion",
);
if (deferred) {
continue;
}
return true;
}
const waitMs = Math.min(this.recovery.pollIntervalMs, completionDeadline - now);
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
}
}
private async deferForPendingPermissionOrFailRecoveredTurnAfterCap(
turnId: string,
attempt: number,
cap: "liveness" | "completion",
): Promise<boolean> {
if (this.pendingPermissions.size > 0) {
// A pending OpenCode question/permission means the turn is blocked on
// user input, not dead. Keep polling until the user response lets the
// assistant finish or the turn is canceled.
traceOpenCode(`recovery.${cap}-deferred-for-permission`, {
turnId,
attempt,
pendingPermissionIds: Array.from(this.pendingPermissions.keys()),
});
await new Promise<void>((resolve) => setTimeout(resolve, this.recovery.pollIntervalMs));
return true;
}
traceOpenCode(cap === "liveness" ? "recovery.liveness-exhausted" : "recovery.exhausted", {
turnId,
attempt,
});
await this.failRecoveredTurnAfterCap(turnId, cap);
return false;
}
private async failRecoveredTurnAfterCap(
turnId: string,
cap: "liveness" | "completion",
): Promise<void> {
await this.abortOpenCodeSessionAfterRecoveryCap(turnId, cap);
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: "OpenCode event stream ended before the turn reached a terminal state",
},
turnId,
);
}
private async abortOpenCodeSessionAfterRecoveryCap(
turnId: string,
cap: "liveness" | "completion",
): Promise<void> {
const abortPromise = this.beginSessionAbort(turnId, `recovery-${cap}`);
await withTimeout(
abortPromise,
OPENCODE_RECOVERY_ABORT_TIMEOUT_MS,
"OpenCode session.abort",
).catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId, cap },
"OpenCode session.abort exceeded the EOF recovery cap",
);
});
}
private async pollPendingQuestionsAndPermissions(turnId: string): Promise<number> {
const [questionsResponse, permissionsResponse] = await Promise.all([
Promise.resolve()
.then(() => this.client.question.list({ directory: this.config.cwd }))
.catch((error) => {
traceOpenCode("recovery.question-list.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
return null;
}),
Promise.resolve()
.then(() => this.client.permission.list({ directory: this.config.cwd }))
.catch((error) => {
traceOpenCode("recovery.permission-list.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
return null;
}),
]);
if (this.activeForegroundTurnId !== turnId) return 0;
let emitted = 0;
for (const question of questionsResponse?.data ?? []) {
if (question.sessionID !== this.sessionId) continue;
if (this.foregroundEmittedQuestionIds.has(question.id)) continue;
this.foregroundEmittedQuestionIds.add(question.id);
emitted += 1;
const synthetic = {
id: question.id,
type: "question.asked",
properties: question,
} as unknown as OpenCodeEvent;
const events = await this.translateEvent(synthetic);
for (const event of events) {
this.notifySubscribers(event, turnId);
}
}
for (const permission of permissionsResponse?.data ?? []) {
if (permission.sessionID !== this.sessionId) continue;
if (this.foregroundEmittedPermissionIds.has(permission.id)) continue;
this.foregroundEmittedPermissionIds.add(permission.id);
emitted += 1;
const synthetic = {
id: permission.id,
type: "permission.asked",
properties: permission,
} as unknown as OpenCodeEvent;
const events = await this.translateEvent(synthetic);
for (const event of events) {
this.notifySubscribers(event, turnId);
}
}
return emitted;
}
private async fetchAssistantOutcomeFromMessagesApi(startedAt: number): Promise<
| {
kind: "completion";
messageId: string;
text: string;
parts: readonly OpenCodePart[];
usage: AgentUsage;
}
| { kind: "failure"; messageId: string; error: string }
| { kind: "in-progress"; messageId: string; parts: readonly OpenCodePart[] }
| null
> {
const response = await Promise.resolve()
.then(() =>
this.client.session.messages({
sessionID: this.sessionId,
directory: this.config.cwd,
}),
)
.catch((error) => {
traceOpenCode("recovery.messages.throw", {
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
return null;
});
if (response === null) {
return null;
}
if (response.error || !response.data) {
return null;
}
for (let index = response.data.length - 1; index >= 0; index -= 1) {
const item = response.data[index];
if (!item) continue;
const info = item.info;
if (info.role !== "assistant") continue;
if (this.foregroundKnownMessageIds.has(info.id)) continue;
if (typeof info.time?.created === "number" && info.time.created < startedAt) continue;
if (info.error) {
return {
kind: "failure",
messageId: info.id,
error: formatOpenCodeAssistantErrorMessage(info.error),
};
}
if (typeof info.time?.completed !== "number") {
return { kind: "in-progress", messageId: info.id, parts: item.parts };
}
let text = item.parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
.map((part) => (part.text ?? "").trim())
.filter((part) => part.length > 0)
.join("\n\n");
if (!text) {
text = stringifyStructuredAssistantMessage(info.structured) ?? "";
}
if (!text) continue;
const usage: AgentUsage = {};
mergeOpenCodeStepFinishUsage(usage, { cost: info.cost, tokens: info.tokens });
return { kind: "completion", messageId: info.id, text, parts: item.parts, usage };
}
return null;
}
private emitIncrementalAssistantParts(parts: readonly OpenCodePart[], turnId: string): void {
for (const part of parts) {
if (part.type === "reasoning" && part.text) {
const emittedTextLength =
this.foregroundEmittedReasoningTextLengthByPartId.get(part.id) ?? 0;
if (part.text.length <= emittedTextLength) continue;
const text = part.text.slice(emittedTextLength);
this.foregroundEmittedReasoningTextLengthByPartId.set(part.id, part.text.length);
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text },
},
turnId,
);
continue;
}
if (part.type !== "tool") continue;
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (!parsedToolPart.success || !parsedToolPart.data) continue;
const callId = parsedToolPart.data.callId;
const signature = this.createRecoveredToolCallSignature(part, parsedToolPart.data);
const lastSignature = this.foregroundEmittedToolCallSignatureByCallId.get(callId);
if (lastSignature === signature) continue;
this.foregroundEmittedToolCallSignatureByCallId.set(callId, signature);
this.trackToolCall(parsedToolPart.data);
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
},
turnId,
);
}
}
private createRecoveredToolCallSignature(
part: Extract<OpenCodePart, { type: "tool" }>,
item: ToolCallTimelineItem,
): string {
const state = (part as { state?: { input?: unknown; output?: unknown; error?: unknown } })
.state;
return JSON.stringify([
item.callId,
item.status,
state?.input ?? null,
state?.output ?? null,
state?.error ?? null,
]);
}
private applyRecoveredAssistantCompletion(
completion: {
messageId: string;
text: string;
parts: readonly OpenCodePart[];
usage: AgentUsage;
},
turnId: string,
): boolean {
if (this.activeForegroundTurnId !== turnId) {
return false;
}
this.foregroundKnownMessageIds.add(completion.messageId);
this.logger.warn(
{ sessionId: this.sessionId, turnId },
"Recovered OpenCode turn completion via messages API after SSE EOF",
);
const recoveryText = this.resolvePersistedAssistantRecoveryText(completion.text);
if (recoveryText === null) {
return false;
}
if (recoveryText.length > 0) {
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: recoveryText },
},
turnId,
);
this.foregroundAssistantMessageEmitted = true;
}
if (hasNormalizedOpenCodeUsage(completion.usage) && !this.foregroundUsageUpdated) {
this.accumulatedUsage = {
...this.accumulatedUsage,
...completion.usage,
};
this.notifySubscribers(
{
type: "usage_updated",
provider: "opencode",
usage: { ...this.accumulatedUsage },
},
turnId,
);
this.foregroundUsageUpdated = true;
}
this.finishForegroundTurn(
{
type: "turn_completed",
provider: "opencode",
usage: hasNormalizedOpenCodeUsage(this.accumulatedUsage)
? { ...this.accumulatedUsage }
: undefined,
},
turnId,
);
return true;
}
private resolvePersistedAssistantRecoveryText(completedText: string): string | null {
if (!this.foregroundAssistantMessageEmitted) {
return completedText;
}
if (completedText === this.foregroundAssistantText) {
return "";
}
return completedText.startsWith(this.foregroundAssistantText)
? completedText.slice(this.foregroundAssistantText.length)
: null;
}
private async readPersistedSessionMessageIds(): Promise<Set<string>> {
const messageRoot = path.join(this.storageRoot, "message", this.sessionId);
const messageFiles = await findJsonFiles(messageRoot);
const messageIds = new Set<string>();
for (const file of messageFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema);
if (parsed?.sessionID === this.sessionId) {
messageIds.add(parsed.id);
}
}
return messageIds;
}
private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,
): void {
traceOpenCode("finishForegroundTurn", {
turnId,
activeTurnId: this.activeForegroundTurnId,
type: event.type,
error: event.type === "turn_failed" ? event.error : undefined,
reason: event.type === "turn_canceled" ? event.reason : undefined,
});
if (this.activeForegroundTurnId !== turnId) {
return;
}
@@ -2516,6 +3169,7 @@ class OpenCodeAgentSession implements AgentSession {
} else {
this.runningToolCalls.clear();
}
this.foregroundTurnStartedAt = null;
this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
@@ -2561,6 +3215,13 @@ class OpenCodeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
if (event.type === "timeline" && event.item.type === "assistant_message") {
this.foregroundAssistantMessageEmitted = true;
this.foregroundAssistantText += event.item.text;
}
if (event.type === "usage_updated") {
this.foregroundUsageUpdated = true;
}
const tagged = turnId ? { ...event, turnId } : event;
for (const callback of this.subscribers) {
try {

View File

@@ -41,6 +41,52 @@ describe("paseo daemon bootstrap", () => {
}
});
test("redacts Agent MCP debug request credentials and bodies", async () => {
const logLines: string[] = [];
const logger = pino(
{ level: "debug" },
{
write: (line: string) => {
logLines.push(line);
},
},
);
const daemonHandle = await createTestPaseoDaemon({
logger,
mcpDebug: true,
});
try {
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`, {
method: "POST",
headers: {
Authorization: "Bearer secret-debug-token",
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
apiKey: "secret-body-token",
},
}),
});
expect(response.status).toBe(400);
const logs = logLines.join("\n");
expect(logs).toContain("Agent MCP request");
expect(logs).toContain("[redacted]");
expect(logs).toContain('"method":"tools/call"');
expect(logs).toContain('"hasParams":true');
expect(logs).not.toContain("secret-debug-token");
expect(logs).not.toContain("secret-body-token");
expect(logs).not.toContain("apiKey");
} finally {
await daemonHandle.close();
}
});
test("fails fast when OpenAI speech provider is configured without credentials", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-openai-config-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");

View File

@@ -1,7 +1,7 @@
import express from "express";
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
import { createReadStream, unlinkSync, existsSync } from "fs";
import { stat } from "fs/promises";
import { constants, existsSync, unlinkSync } from "fs";
import { open } from "fs/promises";
import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os";
import path from "node:path";
@@ -119,6 +119,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
@@ -140,6 +141,11 @@ import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js"
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
const DOWNLOAD_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
function formatHostForHttpUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
@@ -154,6 +160,38 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null
).toString();
}
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return {
type: body === null ? "null" : typeof body,
};
}
const record = body as Record<string, unknown>;
const method = typeof record.method === "string" ? record.method : undefined;
return {
type: "object",
...(typeof record.jsonrpc === "string" ? { jsonrpc: record.jsonrpc } : {}),
...(method ? { method } : {}),
hasId: Object.prototype.hasOwnProperty.call(record, "id"),
hasParams: Object.prototype.hasOwnProperty.call(record, "params"),
};
}
function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
if (!Array.isArray(body)) {
return summarizeAgentMcpDebugMessage(body);
}
const messages = body.slice(0, MAX_MCP_DEBUG_BATCH_ITEMS).map(summarizeAgentMcpDebugMessage);
return {
type: "batch",
count: body.length,
messages,
...(body.length > messages.length ? { omitted: body.length - messages.length } : {}),
};
}
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
@@ -205,6 +243,7 @@ export interface PaseoDaemonConfig {
providerOverrides?: Record<string, ProviderOverride>;
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pushNotificationSender?: PushNotificationSender;
}
export interface PaseoDaemon {
@@ -378,8 +417,10 @@ export async function createPaseoDaemon(
return;
}
let fileHandle: Awaited<ReturnType<typeof open>> | null = null;
try {
const fileStats = await stat(entry.absolutePath);
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS);
const fileStats = await fileHandle.stat();
if (!fileStats.isFile()) {
res.status(404).json({ error: "File not found" });
return;
@@ -388,9 +429,10 @@ export async function createPaseoDaemon(
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.setHeader("Content-Type", entry.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
res.setHeader("Content-Length", entry.size.toString());
res.setHeader("Content-Length", fileStats.size.toString());
const stream = createReadStream(entry.absolutePath);
const stream = fileHandle.createReadStream();
fileHandle = null;
stream.on("error", (err) => {
logger.error({ err }, "Failed to stream download");
if (!res.headersSent) {
@@ -405,6 +447,8 @@ export async function createPaseoDaemon(
if (!res.headersSent) {
res.status(404).json({ error: "File not found" });
}
} finally {
await fileHandle?.close().catch(() => undefined);
}
};
@@ -669,8 +713,8 @@ export async function createPaseoDaemon(
method: req.method,
url: req.originalUrl,
sessionId: req.header("mcp-session-id"),
authorization: req.header("authorization"),
body: req.body,
authorization: req.header("authorization") ? REDACTED_LOG_VALUE : undefined,
body: summarizeAgentMcpDebugBody(req.body),
},
"Agent MCP request",
);
@@ -842,6 +886,7 @@ export async function createPaseoDaemon(
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
github,
config.pushNotificationSender,
);
if (relayEnabled) {

View File

@@ -1,12 +1,19 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "../shared/messages.js";
import type { AgentSnapshotPayload } from "./messages.js";
import { PushService } from "./push/push-service.js";
import { PushTokenStore } from "./push/token-store.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js";
class RecordingPushNotificationSender implements PushNotificationSender {
readonly sent: PushPayload[] = [];
async send(payload: PushPayload): Promise<void> {
this.sent.push(payload);
}
}
/**
* Tests for client activity tracking and smart notifications.
*
@@ -31,23 +38,17 @@ describe("client activity tracking", () => {
let daemon: TestPaseoDaemon;
let client1: DaemonClient;
let client2: DaemonClient;
let sendPushSpy: ReturnType<typeof vi.spyOn>;
let getAllTokensSpy: ReturnType<typeof vi.spyOn>;
let pushNotifications: RecordingPushNotificationSender;
beforeEach(async () => {
sendPushSpy = vi.spyOn(PushService.prototype, "sendPush").mockResolvedValue(undefined);
getAllTokensSpy = vi
.spyOn(PushTokenStore.prototype, "getAllTokens")
.mockReturnValue(["ExponentPushToken[activity-test]"]);
daemon = await createTestPaseoDaemon();
pushNotifications = new RecordingPushNotificationSender();
daemon = await createTestPaseoDaemon({ pushNotificationSender: pushNotifications });
});
afterEach(async () => {
if (client1) await client1.close().catch(() => {});
if (client2) await client2.close().catch(() => {});
await daemon.close();
sendPushSpy.mockRestore();
getAllTokensSpy.mockRestore();
}, 30000);
async function createClient(): Promise<DaemonClient> {
@@ -205,7 +206,7 @@ describe("client activity tracking", () => {
expect(attention.reason).toBe("finished");
expect(attention.shouldNotify).toBe(false);
expect(sendPushSpy).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
}, 120000);
test("notification when no heartbeat received (legacy/new client)", async () => {
@@ -225,7 +226,7 @@ describe("client activity tracking", () => {
expect(attention.reason).toBe("finished");
expect(attention.shouldNotify).toBe(false);
expect(sendPushSpy).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
}, 120000);
});
@@ -309,7 +310,7 @@ describe("client activity tracking", () => {
// No stale client is selected for in-app; push handles the fallback.
expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(false);
expect(sendPushSpy).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
}, 120000);
test("notifies only the present Electron-style web client when Firefox is stale", async () => {
@@ -345,7 +346,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(true);
expect(sendPushSpy).not.toHaveBeenCalled();
expect(pushNotifications.sent).toEqual([]);
}, 120000);
});
@@ -465,7 +466,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(true);
expect(sendPushSpy).not.toHaveBeenCalled();
expect(pushNotifications.sent).toEqual([]);
}, 120000);
test("notify web when user active on web but looking at different agent", async () => {
@@ -543,7 +544,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(false);
expect(sendPushSpy).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
}, 120000);
});
@@ -581,7 +582,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(false);
expect(attention2.shouldNotify).toBe(false);
expect(sendPushSpy).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
}, 120000);
test("notification when app not visible but activity is recent", async () => {
@@ -647,7 +648,7 @@ describe("client activity tracking", () => {
expect(attention1.shouldNotify).toBe(true);
expect(attention2.shouldNotify).toBe(false);
expect(sendPushSpy).not.toHaveBeenCalled();
expect(pushNotifications.sent).toEqual([]);
}, 120000);
});
});

View File

@@ -13,16 +13,6 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-"));
}
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
): string {
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0].id;
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
@@ -63,7 +53,7 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
provider: "opencode",
cwd,
title: "OpenCode question regression",
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
model: "opencode/big-pickle",
modeId: "plan",
});
@@ -111,7 +101,7 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
provider: "opencode",
cwd,
title: "OpenCode plan mode regression",
model: pickOpenCodeModel(modelList.models),
model: "opencode/big-pickle",
modeId: "plan",
});

View File

@@ -22,23 +22,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = [
"minimax-m2.5-free",
"kimi-k2.5-free",
"glm-5-free",
"free",
"mini",
"gpt-5-nano",
],
): string {
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0].id;
}
function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
return messages.some(
(message) =>
@@ -305,8 +288,8 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
provider: "opencode",
cwd,
title: "OpenCode send while working",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
model: "opencode/big-pickle",
modeId: "build",
});
await client.sendMessage(
@@ -365,8 +348,8 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
provider: "opencode",
cwd,
title: "OpenCode explicit interrupt",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
model: "opencode/big-pickle",
modeId: "build",
});
await client.sendMessage(

View File

@@ -1,10 +1,10 @@
// POSIX-only: symlink fixtures
/* eslint-disable max-nested-callbacks */
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listDirectoryEntries, readExplorerFile } from "./service.js";
import { getDownloadableFileInfo, listDirectoryEntries, readExplorerFile } from "./service.js";
import { isPlatform } from "../../test-utils/platform.js";
async function createTempDir(prefix: string): Promise<string> {
@@ -55,4 +55,53 @@ describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
await rm(outsideRoot, { recursive: true, force: true });
}
});
it("skips listed symlink entries that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
await writeFile(path.join(root, "visible.txt"), "visible\n", "utf-8");
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
const result = await listDirectoryEntries({ root });
const names = result.entries.map((entry) => entry.name);
expect(names).toContain("visible.txt");
expect(names).not.toContain("secret-link.txt");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
it("uses canonical paths for downloadable symlink targets inside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
const target = path.join(root, "safe.txt");
const link = path.join(root, "safe-link.txt");
await writeFile(target, "safe\n", "utf-8");
await symlink("safe.txt", link);
const file = await readExplorerFile({
root,
relativePath: "safe-link.txt",
});
const info = await getDownloadableFileInfo({
root,
relativePath: "safe-link.txt",
});
expect(file.path).toBe("safe-link.txt");
expect(file.content).toBe("safe\n");
expect(info.path).toBe("safe-link.txt");
expect(info.fileName).toBe("safe-link.txt");
expect(info.absolutePath).toBe(await realpath(target));
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -1,4 +1,5 @@
import { promises as fs } from "fs";
import { constants, promises as fs } from "fs";
import type { FileHandle } from "fs/promises";
import path from "path";
import { resolvePathFromBase } from "../path-utils.js";
@@ -54,6 +55,10 @@ const TEXT_MIME_TYPES: Record<string, string> = {
};
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
const FILE_TYPE_SAMPLE_BYTES = 8192;
const READ_FILE_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
@@ -69,6 +74,11 @@ interface ScopedPathParams {
relativePath?: string;
}
interface ScopedPath {
requestedPath: string;
resolvedPath: string;
}
interface EntryPayloadParams {
root: string;
targetPath: string;
@@ -81,17 +91,17 @@ export async function listDirectoryEntries({
relativePath = ".",
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
const directoryPath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(directoryPath);
const stats = await fs.stat(directoryPath.resolvedPath);
if (!stats.isDirectory()) {
throw new Error("Requested path is not a directory");
}
const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
const dirents = await fs.readdir(directoryPath.resolvedPath, { withFileTypes: true });
const entriesWithNulls = await Promise.all(
dirents.map(async (dirent) => {
const targetPath = path.join(directoryPath, dirent.name);
const targetPath = path.join(directoryPath.requestedPath, dirent.name);
const kind: ExplorerEntryKind = dirent.isDirectory() ? "directory" : "file";
try {
return await buildEntryPayload({
@@ -103,7 +113,7 @@ export async function listDirectoryEntries({
} catch (error) {
// Directories can contain dangling links (e.g. AGENTS.md -> CLAUDE.md).
// Skip entries whose targets disappeared instead of failing the whole listing.
if (isMissingEntryError(error)) {
if (isMissingEntryError(error) || isOutsideWorkspaceError(error)) {
return null;
}
throw error;
@@ -121,7 +131,7 @@ export async function listDirectoryEntries({
});
return {
path: normalizeRelativePath({ root, targetPath: directoryPath }),
path: normalizeRelativePath({ root, targetPath: directoryPath.requestedPath }),
entries,
};
}
@@ -171,48 +181,53 @@ export async function readExplorerFileBytes({
relativePath,
}: ReadFileParams): Promise<FileExplorerFileBytes> {
const filePath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(filePath);
const handle = await openFileForRead(filePath.resolvedPath);
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
try {
const stats = await handle.stat();
const ext = path.extname(filePath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath }),
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
const ext = path.extname(filePath.resolvedPath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
};
const buffer = await handle.readFile();
if (ext in IMAGE_MIME_TYPES) {
return {
...basePayload,
kind: "image",
encoding: "binary",
bytes: buffer,
mimeType: IMAGE_MIME_TYPES[ext],
};
}
if (isLikelyBinary(buffer)) {
return {
...basePayload,
kind: "binary",
encoding: "binary",
bytes: buffer,
mimeType: "application/octet-stream",
};
}
if (ext in IMAGE_MIME_TYPES) {
const buffer = await fs.readFile(filePath);
return {
...basePayload,
kind: "image",
encoding: "binary",
kind: "text",
encoding: "utf-8",
bytes: buffer,
mimeType: IMAGE_MIME_TYPES[ext],
mimeType: textMimeTypeForExtension(ext),
};
} finally {
await handle.close();
}
const buffer = await fs.readFile(filePath);
if (isLikelyBinary(buffer)) {
return {
...basePayload,
kind: "binary",
encoding: "binary",
bytes: buffer,
mimeType: "application/octet-stream",
};
}
return {
...basePayload,
kind: "text",
encoding: "utf-8",
bytes: buffer,
mimeType: textMimeTypeForExtension(ext),
};
}
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
@@ -223,47 +238,50 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
size: number;
}> {
const filePath = await resolveScopedPath({ root, relativePath });
const stats = await fs.stat(filePath);
const handle = await openFileForRead(filePath.resolvedPath);
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
try {
const stats = await handle.stat();
const ext = path.extname(filePath).toLowerCase();
let mimeType = "application/octet-stream";
if (ext in IMAGE_MIME_TYPES) {
mimeType = IMAGE_MIME_TYPES[ext];
} else {
// Read only a small prefix to classify likely text vs binary.
const handle = await fs.open(filePath, "r");
const sample = Buffer.alloc(8192);
try {
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
const ext = path.extname(filePath.resolvedPath).toLowerCase();
let mimeType = "application/octet-stream";
if (ext in IMAGE_MIME_TYPES) {
mimeType = IMAGE_MIME_TYPES[ext];
} else {
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
if (!isLikelyBinary(chunk)) {
mimeType = textMimeTypeForExtension(ext);
}
} finally {
await handle.close();
}
}
return {
path: normalizeRelativePath({ root, targetPath: filePath }),
absolutePath: filePath,
fileName: path.basename(filePath),
mimeType,
size: stats.size,
};
return {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
absolutePath: filePath.resolvedPath,
fileName: path.basename(filePath.requestedPath),
mimeType,
size: stats.size,
};
} finally {
await handle.close();
}
}
async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams): Promise<string> {
async function resolveScopedPath({
root,
relativePath = ".",
}: ScopedPathParams): Promise<ScopedPath> {
const normalizedRoot = path.resolve(root);
const requestedPath = resolvePathFromBase(normalizedRoot, relativePath);
const relative = path.relative(normalizedRoot, requestedPath);
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
throw new Error("Access outside of workspace is not allowed");
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
}
const realRoot = await fs.realpath(normalizedRoot);
@@ -272,24 +290,32 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
const realPath = await fs.realpath(requestedPath);
const realRelative = path.relative(realRoot, realPath);
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
throw new Error("Access outside of workspace is not allowed");
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
}
return requestedPath;
return { requestedPath, resolvedPath: realPath };
} catch (error) {
if (isMissingEntryError(error)) {
return requestedPath;
return { requestedPath, resolvedPath: requestedPath };
}
throw error;
}
}
async function openFileForRead(filePath: string): Promise<FileHandle> {
return fs.open(filePath, READ_FILE_OPEN_FLAGS);
}
async function buildEntryPayload({
root,
targetPath,
name,
kind,
}: EntryPayloadParams): Promise<FileExplorerEntry> {
const stats = await fs.stat(targetPath);
const entryPath = await resolveScopedPath({
root,
relativePath: normalizeRelativePath({ root, targetPath }),
});
const stats = await fs.stat(entryPath.resolvedPath);
return {
name,
path: normalizeRelativePath({ root, targetPath }),
@@ -304,6 +330,10 @@ function isMissingEntryError(error: unknown): boolean {
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
}
function isOutsideWorkspaceError(error: unknown): boolean {
return error instanceof Error && error.message === ACCESS_OUTSIDE_WORKSPACE_MESSAGE;
}
function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string {
const normalizedRoot = path.resolve(root);
const normalizedTarget = path.resolve(targetPath);

View File

@@ -0,0 +1,29 @@
import type pino from "pino";
import { PushService, type PushPayload } from "./push-service.js";
import type { PushTokenStore } from "./token-store.js";
export type { PushPayload };
export interface PushNotificationSender {
send(payload: PushPayload): Promise<void>;
}
export function createPushNotificationSender(
logger: pino.Logger,
tokenStore: PushTokenStore,
): PushNotificationSender {
const pushService = new PushService(logger, tokenStore);
return {
async send(payload) {
const tokens = tokenStore.getAllTokens();
logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length === 0) {
return;
}
await pushService.sendPush(tokens, payload);
},
};
}

View File

@@ -1,7 +1,7 @@
import type { PushTokenStore } from "./token-store.js";
import type pino from "pino";
interface PushPayload {
export interface PushPayload {
title: string;
body: string;
data?: Record<string, unknown>;

View File

@@ -1,124 +1,115 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const wsMock = vi.hoisted(() => {
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly url: string;
readonly options: unknown;
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
terminateCalls = 0;
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
constructor(url: string, options?: unknown) {
this.url = url;
this.options = options;
MockWebSocket.instances.push(this);
}
static reset() {
MockWebSocket.instances = [];
}
on(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
handlers.push(listener);
this.listeners.set(event, handlers);
return this;
}
once(event: string, listener: (...args: unknown[]) => void) {
const wrapped = (...args: unknown[]) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}
close(code?: number, reason?: string) {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? "");
}
terminate() {
this.terminateCalls += 1;
this.readyState = MockWebSocket.CLOSED;
this.emit("close", 1006, "");
}
send(data: string) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
}
this.sent.push(data);
}
open() {
this.readyState = MockWebSocket.OPEN;
this.emit("open");
}
message(data: unknown) {
this.emit("message", data);
}
error(err: unknown) {
this.emit("error", err);
}
private off(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(
event,
handlers.filter((handler) => handler !== listener),
);
}
private emit(event: string, ...args: unknown[]) {
const handlers = this.listeners.get(event) ?? [];
for (const handler of handlers.slice()) {
handler(...args);
}
}
}
return { MockWebSocket };
});
vi.mock("ws", () => ({
default: wsMock.MockWebSocket,
WebSocket: wsMock.MockWebSocket,
}));
import type pino from "pino";
import { startRelayTransport } from "./relay-transport";
function createMockLogger() {
const messages: { level: "debug" | "info" | "warn" | "error"; args: unknown[] }[] = [];
const logger = {
child: vi.fn(() => logger),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
messages,
child: () => logger,
debug: (...args: unknown[]) => messages.push({ level: "debug", args }),
info: (...args: unknown[]) => messages.push({ level: "info", args }),
warn: (...args: unknown[]) => messages.push({ level: "warn", args }),
error: (...args: unknown[]) => messages.push({ level: "error", args }),
};
return logger;
}
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
type TestLogger = ReturnType<typeof createMockLogger>;
function hasLogMessage(logger: TestLogger, level: "info" | "warn", message: string): boolean {
return logger.messages.some((entry) => {
return entry.level === level && entry.args.some((arg) => arg === message);
});
}
class FakeRelayWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 3;
readyState = FakeRelayWebSocket.CONNECTING;
sent: Array<string | Uint8Array | ArrayBuffer> = [];
terminateCalls = 0;
private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>();
constructor(readonly url: string) {}
on(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
handlers.push(listener);
this.listeners.set(event, handlers);
}
once(event: string, listener: (...args: unknown[]) => void) {
const wrapped = (...args: unknown[]) => {
this.off(event, wrapped);
listener(...args);
};
this.on(event, wrapped);
}
close(code?: number, reason?: string) {
this.readyState = FakeRelayWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? "");
}
terminate() {
this.terminateCalls += 1;
this.readyState = FakeRelayWebSocket.CLOSED;
this.emit("close", 1006, "");
}
send(data: string | Uint8Array | ArrayBuffer) {
if (this.readyState !== FakeRelayWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
}
this.sent.push(data);
}
open() {
this.readyState = FakeRelayWebSocket.OPEN;
this.emit("open");
}
message(data: unknown) {
this.emit("message", data);
}
private off(event: string, listener: (...args: unknown[]) => void) {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(
event,
handlers.filter((handler) => handler !== listener),
);
}
private emit(event: string, ...args: unknown[]) {
const handlers = this.listeners.get(event) ?? [];
for (const handler of handlers.slice()) {
handler(...args);
}
}
}
function createFakeWebSockets() {
const sockets: FakeRelayWebSocket[] = [];
return {
sockets,
createWebSocket(url: string) {
const socket = new FakeRelayWebSocket(url);
sockets.push(socket);
return socket;
},
};
}
describe("relay-transport control lifecycle", () => {
const controllers: Array<{ stop: () => Promise<void> }> = [];
const MockWebSocket = wsMock.MockWebSocket;
let relay: ReturnType<typeof createFakeWebSockets>;
beforeEach(() => {
MockWebSocket.reset();
relay = createFakeWebSockets();
});
afterEach(async () => {
@@ -135,18 +126,19 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
const control = relay.sockets[0];
expect(control).toBeDefined();
control.open();
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(false);
expect(control.sent.length).toBeGreaterThan(0);
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
expect(hasLogMessage(logger, "info", "relay_control_connected")).toBe(true);
});
test("terminates and reconnects when control socket opens but never becomes ready", () => {
@@ -158,18 +150,19 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const firstControl = MockWebSocket.instances[0];
const firstControl = relay.sockets[0];
firstControl.open();
vi.advanceTimersByTime(8_000);
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
expect(hasLogMessage(logger, "warn", "relay_control_ready_timeout_terminating")).toBe(true);
expect(firstControl.terminateCalls).toBe(1);
vi.advanceTimersByTime(1_000);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
expect(relay.sockets.length).toBeGreaterThanOrEqual(2);
});
test("terminates stale control sockets in under one minute", () => {
@@ -181,47 +174,56 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
const control = relay.sockets[0];
control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
logger.warn.mockClear();
logger.messages.length = 0;
vi.advanceTimersByTime(40_000);
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
expect(hasLogMessage(logger, "warn", "relay_control_stale_terminating")).toBe(true);
expect(control.terminateCalls).toBe(1);
});
test("passes stable relay external session metadata when attaching data socket", async () => {
const logger = createMockLogger();
const attachSocket = vi.fn(async () => {});
const attachedSockets: unknown[] = [];
const attachedMetadata: unknown[] = [];
const attachSocket = async (socket: unknown, metadata: unknown) => {
attachedSockets.push(socket);
attachedMetadata.push(metadata);
};
const controller = startRelayTransport({
logger: logger as unknown as pino.Logger,
attachSocket,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
const control = relay.sockets[0];
control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
const dataSocket = MockWebSocket.instances[1];
const dataSocket = relay.sockets[1];
expect(dataSocket).toBeDefined();
dataSocket.open();
await Promise.resolve();
expect(attachSocket).toHaveBeenCalledTimes(1);
expect(attachSocket).toHaveBeenCalledWith(dataSocket, {
transport: "relay",
externalSessionKey: "session:clt_test",
});
expect(attachedSockets).toEqual([dataSocket]);
expect(attachedMetadata).toEqual([
{
transport: "relay",
externalSessionKey: "session:clt_test",
},
]);
});
test("uses relayUseTls for control and data socket URLs", () => {
@@ -232,15 +234,16 @@ describe("relay-transport control lifecycle", () => {
relayEndpoint: "[::1]:443",
relayUseTls: true,
serverId: "srv_test",
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
const control = relay.sockets[0];
control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
expect(MockWebSocket.instances[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(MockWebSocket.instances[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(relay.sockets[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
expect(relay.sockets[1]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
});
});

View File

@@ -18,6 +18,7 @@ interface RelayTransportOptions {
relayUseTls: boolean;
serverId: string;
daemonKeyPair?: KeyPair;
createWebSocket?: RelayWebSocketFactory;
}
export interface RelayTransportController {
@@ -32,6 +33,16 @@ interface RelaySocketLike {
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
}
interface RelayWebSocketLike extends RelaySocketLike {
terminate: () => void;
on: (
event: "open" | "message" | "close" | "error",
listener: (...args: unknown[]) => void,
) => void;
}
type RelayWebSocketFactory = (url: string) => RelayWebSocketLike;
type ControlMessage =
| { type: "sync"; connectionIds: string[] }
| { type: "connected"; connectionId: string }
@@ -42,6 +53,11 @@ type ControlMessage =
const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000;
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
}
function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer {
if (typeof data === "string") return data;
@@ -106,14 +122,15 @@ export function startRelayTransport({
relayUseTls,
serverId,
daemonKeyPair,
createWebSocket = createDefaultRelayWebSocket,
}: RelayTransportOptions): RelayTransportController {
const relayLogger = logger.child({ module: "relay-transport" });
let stopped = false;
let controlWs: WebSocket | null = null;
let controlWs: RelayWebSocketLike | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // connectionId -> ws
const dataSockets = new Map<string, RelayWebSocketLike>(); // connectionId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0;
@@ -161,7 +178,7 @@ export function startRelayTransport({
serverId,
role: "server",
});
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
const socket = createWebSocket(url);
controlWs = socket;
let controlConnected = false;
@@ -338,7 +355,7 @@ export function startRelayTransport({
role: "server",
connectionId,
});
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
const socket = createWebSocket(url);
dataSockets.set(connectionId, socket);
let attached = false;
@@ -397,7 +414,7 @@ export function startRelayTransport({
}
async function attachEncryptedSocket(
socket: WebSocket,
socket: RelayWebSocketLike,
daemonKeyPair: KeyPair,
logger: pino.Logger,
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
@@ -426,7 +443,10 @@ async function attachEncryptedSocket(
}
}
function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): RelayTransport {
function createRelayTransportAdapter(
socket: RelayWebSocketLike,
logger: pino.Logger,
): RelayTransport {
const relayTransport: RelayTransport = {
send: (data) => {
try {
@@ -445,10 +465,11 @@ function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): Re
};
socket.on("message", (data, isBinary) => {
relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
relayTransport.onmessage?.(normalizeMessageData(data, isBinary === true));
});
socket.on("close", (code, reason) => {
relayTransport.onclose?.(code, reason.toString());
const closeCode = typeof code === "number" ? code : 1006;
relayTransport.onclose?.(closeCode, String(reason ?? ""));
});
socket.on("error", (err) => {
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));

View File

@@ -11,12 +11,14 @@ import {
} from "../bootstrap.js";
import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js";
import { createTestAgentClients } from "./fake-agent-client.js";
import type { PushNotificationSender } from "../push/notifications.js";
interface TestPaseoDaemonOptions {
downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[];
listen?: string;
logger?: Parameters<typeof createPaseoDaemon>[1];
mcpDebug?: boolean;
relayEnabled?: boolean;
relayEndpoint?: string;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
@@ -30,6 +32,7 @@ interface TestPaseoDaemonOptions {
voiceLlmModel?: string | null;
dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender;
}
export interface TestPaseoDaemon {
@@ -150,13 +153,14 @@ async function prepareTestDaemonConfig(
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
mcpDebug: options.mcpDebug ?? false,
agentClients: options.agentClients ?? createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: options.relayEnabled ?? false,
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
appBaseUrl: "https://app.paseo.sh",
auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
openai: options.openai,
speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null,

View File

@@ -10,6 +10,7 @@ import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer {
@@ -28,11 +29,6 @@ const wsModuleMock = vi.hoisted(() => {
return { MockWebSocketServer };
});
const pushMocks = vi.hoisted(() => ({
getAllTokens: vi.fn(() => ["ExponentPushToken[token-1]"]),
sendPush: vi.fn(async () => {}),
}));
vi.mock("ws", () => ({
WebSocketServer: wsModuleMock.MockWebSocketServer,
}));
@@ -43,19 +39,6 @@ vi.mock("./session.js", () => ({
},
}));
vi.mock("./push/token-store.js", () => ({
PushTokenStore: class {
getAllTokens = pushMocks.getAllTokens;
removeToken = vi.fn();
},
}));
vi.mock("./push/push-service.js", () => ({
PushService: class {
sendPush = pushMocks.sendPush;
},
}));
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
interface WebSocketServerInternals {
@@ -81,7 +64,16 @@ function createLogger() {
return logger;
}
class RecordingPushNotificationSender implements PushNotificationSender {
readonly sent: PushPayload[] = [];
async send(payload: PushPayload): Promise<void> {
this.sent.push(payload);
}
}
function createServer(agentManagerOverrides?: Record<string, unknown>) {
const pushNotifications = new RecordingPushNotificationSender();
const agentManager = {
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
@@ -137,9 +129,18 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
})),
dispose: vi.fn(),
}),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
pushNotifications,
);
return { server, agentManager };
return { server, agentManager, pushNotifications };
}
function createOpenSocket() {
@@ -208,7 +209,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
);
const { server } = createServer({
const { server, pushNotifications } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
@@ -223,21 +224,23 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished",
});
expect(pushMocks.sendPush).toHaveBeenCalledWith(["ExponentPushToken[token-1]"], {
title: "Agent finished",
body: "Done. Updated README.md and link.",
data: {
serverId: "srv-test",
agentId: "agent-1",
reason: "finished",
expect(pushNotifications.sent).toEqual([
{
title: "Agent finished",
body: "Done. Updated README.md and link.",
data: {
serverId: "srv-test",
agentId: "agent-1",
reason: "finished",
},
},
});
]);
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
});
it("sends push notifications regardless of UI label presence", async () => {
const getLastAssistantMessage = vi.fn(async () => "Done.");
const { server } = createServer({
const { server, pushNotifications } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
@@ -253,12 +256,12 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished",
});
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
});
it("routes a hidden stale focused browser tab's notification to the present Electron web client", async () => {
const { server } = createServer();
const { server, pushNotifications } = createServer();
const nowMs = Date.now();
const electronWs = connectClient(server, {
deviceType: "web",
@@ -281,11 +284,11 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
expect(readAttentionRequiredMessage(electronWs).shouldNotify).toBe(true);
expect(readAttentionRequiredMessage(firefoxWs).shouldNotify).toBe(false);
expect(pushMocks.sendPush).not.toHaveBeenCalled();
expect(pushNotifications.sent).toEqual([]);
});
it("pushes non-error attention when the only connected client has never sent a heartbeat", async () => {
const { server } = createServer();
const { server, pushNotifications } = createServer();
const ws = connectClient(server, null);
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
@@ -295,11 +298,11 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
});
expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false);
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
expect(pushNotifications.sent).toHaveLength(1);
});
it("does not push error attention when the only connected client has never sent a heartbeat", async () => {
const { server } = createServer();
const { server, pushNotifications } = createServer();
const ws = connectClient(server, null);
await asInternals<WebSocketServerInternals>(server).broadcastAgentAttention({
@@ -309,6 +312,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
});
expect(readAttentionRequiredMessage(ws).shouldNotify).toBe(false);
expect(pushMocks.sendPush).not.toHaveBeenCalled();
expect(pushNotifications.sent).toEqual([]);
});
});

View File

@@ -1,369 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { Server as HTTPServer } from "http";
import type pino from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { SessionOutboundMessage, WSOutboundMessage } from "./messages.js";
const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer {
readonly handlers = new Map<string, (...args: unknown[]) => void>();
on(event: string, handler: (...args: unknown[]) => void) {
this.handlers.set(event, handler);
return this;
}
close() {
// no-op
}
}
return { MockWebSocketServer };
});
vi.mock("ws", () => ({
WebSocketServer: wsModuleMock.MockWebSocketServer,
}));
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { wrapSessionMessage } from "./messages.js";
interface WebSocketServerInternals {
flushRuntimeMetrics(options?: { final?: boolean }): void;
sendToClient(ws: unknown, message: WSOutboundMessage): void;
sendBinaryToClient(ws: unknown, frame: Uint8Array): void;
sessions: Map<unknown, unknown>;
}
const RuntimeMetricsLogSchema = z.object({
outboundMessageTypesTop: z.array(z.tuple([z.string(), z.number()])),
outboundSessionMessageTypesTop: z.array(z.tuple([z.string(), z.number()])),
outboundAgentStreamTypesTop: z.array(z.tuple([z.string(), z.number()])),
outboundAgentStreamAgentsTop: z.array(z.tuple([z.string(), z.number()])),
outboundBinaryFrameTypesTop: z.array(z.tuple([z.string(), z.number()])),
bufferedAmount: z.object({
p95: z.number(),
max: z.number(),
}),
});
type RuntimeMetricsLog = z.infer<typeof RuntimeMetricsLogSchema>;
interface TestSocket {
readyState: number;
bufferedAmount: number;
sent: Array<string | Uint8Array | ArrayBuffer>;
afterSendBufferedAmounts: number[];
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: () => void;
on: () => void;
once: () => void;
}
function createLogger() {
const logger = {
child: vi.fn(() => logger),
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return logger;
}
function createServer(logger: ReturnType<typeof createLogger>) {
const daemonConfigStore = {
onChange: vi.fn(() => () => {}),
};
return new VoiceAssistantWebSocketServer(
{} as unknown as HTTPServer,
logger as unknown as pino.Logger,
"srv-test",
{
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
getMetricsSnapshot: vi.fn(() => ({
totalAgents: 0,
idleAgents: 0,
runningAgents: 0,
pendingPermissionAgents: 0,
erroredAgents: 0,
})),
} as unknown as AgentManager,
{} as unknown as AgentStorage,
{} as unknown as DownloadTokenStore,
"/tmp/paseo-test",
daemonConfigStore as unknown as DaemonConfigStore,
null,
{ allowedOrigins: new Set() },
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
false,
"1.2.3-test",
undefined,
undefined,
undefined,
{} as unknown as FileBackedChatService,
{} as unknown as LoopService,
{} as unknown as ScheduleService,
{
subscribe: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
} as unknown as CheckoutDiffManager,
);
}
function createSocket(afterSendBufferedAmounts: number[]): TestSocket {
const socket: TestSocket = {
readyState: 1,
bufferedAmount: 0,
sent: [],
afterSendBufferedAmounts,
send: vi.fn((data) => {
socket.sent.push(data);
socket.bufferedAmount = afterSendBufferedAmounts.shift() ?? socket.bufferedAmount;
}),
close: vi.fn(),
on: vi.fn(),
once: vi.fn(),
};
return socket;
}
function flushRuntimeMetrics(server: VoiceAssistantWebSocketServer): void {
(server as unknown as WebSocketServerInternals).flushRuntimeMetrics({ final: true });
}
function getRuntimeMetricsLog(logger: ReturnType<typeof createLogger>): RuntimeMetricsLog {
const metricsCall = logger.info.mock.calls.find((call) => call[1] === "ws_runtime_metrics");
expect(metricsCall).toBeDefined();
return RuntimeMetricsLogSchema.parse(metricsCall![0]);
}
function sendToClient(
server: VoiceAssistantWebSocketServer,
socket: TestSocket,
message: WSOutboundMessage,
) {
(server as unknown as WebSocketServerInternals).sendToClient(socket, message);
}
function sendBinaryToClient(
server: VoiceAssistantWebSocketServer,
socket: TestSocket,
frame: Uint8Array,
) {
(server as unknown as WebSocketServerInternals).sendBinaryToClient(socket, frame);
}
function attachSessionSocket(server: VoiceAssistantWebSocketServer, socket: TestSocket): void {
(server as unknown as WebSocketServerInternals).sessions.set(socket, {
session: {},
clientId: "client-1",
appVersion: null,
connectionLogger: createLogger(),
sockets: new Set([socket]),
externalDisconnectCleanupTimeout: null,
});
}
function agentStreamMessage(params: {
agentId: string;
event: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"];
}): WSOutboundMessage {
return wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: params.event,
timestamp: "2026-04-17T00:00:00.000Z",
},
});
}
describe("VoiceAssistantWebSocketServer runtime metrics", () => {
it("records outbound message type counts in the ws runtime metrics window", () => {
const logger = createLogger();
const server = createServer(logger);
const broadcastSocket = createSocket([0]);
const directSocket = createSocket([0, 0]);
attachSessionSocket(server, broadcastSocket);
server.broadcast(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
);
sendToClient(
server,
directSocket,
wrapSessionMessage({
type: "status",
payload: {
status: "ok",
message: "ready",
},
}),
);
sendToClient(server, directSocket, { type: "pong" });
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.outboundMessageTypesTop).toEqual([
["session_message", 2],
["pong", 1],
]);
expect(metrics.outboundSessionMessageTypesTop).toEqual([
["agent_stream", 1],
["status", 1],
]);
});
it("records agent_stream subtypes and top agents", () => {
const logger = createLogger();
const server = createServer(logger);
const socket = createSocket([0, 0, 0, 0]);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "hello" },
},
}),
);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "reasoning", text: "thinking" },
},
}),
);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
);
sendToClient(
server,
socket,
agentStreamMessage({
agentId: "agent-2",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "there" },
},
}),
);
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.outboundAgentStreamTypesTop).toEqual([
["timeline:assistant_message", 2],
["timeline:reasoning", 1],
["turn_completed", 1],
]);
expect(metrics.outboundAgentStreamAgentsTop).toEqual([
["agent-1", 3],
["agent-2", 1],
]);
});
it("records bufferedAmount p95 and max from samples taken after send", () => {
const logger = createLogger();
const server = createServer(logger);
const broadcastSocket = createSocket([0]);
const directSocket = createSocket([10, 50]);
const binarySocket = createSocket([100]);
attachSessionSocket(server, broadcastSocket);
server.broadcast(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
);
sendToClient(
server,
directSocket,
wrapSessionMessage({
type: "status",
payload: { status: "ok" },
}),
);
sendToClient(server, directSocket, { type: "pong" });
sendBinaryToClient(server, binarySocket, new Uint8Array([1, 2, 3]));
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.bufferedAmount).toEqual({
p95: 100,
max: 100,
});
});
it("counts binary frames without decoding", () => {
const logger = createLogger();
const server = createServer(logger);
const socket = createSocket([12, 24]);
const frame: Uint8Array = new Proxy(Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0xc0]), {
get(_target, property) {
throw new Error(`Binary frame payload was unexpectedly accessed via ${String(property)}`);
},
});
expect(() => sendBinaryToClient(server, socket, frame)).not.toThrow();
flushRuntimeMetrics(server);
const metrics = getRuntimeMetricsLog(logger);
expect(metrics.outboundBinaryFrameTypesTop).toEqual([["binary", 1]]);
});
});

View File

@@ -16,7 +16,6 @@ import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-sto
import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import {
type ServerInfoStatusPayload,
type SessionOutboundMessage,
type WorkspaceSetupSnapshot,
type WSHelloMessage,
type WSInboundMessage,
@@ -40,7 +39,7 @@ import { buildProviderRegistry, createClientsFromRegistry } from "./agent/provid
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -58,6 +57,10 @@ import {
isBearerTokenValid,
type DaemonAuthConfig,
} from "./auth.js";
import {
WebSocketRuntimeMetricsWindow,
type WebSocketRuntimeCounters,
} from "./websocket/runtime-metrics.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
@@ -270,24 +273,6 @@ interface SessionConnection {
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
}
interface WebSocketRuntimeCounters {
connectedAwaitingHello: number;
helloResumed: number;
helloNew: number;
pendingDisconnected: number;
sessionDisconnectedWaitingReconnect: number;
sessionSocketDisconnectedAttached: number;
sessionCleanup: number;
validationFailed: number;
binaryBeforeHelloRejected: number;
pendingMessageRejectedBeforeHello: number;
missingConnectionForMessage: number;
unexpectedHelloOnActiveConnection: number;
relayExternalSocketAttached: number;
originRejected: number;
hostRejected: number;
}
const SLOW_REQUEST_THRESHOLD_MS = 500;
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
const HELLO_TIMEOUT_MS = 15_000;
@@ -358,7 +343,7 @@ export class VoiceAssistantWebSocketServer {
private readonly paseoHome: string;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushService: PushService;
private readonly pushNotificationSender: PushNotificationSender;
private readonly mcpBaseUrl: string | null;
private speech!: SpeechService | null;
private terminalManager!: TerminalManager | null;
@@ -382,33 +367,7 @@ export class VoiceAssistantWebSocketServer {
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
| null;
private serverCapabilities: ServerCapabilities | undefined;
private runtimeWindowStartedAt = Date.now();
private readonly runtimeCounters: WebSocketRuntimeCounters = {
connectedAwaitingHello: 0,
helloResumed: 0,
helloNew: 0,
pendingDisconnected: 0,
sessionDisconnectedWaitingReconnect: 0,
sessionSocketDisconnectedAttached: 0,
sessionCleanup: 0,
validationFailed: 0,
binaryBeforeHelloRejected: 0,
pendingMessageRejectedBeforeHello: 0,
missingConnectionForMessage: 0,
unexpectedHelloOnActiveConnection: 0,
relayExternalSocketAttached: 0,
originRejected: 0,
hostRejected: 0,
};
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly outboundMessageCounts = new Map<string, number>();
private readonly outboundSessionMessageCounts = new Map<string, number>();
private readonly outboundAgentStreamCounts = new Map<string, number>();
private readonly outboundAgentStreamByAgentCounts = new Map<string, number>();
private readonly outboundBinaryFrameCounts = new Map<string, number>();
private readonly bufferedAmountSamples: number[] = [];
private readonly requestLatencies = new Map<string, number[]>();
private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private unsubscribeSpeechReadiness: (() => void) | null = null;
private unsubscribeDaemonConfigChange: (() => void) | null = null;
@@ -453,6 +412,7 @@ export class VoiceAssistantWebSocketServer {
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitService,
github?: GitHubService,
pushNotificationSender?: PushNotificationSender,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -529,7 +489,8 @@ export class VoiceAssistantWebSocketServer {
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.pushNotificationSender =
pushNotificationSender ?? createPushNotificationSender(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => {
void this.broadcastAgentAttention(params).catch((err) => {
@@ -666,7 +627,7 @@ export class VoiceAssistantWebSocketServer {
// WebSocket.OPEN = 1
if (ws.readyState === 1) {
ws.send(payload);
this.recordOutboundMessage(message, ws);
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
}
}
}
@@ -783,7 +744,7 @@ export class VoiceAssistantWebSocketServer {
}
try {
ws.send(JSON.stringify(message));
this.recordOutboundMessage(message, ws);
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
} catch (err) {
this.logger.warn({ err }, "ws_send_failed");
}
@@ -795,7 +756,7 @@ export class VoiceAssistantWebSocketServer {
}
try {
ws.send(frame);
this.recordOutboundBinaryFrame(ws);
this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount);
} catch (err) {
this.logger.warn({ err }, "ws_send_binary_failed");
}
@@ -1567,116 +1528,19 @@ export class VoiceAssistantWebSocketServer {
}
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
this.runtimeCounters[counter] += 1;
}
private incrementCount(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
this.runtimeMetrics.incrementCounter(counter);
}
private recordInboundMessageType(type: string): void {
this.incrementCount(this.inboundMessageCounts, type);
this.runtimeMetrics.recordInboundMessage(type);
}
private recordInboundSessionRequestType(type: string): void {
this.incrementCount(this.inboundSessionRequestCounts, type);
}
private recordOutboundMessage(message: WSOutboundMessage, ws: WebSocketLike): void {
if (message.type !== "session") {
this.incrementCount(this.outboundMessageCounts, message.type);
this.recordBufferedAmount(ws);
return;
}
this.incrementCount(this.outboundMessageCounts, "session_message");
this.incrementCount(this.outboundSessionMessageCounts, message.message.type);
if (message.message.type === "agent_stream") {
this.recordOutboundAgentStreamMessage(message.message.payload);
}
this.recordBufferedAmount(ws);
}
private recordOutboundAgentStreamMessage(
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
): void {
const { agentId, event } = payload;
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
this.incrementCount(this.outboundAgentStreamCounts, eventType);
this.incrementCount(this.outboundAgentStreamByAgentCounts, agentId);
}
private recordOutboundBinaryFrame(ws: WebSocketLike): void {
this.incrementCount(this.outboundBinaryFrameCounts, "binary");
this.recordBufferedAmount(ws);
}
private recordBufferedAmount(ws: WebSocketLike): void {
if (typeof ws.bufferedAmount !== "number") {
return;
}
this.bufferedAmountSamples.push(ws.bufferedAmount);
this.runtimeMetrics.recordInboundSessionRequest(type);
}
private recordRequestLatency(type: string, durationMs: number): void {
let latencies = this.requestLatencies.get(type);
if (!latencies) {
latencies = [];
this.requestLatencies.set(type, latencies);
}
latencies.push(durationMs);
}
private getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
private computeLatencyStats(): Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> {
const stats: Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}> = [];
for (const [type, latencies] of this.requestLatencies) {
if (latencies.length === 0) continue;
latencies.sort((a, b) => a - b);
const count = latencies.length;
const minMs = Math.round(latencies[0]);
const maxMs = Math.round(latencies[count - 1]);
const p50Ms = Math.round(latencies[Math.floor(count / 2)]);
const totalMs = Math.round(latencies.reduce((sum, v) => sum + v, 0));
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
}
stats.sort((a, b) => b.totalMs - a.totalMs);
return stats.slice(0, 15);
}
private computeBufferedAmountStats(): {
p95: number;
max: number;
} {
if (this.bufferedAmountSamples.length === 0) {
return { p95: 0, max: 0 };
}
const samples = [...this.bufferedAmountSamples].sort((a, b) => a - b);
const p95Index = Math.ceil(samples.length * 0.95) - 1;
return {
p95: samples[p95Index] ?? 0,
max: samples[samples.length - 1] ?? 0,
};
this.runtimeMetrics.recordRequestLatency(type, durationMs);
}
private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics {
@@ -1705,8 +1569,7 @@ export class VoiceAssistantWebSocketServer {
}
private flushRuntimeMetrics(options?: { final?: boolean }): void {
const now = Date.now();
const windowMs = Math.max(0, now - this.runtimeWindowStartedAt);
const runtimeMetrics = this.runtimeMetrics.snapshotAndReset();
const activeConnections = new Set<SessionConnection>(this.sessions.values()).size;
const activeSockets = this.sessions.size;
const pendingConnections = this.pendingConnections.size;
@@ -1715,13 +1578,11 @@ export class VoiceAssistantWebSocketServer {
connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null,
).length;
const sessionMetrics = this.collectSessionRuntimeMetrics();
const latencyStats = this.computeLatencyStats();
const bufferedAmountStats = this.computeBufferedAmountStats();
const agentSnapshot = this.agentManager.getMetricsSnapshot();
this.logger.info(
{
windowMs,
windowMs: runtimeMetrics.windowMs,
final: Boolean(options?.final),
sessions: {
activeConnections,
@@ -1732,37 +1593,21 @@ export class VoiceAssistantWebSocketServer {
activeSockets,
pendingConnections,
},
counters: { ...this.runtimeCounters },
inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12),
inboundSessionRequestTypesTop: this.getTopCounts(this.inboundSessionRequestCounts, 20),
outboundMessageTypesTop: this.getTopCounts(this.outboundMessageCounts, 12),
outboundSessionMessageTypesTop: this.getTopCounts(this.outboundSessionMessageCounts, 20),
outboundAgentStreamTypesTop: this.getTopCounts(this.outboundAgentStreamCounts, 20),
outboundAgentStreamAgentsTop: this.getTopCounts(this.outboundAgentStreamByAgentCounts, 20),
outboundBinaryFrameTypesTop: this.getTopCounts(this.outboundBinaryFrameCounts, 12),
bufferedAmount: bufferedAmountStats,
counters: runtimeMetrics.counters,
inboundMessageTypesTop: runtimeMetrics.inboundMessageTypesTop,
inboundSessionRequestTypesTop: runtimeMetrics.inboundSessionRequestTypesTop,
outboundMessageTypesTop: runtimeMetrics.outboundMessageTypesTop,
outboundSessionMessageTypesTop: runtimeMetrics.outboundSessionMessageTypesTop,
outboundAgentStreamTypesTop: runtimeMetrics.outboundAgentStreamTypesTop,
outboundAgentStreamAgentsTop: runtimeMetrics.outboundAgentStreamAgentsTop,
outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop,
bufferedAmount: runtimeMetrics.bufferedAmount,
runtime: sessionMetrics,
latency: latencyStats,
latency: runtimeMetrics.latency,
agents: agentSnapshot,
},
"ws_runtime_metrics",
);
for (const counter of Object.keys(this.runtimeCounters) as Array<
keyof WebSocketRuntimeCounters
>) {
this.runtimeCounters[counter] = 0;
}
this.inboundMessageCounts.clear();
this.inboundSessionRequestCounts.clear();
this.outboundMessageCounts.clear();
this.outboundSessionMessageCounts.clear();
this.outboundAgentStreamCounts.clear();
this.outboundAgentStreamByAgentCounts.clear();
this.outboundBinaryFrameCounts.clear();
this.bufferedAmountSamples.length = 0;
this.requestLatencies.clear();
this.runtimeWindowStartedAt = now;
}
private getClientActivityState(session: Session): ClientPresenceState {
@@ -1819,11 +1664,9 @@ export class VoiceAssistantWebSocketServer {
});
if (plan.shouldPush) {
const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length > 0) {
void this.pushService.sendPush(tokens, notification);
}
void this.pushNotificationSender.send(notification).catch((err) => {
this.logger.warn({ err, agentId: params.agentId }, "Failed to send push notification");
});
}
for (const [clientIndex, { ws }] of clientEntries.entries()) {

View File

@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
import { wrapSessionMessage } from "../messages.js";
import { WebSocketRuntimeMetricsWindow } from "./runtime-metrics.js";
function createMetricsWindow(): {
metrics: WebSocketRuntimeMetricsWindow;
advanceClock(ms: number): void;
} {
let now = 1_000;
return {
metrics: new WebSocketRuntimeMetricsWindow(() => now),
advanceClock(ms: number) {
now += ms;
},
};
}
function agentStreamMessage(params: {
agentId: string;
event: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"];
}): WSOutboundMessage {
return wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: params.event,
timestamp: "2026-04-17T00:00:00.000Z",
},
});
}
describe("WebSocketRuntimeMetricsWindow", () => {
it("records outbound message type counts in the runtime metrics window", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
0,
);
metrics.recordOutboundMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "ok",
message: "ready",
},
}),
0,
);
metrics.recordOutboundMessage({ type: "pong" }, 0);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.outboundMessageTypesTop).toEqual([
["session_message", 2],
["pong", 1],
]);
expect(snapshot.outboundSessionMessageTypesTop).toEqual([
["agent_stream", 1],
["status", 1],
]);
});
it("records agent_stream subtypes and top agents", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "hello" },
},
}),
0,
);
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "timeline",
provider: "codex",
item: { type: "reasoning", text: "thinking" },
},
}),
0,
);
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
0,
);
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-2",
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "there" },
},
}),
0,
);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.outboundAgentStreamTypesTop).toEqual([
["timeline:assistant_message", 2],
["timeline:reasoning", 1],
["turn_completed", 1],
]);
expect(snapshot.outboundAgentStreamAgentsTop).toEqual([
["agent-1", 3],
["agent-2", 1],
]);
});
it("records bufferedAmount p95 and max from samples taken after send", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundMessage(
agentStreamMessage({
agentId: "agent-1",
event: {
type: "turn_completed",
provider: "codex",
},
}),
0,
);
metrics.recordOutboundMessage(
wrapSessionMessage({
type: "status",
payload: { status: "ok" },
}),
10,
);
metrics.recordOutboundMessage({ type: "pong" }, 50);
metrics.recordOutboundBinaryFrame(100);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.bufferedAmount).toEqual({
p95: 100,
max: 100,
});
});
it("counts binary frames without decoding", () => {
const { metrics } = createMetricsWindow();
metrics.recordOutboundBinaryFrame(24);
const snapshot = metrics.snapshotAndReset();
expect(snapshot.outboundBinaryFrameTypesTop).toEqual([["binary", 1]]);
});
it("resets the runtime window after producing a snapshot", () => {
const { metrics, advanceClock } = createMetricsWindow();
metrics.incrementCounter("helloNew");
metrics.recordInboundMessage("session");
metrics.recordInboundSessionRequest("send");
metrics.recordRequestLatency("send", 12.4);
advanceClock(250);
const firstSnapshot = metrics.snapshotAndReset();
const secondSnapshot = metrics.snapshotAndReset();
expect(firstSnapshot.windowMs).toBe(250);
expect(firstSnapshot.counters.helloNew).toBe(1);
expect(firstSnapshot.inboundMessageTypesTop).toEqual([["session", 1]]);
expect(firstSnapshot.inboundSessionRequestTypesTop).toEqual([["send", 1]]);
expect(firstSnapshot.latency).toEqual([
{
type: "send",
count: 1,
minMs: 12,
maxMs: 12,
p50Ms: 12,
totalMs: 12,
},
]);
expect(secondSnapshot.windowMs).toBe(0);
expect(secondSnapshot.counters.helloNew).toBe(0);
expect(secondSnapshot.inboundMessageTypesTop).toEqual([]);
expect(secondSnapshot.inboundSessionRequestTypesTop).toEqual([]);
expect(secondSnapshot.latency).toEqual([]);
});
});

View File

@@ -0,0 +1,215 @@
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
export interface WebSocketRuntimeCounters {
connectedAwaitingHello: number;
helloResumed: number;
helloNew: number;
pendingDisconnected: number;
sessionDisconnectedWaitingReconnect: number;
sessionSocketDisconnectedAttached: number;
sessionCleanup: number;
validationFailed: number;
binaryBeforeHelloRejected: number;
pendingMessageRejectedBeforeHello: number;
missingConnectionForMessage: number;
unexpectedHelloOnActiveConnection: number;
relayExternalSocketAttached: number;
originRejected: number;
hostRejected: number;
}
export interface WebSocketRuntimeMetricsSnapshot {
windowMs: number;
counters: WebSocketRuntimeCounters;
inboundMessageTypesTop: Array<[string, number]>;
inboundSessionRequestTypesTop: Array<[string, number]>;
outboundMessageTypesTop: Array<[string, number]>;
outboundSessionMessageTypesTop: Array<[string, number]>;
outboundAgentStreamTypesTop: Array<[string, number]>;
outboundAgentStreamAgentsTop: Array<[string, number]>;
outboundBinaryFrameTypesTop: Array<[string, number]>;
bufferedAmount: {
p95: number;
max: number;
};
latency: Array<{
type: string;
count: number;
minMs: number;
maxMs: number;
p50Ms: number;
totalMs: number;
}>;
}
type Clock = () => number;
export class WebSocketRuntimeMetricsWindow {
private windowStartedAt: number;
private readonly counters: WebSocketRuntimeCounters = createRuntimeCounters();
private readonly inboundMessageCounts = new Map<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly outboundMessageCounts = new Map<string, number>();
private readonly outboundSessionMessageCounts = new Map<string, number>();
private readonly outboundAgentStreamCounts = new Map<string, number>();
private readonly outboundAgentStreamByAgentCounts = new Map<string, number>();
private readonly outboundBinaryFrameCounts = new Map<string, number>();
private readonly bufferedAmountSamples: number[] = [];
private readonly requestLatencies = new Map<string, number[]>();
constructor(private readonly clock: Clock = Date.now) {
this.windowStartedAt = this.clock();
}
incrementCounter(counter: keyof WebSocketRuntimeCounters): void {
this.counters[counter] += 1;
}
recordInboundMessage(type: string): void {
incrementCount(this.inboundMessageCounts, type);
}
recordInboundSessionRequest(type: string): void {
incrementCount(this.inboundSessionRequestCounts, type);
}
recordOutboundMessage(message: WSOutboundMessage, bufferedAmount?: number): void {
if (message.type !== "session") {
incrementCount(this.outboundMessageCounts, message.type);
this.recordBufferedAmount(bufferedAmount);
return;
}
incrementCount(this.outboundMessageCounts, "session_message");
incrementCount(this.outboundSessionMessageCounts, message.message.type);
if (message.message.type === "agent_stream") {
this.recordOutboundAgentStreamMessage(message.message.payload);
}
this.recordBufferedAmount(bufferedAmount);
}
recordOutboundBinaryFrame(bufferedAmount?: number): void {
incrementCount(this.outboundBinaryFrameCounts, "binary");
this.recordBufferedAmount(bufferedAmount);
}
recordRequestLatency(type: string, durationMs: number): void {
let latencies = this.requestLatencies.get(type);
if (!latencies) {
latencies = [];
this.requestLatencies.set(type, latencies);
}
latencies.push(durationMs);
}
snapshotAndReset(): WebSocketRuntimeMetricsSnapshot {
const now = this.clock();
const snapshot: WebSocketRuntimeMetricsSnapshot = {
windowMs: Math.max(0, now - this.windowStartedAt),
counters: { ...this.counters },
inboundMessageTypesTop: getTopCounts(this.inboundMessageCounts, 12),
inboundSessionRequestTypesTop: getTopCounts(this.inboundSessionRequestCounts, 20),
outboundMessageTypesTop: getTopCounts(this.outboundMessageCounts, 12),
outboundSessionMessageTypesTop: getTopCounts(this.outboundSessionMessageCounts, 20),
outboundAgentStreamTypesTop: getTopCounts(this.outboundAgentStreamCounts, 20),
outboundAgentStreamAgentsTop: getTopCounts(this.outboundAgentStreamByAgentCounts, 20),
outboundBinaryFrameTypesTop: getTopCounts(this.outboundBinaryFrameCounts, 12),
bufferedAmount: this.computeBufferedAmountStats(),
latency: this.computeLatencyStats(),
};
this.reset(now);
return snapshot;
}
private recordOutboundAgentStreamMessage(
payload: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"],
): void {
const { agentId, event } = payload;
const eventType = event.type === "timeline" ? `timeline:${event.item.type}` : event.type;
incrementCount(this.outboundAgentStreamCounts, eventType);
incrementCount(this.outboundAgentStreamByAgentCounts, agentId);
}
private recordBufferedAmount(bufferedAmount: number | undefined): void {
if (typeof bufferedAmount !== "number") {
return;
}
this.bufferedAmountSamples.push(bufferedAmount);
}
private computeLatencyStats(): WebSocketRuntimeMetricsSnapshot["latency"] {
const stats: WebSocketRuntimeMetricsSnapshot["latency"] = [];
for (const [type, latencies] of this.requestLatencies) {
if (latencies.length === 0) continue;
const sortedLatencies = [...latencies].sort((a, b) => a - b);
const count = sortedLatencies.length;
const minMs = Math.round(sortedLatencies[0]);
const maxMs = Math.round(sortedLatencies[count - 1]);
const p50Ms = Math.round(sortedLatencies[Math.floor(count / 2)]);
const totalMs = Math.round(sortedLatencies.reduce((sum, value) => sum + value, 0));
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
}
stats.sort((a, b) => b.totalMs - a.totalMs);
return stats.slice(0, 15);
}
private computeBufferedAmountStats(): WebSocketRuntimeMetricsSnapshot["bufferedAmount"] {
if (this.bufferedAmountSamples.length === 0) {
return { p95: 0, max: 0 };
}
const samples = [...this.bufferedAmountSamples].sort((a, b) => a - b);
const p95Index = Math.ceil(samples.length * 0.95) - 1;
return {
p95: samples[p95Index] ?? 0,
max: samples[samples.length - 1] ?? 0,
};
}
private reset(windowStartedAt: number): void {
for (const counter of Object.keys(this.counters) as Array<keyof WebSocketRuntimeCounters>) {
this.counters[counter] = 0;
}
this.inboundMessageCounts.clear();
this.inboundSessionRequestCounts.clear();
this.outboundMessageCounts.clear();
this.outboundSessionMessageCounts.clear();
this.outboundAgentStreamCounts.clear();
this.outboundAgentStreamByAgentCounts.clear();
this.outboundBinaryFrameCounts.clear();
this.bufferedAmountSamples.length = 0;
this.requestLatencies.clear();
this.windowStartedAt = windowStartedAt;
}
}
function createRuntimeCounters(): WebSocketRuntimeCounters {
return {
connectedAwaitingHello: 0,
helloResumed: 0,
helloNew: 0,
pendingDisconnected: 0,
sessionDisconnectedWaitingReconnect: 0,
sessionSocketDisconnectedAttached: 0,
sessionCleanup: 0,
validationFailed: 0,
binaryBeforeHelloRejected: 0,
pendingMessageRejectedBeforeHello: 0,
missingConnectionForMessage: 0,
unexpectedHelloOnActiveConnection: 0,
relayExternalSocketAttached: 0,
originRejected: 0,
hostRejected: 0,
};
}
function incrementCount(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
}
function getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}

View File

@@ -13,11 +13,6 @@ import { isPlatform } from "../test-utils/platform.js";
const REPO_CWD = path.resolve("/tmp/repo");
interface ServiceInternals {
workingTreeWatchTargets: Map<string, { fallbackRefreshInterval: unknown; repoWatchPath: string }>;
scheduleWorkspaceRefresh(cwd: string, options: { force: boolean; reason: string }): void;
}
function createLogger() {
const logger = {
child: () => logger,
@@ -740,9 +735,8 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService({ watch });
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(REPO_CWD);
expect(target?.fallbackRefreshInterval).not.toBeNull();
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
service.dispose();
@@ -762,7 +756,6 @@ describe("WorkspaceGitServiceImpl", () => {
const plainCwd = path.join(os.tmpdir(), "plain");
const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn());
const target = (service as unknown as ServiceInternals).workingTreeWatchTargets.get(plainCwd);
expect(subscription.repoRoot).toBeNull();
const expectedRecursive = process.platform !== "linux";
@@ -771,14 +764,13 @@ describe("WorkspaceGitServiceImpl", () => {
{ recursive: expectedRecursive },
expect.any(Function),
);
expect(target?.repoWatchPath).toBe(plainCwd);
expect(target?.fallbackRefreshInterval).not.toBeNull();
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
service.dispose();
});
test("working tree changes notify listeners and schedule workspace refresh", async () => {
test("working tree changes notify watch listeners immediately", async () => {
const watchCallbacks: Array<() => void> = [];
const watch = vi.fn(
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
@@ -787,7 +779,6 @@ describe("WorkspaceGitServiceImpl", () => {
},
);
const service = createService({ watch });
const refreshSpy = vi.spyOn(service as unknown as ServiceInternals, "scheduleWorkspaceRefresh");
const listener = vi.fn();
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener);
@@ -796,10 +787,6 @@ describe("WorkspaceGitServiceImpl", () => {
watchCallbacks[0]?.();
expect(listener).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith(REPO_CWD, {
force: true,
reason: "working-tree-watch",
});
subscription.unsubscribe();
service.dispose();

View File

@@ -40,7 +40,7 @@ describe("connection offer", () => {
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
});
it("defaults relay TLS to false when absent", () => {
it("leaves relay TLS unset when absent", () => {
expect(
ConnectionOfferSchema.parse({
v: 2,
@@ -52,7 +52,7 @@ describe("connection offer", () => {
v: 2,
serverId: "server-123",
daemonPublicKeyB64: "pubkey",
relay: { endpoint: "relay.example.com:80", useTls: false },
relay: { endpoint: "relay.example.com:80" },
});
});

View File

@@ -12,7 +12,7 @@ export const ConnectionOfferV2Schema = z.object({
daemonPublicKeyB64: z.string().min(1),
relay: z.object({
endpoint: z.string().min(1),
useTls: z.boolean().optional().default(false),
useTls: z.boolean().optional(),
}),
});

View File

@@ -0,0 +1,134 @@
import { readFile, writeFile } from "node:fs/promises";
import type { Task, TaskStatus } from "./types.js";
function serializeTask(task: Task): string {
const frontmatterLines = [
"---",
`id: ${task.id}`,
`title: ${task.title}`,
`status: ${task.status}`,
`deps: [${task.deps.join(", ")}]`,
`created: ${task.created}`,
];
if (task.parentId) {
frontmatterLines.push(`parentId: ${task.parentId}`);
}
if (task.assignee) {
frontmatterLines.push(`assignee: ${task.assignee}`);
}
if (task.priority !== undefined) {
frontmatterLines.push(`priority: ${task.priority}`);
}
frontmatterLines.push("---");
const frontmatter = frontmatterLines.join("\n");
let content = "";
if (task.body) {
content += task.body + "\n";
}
if (task.acceptanceCriteria.length > 0) {
content += "\n## Acceptance Criteria\n\n";
for (const criterion of task.acceptanceCriteria) {
content += `- [ ] ${criterion}\n`;
}
}
if (task.notes.length > 0) {
content += "\n## Notes\n";
for (const note of task.notes) {
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
}
}
return frontmatter + "\n\n" + content;
}
function parseTask(content: string): Task {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
if (!frontmatterMatch) {
throw new Error("Invalid task file: missing frontmatter");
}
const frontmatter = frontmatterMatch[1];
const fileBody = content.slice(frontmatterMatch[0].length);
const getValue = (key: string): string => {
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
return match ? match[1] : "";
};
const depsStr = getValue("deps");
const depsMatch = depsStr.match(/\[(.*)\]/);
const deps =
depsMatch && depsMatch[1].trim()
? depsMatch[1]
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [];
const notes: Task["notes"] = [];
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
if (notesSection) {
const noteMatches = notesSection[1].matchAll(
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g,
);
for (const match of noteMatches) {
notes.push({
timestamp: match[1],
content: match[2].trim(),
});
}
}
const acceptanceCriteria: string[] = [];
const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/);
if (criteriaSection) {
const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm);
for (const match of criteriaMatches) {
acceptanceCriteria.push(match[1].trim());
}
}
let taskBody = fileBody;
const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/);
if (firstSection) {
taskBody = fileBody.slice(0, firstSection.index).trim();
}
taskBody = taskBody.trim();
const assignee = getValue("assignee");
const parentId = getValue("parentId");
const priorityStr = getValue("priority");
const priority = priorityStr ? parseInt(priorityStr, 10) : undefined;
return {
id: getValue("id"),
title: getValue("title"),
status: getValue("status") as TaskStatus,
deps,
parentId: parentId || undefined,
body: taskBody,
acceptanceCriteria,
notes,
created: getValue("created") || new Date().toISOString(),
assignee: assignee || undefined,
priority,
raw: content,
};
}
export async function readTaskDocument(filePath: string): Promise<Task> {
const content = await readFile(filePath, "utf-8");
return parseTask(content);
}
export async function writeTaskDocument(filePath: string, task: Task): Promise<void> {
await writeFile(filePath, serializeTask(task), "utf-8");
}

View File

@@ -1,144 +1,19 @@
import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { readdir, mkdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
import type { Task, TaskStore, CreateTaskOptions } from "./types.js";
import {
isBlockedTask,
isReadyTask,
loadScopedTaskGraph,
sortByPriorityThenCreated,
} from "./task-graph.js";
import { readTaskDocument, writeTaskDocument } from "./task-document.js";
function generateId(): string {
return randomBytes(4).toString("hex");
}
function serializeTask(task: Task): string {
const frontmatterLines = [
"---",
`id: ${task.id}`,
`title: ${task.title}`,
`status: ${task.status}`,
`deps: [${task.deps.join(", ")}]`,
`created: ${task.created}`,
];
if (task.parentId) {
frontmatterLines.push(`parentId: ${task.parentId}`);
}
if (task.assignee) {
frontmatterLines.push(`assignee: ${task.assignee}`);
}
if (task.priority !== undefined) {
frontmatterLines.push(`priority: ${task.priority}`);
}
frontmatterLines.push("---");
const frontmatter = frontmatterLines.join("\n");
let content = "";
if (task.body) {
content += task.body + "\n";
}
if (task.acceptanceCriteria.length > 0) {
content += "\n## Acceptance Criteria\n\n";
for (const criterion of task.acceptanceCriteria) {
content += `- [ ] ${criterion}\n`;
}
}
if (task.notes.length > 0) {
content += "\n## Notes\n";
for (const note of task.notes) {
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
}
}
return frontmatter + "\n\n" + content;
}
function parseTask(content: string): Task {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
if (!frontmatterMatch) {
throw new Error("Invalid task file: missing frontmatter");
}
const frontmatter = frontmatterMatch[1];
const fileBody = content.slice(frontmatterMatch[0].length);
const getValue = (key: string): string => {
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
return match ? match[1] : "";
};
const depsStr = getValue("deps");
const depsMatch = depsStr.match(/\[(.*)\]/);
const deps =
depsMatch && depsMatch[1].trim()
? depsMatch[1]
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [];
// Parse notes from body
const notes: Task["notes"] = [];
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
if (notesSection) {
const noteMatches = notesSection[1].matchAll(
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g,
);
for (const match of noteMatches) {
notes.push({
timestamp: match[1],
content: match[2].trim(),
});
}
}
// Parse acceptance criteria
const acceptanceCriteria: string[] = [];
const criteriaSection = fileBody.match(/## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/);
if (criteriaSection) {
const criteriaMatches = criteriaSection[1].matchAll(/- \[[ x]\] (.+)$/gm);
for (const match of criteriaMatches) {
acceptanceCriteria.push(match[1].trim());
}
}
// Body is everything before ## Acceptance Criteria or ## Notes
let taskBody = fileBody;
const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/);
if (firstSection) {
taskBody = fileBody.slice(0, firstSection.index).trim();
}
taskBody = taskBody.trim();
const assignee = getValue("assignee");
const parentId = getValue("parentId");
const priorityStr = getValue("priority");
const priority = priorityStr ? parseInt(priorityStr, 10) : undefined;
return {
id: getValue("id"),
title: getValue("title"),
status: getValue("status") as TaskStatus,
deps,
parentId: parentId || undefined,
body: taskBody,
acceptanceCriteria,
notes,
created: getValue("created") || new Date().toISOString(),
assignee: assignee || undefined,
priority,
raw: content,
};
}
export class FileTaskStore implements TaskStore {
constructor(private readonly dir: string) {}
@@ -152,8 +27,7 @@ export class FileTaskStore implements TaskStore {
private async readTask(id: string): Promise<Task | null> {
try {
const content = await readFile(this.taskPath(id), "utf-8");
return parseTask(content);
return await readTaskDocument(this.taskPath(id));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
@@ -164,7 +38,7 @@ export class FileTaskStore implements TaskStore {
private async writeTask(task: Task): Promise<void> {
await this.ensureDir();
await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8");
await writeTaskDocument(this.taskPath(task.id), task);
}
async list(): Promise<Task[]> {

View File

@@ -54,6 +54,7 @@ export function runGitCommand(
const child = spawnProcess("git", args, {
cwd: options.cwd,
envOverlay: mergeEnvOverlays(options.env, options.envOverlay),
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -0,0 +1,42 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "./run-git-command.js";
const tempDirs: string[] = [];
function makeTempRepo(): string {
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
tempDirs.push(repo);
return repo;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
describe("runGitCommand shell behavior", () => {
it("passes git arguments directly instead of through the platform shell", async () => {
const repo = makeTempRepo();
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
const expandedName = "expanded-by-cmd";
await runGitCommand(["init"], { cwd: repo });
writeFileSync(path.join(repo, literalName), "literal\n");
writeFileSync(path.join(repo, expandedName), "expanded\n");
await runGitCommand(["add", literalName, expandedName], { cwd: repo });
const result = await runGitCommand(["ls-files", "--error-unmatch", literalName], {
cwd: repo,
envOverlay: {
PASEO_GIT_SHELL_SENTINEL: expandedName,
},
});
expect(result.stdout.trim()).toBe(literalName);
});
});

View File

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