Compare commits

...

16 Commits

Author SHA1 Message Date
Mohamed Boudra
a03da6774a chore(release): cut 0.1.11 2026-02-17 16:22:01 +07:00
Mohamed Boudra
7c5bbc8048 chore(release): cut 0.1.10 2026-02-17 16:19:52 +07:00
Mohamed Boudra
76e6ad89d2 fix(server): publish daemon-runner sherpa runtime module 2026-02-17 16:19:38 +07:00
Mohamed Boudra
93d6eb611e chore(release): cut 0.1.9 2026-02-17 14:53:22 +07:00
Mohamed Boudra
ce5d9d6dc8 refactor: unify structured generation pipeline 2026-02-17 14:52:58 +07:00
Mohamed Boudra
abc146b186 chore(release): cut 0.1.8 2026-02-17 13:45:15 +07:00
Mohamed Boudra
4300750001 chore(release): cut 0.1.8 2026-02-17 13:32:13 +07:00
Mohamed Boudra
0af2d8989a fix(web): route tool-detail wheel events at scroll edges 2026-02-17 13:20:49 +07:00
Mohamed Boudra
34e35621f0 simplify speech bootstrap and clean daemon startup locking 2026-02-17 12:16:02 +07:00
Mohamed Boudra
efde557d6f Fix stuck send-while-running processing recovery 2026-02-17 10:43:20 +07:00
Mohamed Boudra
50b99584a1 fix(app): update combobox options behavior and tests 2026-02-17 10:38:31 +07:00
Mohamed Boudra
459af22670 Add cross-platform confirm dialog wrapper for daemon restart 2026-02-17 10:04:58 +07:00
Mohamed Boudra
12ba65d6e7 fix(app): guard tauri listener cleanup to prevent uncaught unlisten errors 2026-02-17 09:43:22 +07:00
Mohamed Boudra
bb6d30e4f8 fix(server): preserve claude session identity on agent reload 2026-02-17 09:43:19 +07:00
Mohamed Boudra
82a5723240 docs: add changelog for releases 0.1.1 through 0.1.7 2026-02-17 09:00:26 +07:00
Mohamed Boudra
949ac8decb website: update hero copy to on your machine 2026-02-17 00:20:40 +07:00
67 changed files with 2420 additions and 469 deletions

99
CHANGELOG.md Normal file
View File

@@ -0,0 +1,99 @@
# Changelog
## [0.1.9] - 2026-02-17
### Improved
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
- Reused provider availability checks for structured generation fallback selection.
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
### Fixed
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
## [0.1.8] - 2026-02-17
### Added
- Added a cross-platform confirm dialog flow for daemon restarts.
### Improved
- Simplified local speech bootstrap and daemon startup locking behavior.
- Updated website hero copy to emphasize local execution.
### Fixed
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed web tool-detail wheel event routing at scroll edges.
## [0.1.7] - 2026-02-16
### Added
- Improved agent workspace flows with better directory suggestions.
- Added iOS TestFlight and Android app access request forms on the website.
### Improved
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
- Improved website app download and update guidance.
### Fixed
- Prevented an initial desktop combobox `0,0` position flash.
- Fixed CLI version output issues.
- Hardened server runtime loading for local speech dependencies.
## [0.1.6] - 2026-02-16
### Notes
- No major visible product changes in this patch release.
## [0.1.5] - 2026-02-16
### Added
- Added terminal reattach support and better worktree terminal handling.
- Added global keyboard shortcut help in the app.
- Added sidebar host filtering and improved agent workflow controls.
### Improved
- Improved worktree setup visibility by streaming setup progress.
- Improved terminal streaming reliability and lifecycle handling.
- Preserved explorer tab state so context survives navigation better.
## [0.1.4] - 2026-02-14
### Added
- Added voice capability status reporting in the client.
- Added background local speech model downloads with runtime gating.
- Added adaptive dictation finish timing based on server-provided budgets.
- Added relay reconnect behavior with grace periods and branch suggestions.
### Improved
- Improved connection selection and agent hydration reliability.
- Improved timeline loading with cursor-based fetch behavior.
- Improved first-run experience by bootstrapping a default localhost connection.
- Improved inline code rendering by auto-linkifying URLs.
### Fixed
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
- Fixed stale relay client timer behavior.
- Fixed unnecessary git diff header auto-scroll on collapse.
## [0.1.3] - 2026-02-12
### Added
- Added CLI onboarding command.
- Added CLI `--output-schema` support for structured agent output.
- Added CLI agent metadata update support for names and labels.
- Added provider availability detection with normalization of legacy default model IDs.
### Improved
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
- Added better voice interrupt handling with a speech-start grace period.
- Improved CLI defaults to list all non-archived agents by default.
- Improved website UX with clearer install CTA and privacy policy access.
### Fixed
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
## [0.1.2] - 2026-02-11
### Notes
- No major visible product changes in this patch release.
## [0.1.1] - 2026-02-11
### Added
- Initial `0.1.x` release line.

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.7",
"version": "0.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.7",
"version": "0.1.11",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -20344,7 +20344,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.7",
"version": "0.1.11",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -20352,7 +20352,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.7",
"@getpaseo/server": "0.1.11",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -20456,11 +20456,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.7",
"version": "0.1.11",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.7",
"@getpaseo/server": "0.1.7",
"@getpaseo/relay": "0.1.11",
"@getpaseo/server": "0.1.11",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -20510,14 +20510,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.7",
"version": "0.1.11",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.7",
"version": "0.1.11",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -20533,12 +20533,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.7",
"version": "0.1.11",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.7",
"@getpaseo/relay": "0.1.11",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20890,7 +20890,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.7",
"version": "0.1.11",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.7",
"version": "0.1.11",
"private": true,
"workspaces": [
"packages/server",

View File

@@ -156,7 +156,6 @@ export default async function globalSetup() {
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
NODE_ENV: 'development',
},
stdio: ['ignore', 'pipe', 'pipe'],

View File

@@ -173,7 +173,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const input = page.getByRole('textbox', { name: '/path/to/project' });
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
const closeBottomSheet = async () => {
@@ -218,29 +219,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeWorktreeSheetIfOpen();
if (!(await input.isVisible())) {
const pickerInputVisible = async () =>
(await directorySearchInput.isVisible().catch(() => false)) ||
(await legacyInput.isVisible().catch(() => false));
if (!(await pickerInputVisible())) {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
if (!(await input.isVisible())) {
if (!(await pickerInputVisible())) {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect(input).toBeVisible();
await expect
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, '');
await input.fill(trimmedDirectory);
await input.press('Enter');
const activeInput =
(await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
const useOption = page.getByTestId('working-directory-custom-option').first();
if (await useOption.isVisible().catch(() => false)) {
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
await useOption.click({ force: true });
await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
.first();
if (await plainOption.isVisible().catch(() => false)) {
await plainOption.click({ force: true });
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press('Enter');
}
} else {
// Legacy path picker fallback.
await activeInput.press('Enter');
}
// Wait for the sheet to close after clicking "Use"
await expect(input).not.toBeVisible({ timeout: 10000 });
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) {

View File

@@ -0,0 +1,158 @@
import { expect, test } from "./fixtures";
import { gotoHome } from "./helpers/app";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
await gotoHome(page);
const workingDirectorySelect = page
.locator('[data-testid="working-directory-select"]:visible')
.first();
await expect(workingDirectorySelect).toBeVisible();
await workingDirectorySelect.click({ force: true });
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
await expect(searchInput).toBeVisible();
await page.evaluate(() => {
const trigger = document.querySelector('[data-testid="working-directory-select"]');
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
if (!(trigger instanceof HTMLElement)) {
throw new Error("Missing working-directory-select trigger.");
}
if (!(searchInput instanceof HTMLInputElement)) {
throw new Error("Missing working directory search input.");
}
if (!(container instanceof HTMLElement)) {
throw new Error("Missing combobox desktop container.");
}
const state = {
samples: 0,
underTriggerSamples: 0,
emptyWhileSearchingSamples: 0,
minSearchDelta: Number.POSITIVE_INFINITY,
maxSearchDelta: Number.NEGATIVE_INFINITY,
minContainerDelta: Number.POSITIVE_INFINITY,
maxContainerDelta: Number.NEGATIVE_INFINITY,
logs: [] as Array<{
reason: string;
query: string;
searchDelta: number;
containerDelta: number;
hasEmpty: boolean;
containerTop: number;
containerBottom: number;
triggerTop: number;
searchBottom: number;
}>,
};
const sample = (reason: string) => {
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
return;
}
const query = searchInput.value.trim();
if (!query) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const searchRect = searchInput.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
const searchDelta = searchRect.bottom - triggerRect.top;
const containerDelta = containerRect.bottom - triggerRect.top;
state.samples += 1;
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
if (searchDelta > 2 || containerDelta > 2) {
state.underTriggerSamples += 1;
}
if (hasEmpty) {
state.emptyWhileSearchingSamples += 1;
}
if (state.logs.length < 80) {
state.logs.push({
reason,
query,
searchDelta,
containerDelta,
hasEmpty,
containerTop: containerRect.top,
containerBottom: containerRect.bottom,
triggerTop: triggerRect.top,
searchBottom: searchRect.bottom,
});
}
};
const mutationObserver = new MutationObserver(() => sample("mutation"));
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
const resizeObserver = new ResizeObserver(() => sample("resize"));
resizeObserver.observe(container);
resizeObserver.observe(searchInput);
let rafId = 0;
const loop = () => {
sample("raf");
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
(window as any).__paseoComboboxObserver = {
stop: () => {
cancelAnimationFrame(rafId);
mutationObserver.disconnect();
resizeObserver.disconnect();
sample("stop");
return {
...state,
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
};
},
};
});
const queries = [
"/tmp/paseo-stability-a",
"/tmp/paseo-stability-ab",
"/tmp/paseo-stability-abc",
"/tmp/paseo-stability-longer-branch",
"/tmp/paseo-stability-z",
];
for (const query of queries) {
await searchInput.fill("");
await searchInput.type(query, { delay: 20 });
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
await expect(customOption).toBeVisible();
await page.waitForTimeout(100);
}
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
const debug = JSON.stringify(stats.logs.slice(-10));
expect(stats.samples, debug).toBeGreaterThan(20);
expect(stats.underTriggerSamples, debug).toBe(0);
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
});

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.7",
"version": "0.1.11",
"private": true,
"scripts": {
"start": "expo start",
@@ -30,7 +30,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.7",
"@getpaseo/server": "0.1.11",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -885,6 +885,9 @@ export function WorkingDirectoryDropdown({
onSelect={onSelectPath}
searchPlaceholder="Search directories..."
emptyText={emptyText}
allowCustomValue
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isOpen}

View File

@@ -233,15 +233,38 @@ export function AgentInputArea({
}, [onSubmitMessage]);
const isAgentRunning = agent?.status === "running";
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
const prevIsAgentRunningRef = useRef(isAgentRunning);
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
useEffect(() => {
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
if (agentUpdatedAtMs < previousUpdatedAt) {
return;
}
const wasRunning = prevIsAgentRunningRef.current;
let shouldClearProcessing = false;
if (isProcessing) {
const hasEnteredRunning = !wasRunning && isAgentRunning;
const hasFreshRunningUpdateWhileRunning =
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
const hasStoppedRunning = wasRunning && !isAgentRunning;
shouldClearProcessing =
hasEnteredRunning ||
hasFreshRunningUpdateWhileRunning ||
hasStoppedRunning;
}
prevIsAgentRunningRef.current = isAgentRunning;
if (!wasRunning && isAgentRunning && isProcessing) {
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
if (shouldClearProcessing) {
setIsProcessing(false);
}
}, [isAgentRunning, isProcessing]);
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
const updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {

View File

@@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
headerLine: {
backgroundColor: theme.colors.surface1,

View File

@@ -117,6 +117,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
}
`;
let webToolCallShimmerRegistered = false;
const SCROLL_EDGE_EPSILON = 0.5;
type ScrollAxis = "x" | "y";
function ensureWebToolCallShimmerKeyframes() {
if (Platform.OS !== "web") {
@@ -143,6 +145,113 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
}
if (target instanceof Node && target.parentElement) {
return target.parentElement;
}
return fallback;
}
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
): boolean {
if (delta === 0) {
return false;
}
const computedStyle = window.getComputedStyle(element);
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
const isScrollableOverflow =
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
if (!isScrollableOverflow) {
return false;
}
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false;
}
if (delta > 0) {
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
}
return scrollPosition > SCROLL_EDGE_EPSILON;
}
function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
): boolean {
if (delta === 0) {
return false;
}
let current: HTMLElement | null = startElement;
while (current) {
if (canElementScrollInDirection(current, axis, delta)) {
return true;
}
if (current === detailRoot) {
break;
}
current = current.parentElement;
}
return false;
}
function shouldStopDetailWheelPropagation(
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY;
const horizontalDelta =
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
if (!hasVerticalIntent && !hasHorizontalIntent) {
return false;
}
const canScrollVertically = hasVerticalIntent
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
return isVerticalDominant
? canScrollVertically || canScrollHorizontally
: canScrollHorizontally || canScrollVertically;
}
if (hasVerticalIntent) {
return canScrollVertically;
}
return canScrollHorizontally;
}
const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
@@ -1121,6 +1230,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
@@ -1240,6 +1350,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
shimmerTranslateX,
]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
return;
}
const node = detailWrapperRef.current as unknown as HTMLElement | null;
if (!node || typeof node.addEventListener !== "function") {
return;
}
const stopWheelPropagation = (event: WheelEvent) => {
if (shouldStopDetailWheelPropagation(node, event)) {
event.stopPropagation();
}
};
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => {
node.removeEventListener("wheel", stopWheelPropagation);
};
}, [isExpanded, hasDetailContent]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
}));
@@ -1476,6 +1608,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Pressable>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
style={expandableBadgeStylesheet.detailWrapper}
onHoverIn={() => onDetailHoverChange?.(true)}
onHoverOut={() => onDetailHoverChange?.(false)}

View File

@@ -393,6 +393,12 @@ const styles = StyleSheet.create((theme) => {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
shellPrompt: {
color: theme.colors.foregroundMuted,

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
} from "./combobox-options";
describe("buildVisibleComboboxOptions", () => {
const options = [
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
];
it("keeps a custom row visible while searching with no matches", () => {
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "/tmp/new-project",
searchable: true,
allowCustomValue: true,
customValuePrefix: "",
customValueKind: "directory",
});
expect(visible).toHaveLength(1);
expect(visible[0]).toEqual({
id: "/tmp/new-project",
label: "/tmp/new-project",
description: undefined,
kind: "directory",
});
});
it("does not duplicate a row when search exactly matches an existing option", () => {
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "/Users/me/project-a",
searchable: true,
allowCustomValue: true,
customValuePrefix: "",
customValueKind: "directory",
});
expect(visible).toEqual([
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" },
]);
});
});
describe("combobox above-search ordering", () => {
const visible = [
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
];
it("renders first logical option closest to the search box in above-search mode", () => {
const ordered = orderVisibleComboboxOptions(visible, "above-search");
expect(ordered.map((option) => option.id)).toEqual([
"/Users/me/project-b",
"/Users/me/project-a",
"/tmp/new-project",
]);
expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2);
});
it("keeps normal top-down order in below-search mode", () => {
const ordered = orderVisibleComboboxOptions(visible, "below-search");
expect(ordered.map((option) => option.id)).toEqual([
"/tmp/new-project",
"/Users/me/project-a",
"/Users/me/project-b",
]);
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});

View File

@@ -0,0 +1,95 @@
export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
kind?: "directory";
}
export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
customValuePrefix: string;
customValueDescription?: string;
customValueKind?: "directory";
}
export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
}): boolean {
const sanitizedSearchValue = input.searchQuery.trim();
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false;
}
return !input.options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
)
: input.options;
const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({
options: input.options,
searchQuery: input.searchQuery,
searchable: input.searchable,
allowCustomValue: input.allowCustomValue,
});
const visibleOptions: ComboboxOptionModel[] = [];
if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim();
const customLabel =
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
visibleOptions.push({
id: sanitizedSearchValue,
label: customLabel,
description: input.customValueDescription,
kind: input.customValueKind,
});
}
visibleOptions.push(...filteredOptions);
return visibleOptions;
}
export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[],
optionsPosition: "below-search" | "above-search"
): ComboboxOptionModel[] {
if (optionsPosition !== "above-search") {
return visibleOptions;
}
return [...visibleOptions].reverse();
}
export function getComboboxFallbackIndex(
itemCount: number,
optionsPosition: "below-search" | "above-search"
): number {
if (itemCount <= 0) {
return -1;
}
return optionsPosition === "above-search" ? itemCount - 1 : 0;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
@@ -9,6 +9,7 @@ import {
ScrollView,
Platform,
StatusBar,
useWindowDimensions,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
@@ -22,15 +23,17 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, Folder, Search } from "lucide-react-native";
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
const IS_WEB = Platform.OS === "web";
export interface ComboboxOption {
id: string;
label: string;
description?: string;
kind?: "directory";
}
export type ComboboxOption = ComboboxOptionModel;
export interface ComboboxProps {
options: ComboboxOption[];
@@ -181,7 +184,7 @@ export function ComboboxItem({
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
return <Text style={styles.emptyText}>{children}</Text>;
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>;
}
export function Combobox({
@@ -210,13 +213,18 @@ export function Combobox({
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition =
isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
@@ -262,7 +270,7 @@ export function Combobox({
() => [
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
shift({ padding: collisionPadding }),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
@@ -280,7 +288,7 @@ export function Combobox({
},
}),
],
[collisionPadding]
[collisionPadding, isDesktopAboveSearch]
);
const { refs, floatingStyles, update } = useFloating({
@@ -305,30 +313,49 @@ export function Combobox({
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
const referenceEl = anchorRef.current;
if (!referenceEl) {
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
}
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
});
}, [anchorRef, isMobile, isOpen]);
const measure = () => {
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
});
};
measure();
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
const floatingTop = toNumericStyleValue(floatingStyles.top);
const floatingLeft = toNumericStyleValue(floatingStyles.left);
const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null;
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingTop !== null &&
floatingLeft !== null &&
(floatingTop !== 0 || floatingLeft !== 0 || referenceAtOrigin);
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent =
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles;
useEffect(() => {
if (!isMobile) return;
@@ -361,76 +388,75 @@ export function Combobox({
);
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const filteredOptions = useMemo(() => {
if (!normalizedSearch) {
return options;
}
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
);
}, [options, normalizedSearch]);
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
searchable &&
allowCustomValue &&
sanitizedSearchValue.length > 0 &&
!options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
const showCustomOption = useMemo(
() =>
shouldShowCustomComboboxOption({
options,
searchQuery,
searchable,
allowCustomValue,
}),
[allowCustomValue, options, searchQuery, searchable]
);
const visibleOptions = useMemo(() => {
const next: Array<{
id: string;
label: string;
description?: string;
kind?: "directory";
}> = [];
const visibleOptions = useMemo(
() =>
buildVisibleComboboxOptions({
options,
searchQuery,
searchable,
allowCustomValue,
customValuePrefix,
customValueDescription,
customValueKind,
}),
[
allowCustomValue,
customValueDescription,
customValueKind,
customValuePrefix,
options,
searchQuery,
searchable,
]
);
if (showCustomOption) {
const trimmedPrefix = customValuePrefix.trim();
const customLabel =
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
next.push({
id: sanitizedSearchValue,
label: customLabel,
description: customValueDescription,
kind: customValueKind,
});
const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions]
);
const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== "above-search") {
return;
}
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
});
}, [effectiveOptionsPosition, isMobile]);
for (const opt of filteredOptions) {
next.push({
id: opt.id,
label: opt.label,
description: opt.description,
kind: opt.kind,
});
const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) {
return;
}
pinDesktopOptionsToBottom();
}, [isOpen, pinDesktopOptionsToBottom]);
return next;
}, [
customValueDescription,
customValueKind,
customValuePrefix,
filteredOptions,
sanitizedSearchValue,
showCustomOption,
]);
const orderedVisibleOptions = useMemo(() => {
if (effectiveOptionsPosition !== "above-search") {
return visibleOptions;
useEffect(() => {
if (!isOpen) {
return;
}
return [...visibleOptions].reverse();
}, [effectiveOptionsPosition, visibleOptions]);
pinDesktopOptionsToBottom();
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
useLayoutEffect(() => {
if (!isOpen || isMobile) {
return;
}
void update();
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
useEffect(() => {
if (!isOpen) return;
@@ -441,8 +467,10 @@ export function Combobox({
return;
}
const fallbackIndex =
effectiveOptionsPosition === "above-search" ? orderedVisibleOptions.length - 1 : 0;
const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length,
effectiveOptionsPosition
);
if (normalizedSearch) {
setActiveIndex(fallbackIndex);
@@ -607,6 +635,7 @@ export function Combobox({
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View
testID="combobox-desktop-container"
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={[
@@ -616,7 +645,7 @@ export function Combobox({
minWidth: referenceWidth ?? 200,
maxWidth: 400,
},
floatingStyles,
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
]}
@@ -635,15 +664,32 @@ export function Combobox({
</ScrollView>
) : (
<>
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={desktopOptionsScrollRef}
contentContainerStyle={[
styles.desktopScrollContent,
styles.desktopScrollContentAboveSearch,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
onContentSizeChange={handleDesktopOptionsContentSizeChange}
>
{optionsList}
</ScrollView>
) : null}
{searchable ? searchInput : null}
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
{effectiveOptionsPosition === "below-search" ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
) : null}
</>
)}
</Animated.View>
@@ -773,9 +819,14 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
desktopScroll: {
maxHeight: 400,
flexShrink: 1,
minHeight: 0,
},
desktopScrollContent: {
paddingVertical: theme.spacing[1],
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: "flex-end",
},
}));

View File

@@ -128,6 +128,21 @@ export function useFileDropZone({
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup(unlisten?: () => void | Promise<void>) {
if (didCleanup) return;
const cleanupFn = unlisten ?? cleanup;
if (!cleanupFn) return;
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
});
} catch (error) {
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
}
}
async function setupTauriDragDrop(): Promise<boolean> {
if (!isTauriEnvironment()) {
@@ -171,7 +186,7 @@ export function useFileDropZone({
);
if (disposed) {
unlisten();
runCleanup(unlisten);
return true;
}
@@ -271,7 +286,7 @@ export function useFileDropZone({
return () => {
disposed = true;
cleanup?.();
runCleanup();
};
}, [disabled]);

View File

@@ -1191,6 +1191,9 @@ export function DraftAgentScreen({
onSearchQueryChange={setWorkingDirSearchQuery}
searchPlaceholder="Search directories..."
emptyText={workingDirEmptyText}
allowCustomValue
customValuePrefix=""
customValueKind="directory"
optionsPosition="above-search"
title="Working directory"
open={isWorkingDirOpen}

View File

@@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { theme as defaultTheme } from "@/styles/theme";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
@@ -982,29 +983,21 @@ function HostDetailModal({
return;
}
if (Platform.OS === "web") {
const hasBrowserConfirm =
typeof globalThis !== "undefined" &&
typeof (globalThis as any).confirm === "function";
const confirmed = hasBrowserConfirm
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
: true;
if (confirmed) {
beginServerRestart();
void confirmDialog({
title: `Restart ${host.label}`,
message: restartConfirmationMessage,
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
}).then((confirmed) => {
if (!confirmed) {
return;
}
return;
}
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
{ text: "Cancel", style: "cancel" },
{
text: "Restart",
style: "destructive",
onPress: beginServerRestart,
},
]);
beginServerRestart();
}).catch((error) => {
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
});
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
// Status display

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type MockPlatform = "web" | "ios" | "android";
type AlertButton = {
onPress?: () => void;
};
async function loadModuleForPlatform(platform: MockPlatform): Promise<{
confirmDialog: typeof import("./confirm-dialog").confirmDialog;
alertMock: ReturnType<typeof vi.fn>;
}> {
vi.resetModules();
const alertMock = vi.fn();
vi.doMock("react-native", () => ({
Alert: {
alert: alertMock,
},
Platform: { OS: platform },
}));
const module = await import("./confirm-dialog");
return { confirmDialog: module.confirmDialog, alertMock };
}
function clearDialogGlobals(): void {
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
delete (globalThis as { confirm?: unknown }).confirm;
}
describe("confirmDialog", () => {
afterEach(() => {
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
clearDialogGlobals();
});
it("uses Tauri dialog.ask on web when available", async () => {
const askMock = vi.fn(async () => true);
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
dialog: { ask: askMock },
};
const { confirmDialog, alertMock } = await loadModuleForPlatform("web");
const confirmed = await confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
});
expect(confirmed).toBe(true);
expect(alertMock).not.toHaveBeenCalled();
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
title: "Restart host",
okLabel: "Restart",
cancelLabel: "Cancel",
kind: "warning",
});
});
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
const browserConfirm = vi.fn(() => true);
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
const { confirmDialog } = await loadModuleForPlatform("web");
const confirmed = await confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
});
expect(confirmed).toBe(true);
expect(browserConfirm).toHaveBeenCalledWith(
"Restart host\n\nThis will restart the daemon."
);
});
it("throws on web when no confirm backend exists", async () => {
const { confirmDialog } = await loadModuleForPlatform("web");
await expect(
confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
})
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
});
it("uses native Alert on iOS/Android", async () => {
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
alertMock.mockImplementation(
(
_title: string,
_message: string,
buttons?: AlertButton[]
) => {
const confirmButton = buttons?.[1];
confirmButton?.onPress?.();
}
);
const confirmed = await confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
confirmLabel: "Restart",
cancelLabel: "Cancel",
destructive: true,
});
expect(confirmed).toBe(true);
expect(alertMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,144 @@
import { Alert, Platform } from "react-native";
export interface ConfirmDialogInput {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
}
interface TauriDialogAskOptions {
title?: string;
okLabel?: string;
cancelLabel?: string;
kind?: "info" | "warning" | "error";
}
interface TauriDialogApi {
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
}
interface TauriCoreApi {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
interface TauriGlobalApi {
dialog?: TauriDialogApi;
core?: TauriCoreApi;
}
interface ConfirmButtonConfig {
confirmLabel: string;
cancelLabel: string;
}
function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig {
return {
confirmLabel: input.confirmLabel ?? "Confirm",
cancelLabel: input.cancelLabel ?? "Cancel",
};
}
async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boolean> {
const labels = resolveButtonLabels(input);
return new Promise<boolean>((resolve) => {
Alert.alert(
input.title,
input.message,
[
{
text: labels.cancelLabel,
style: "cancel",
onPress: () => resolve(false),
},
{
text: labels.confirmLabel,
style: input.destructive ? "destructive" : "default",
onPress: () => resolve(true),
},
],
{
cancelable: true,
onDismiss: () => resolve(false),
}
);
});
}
function getTauriApi(): TauriGlobalApi | null {
if (Platform.OS !== "web") {
return null;
}
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
return tauriApi ?? null;
}
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
const labels = resolveButtonLabels(input);
return {
title: input.title,
okLabel: labels.confirmLabel,
cancelLabel: labels.cancelLabel,
kind: input.destructive ? "warning" : "info",
};
}
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
const tauriApi = getTauriApi();
if (!tauriApi) {
return null;
}
const options = buildTauriAskOptions(input);
const tauriAsk = tauriApi.dialog?.ask;
if (typeof tauriAsk === "function") {
try {
return Boolean(await tauriAsk(input.message, options));
} catch (error) {
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
}
}
const tauriInvoke = tauriApi.core?.invoke;
if (typeof tauriInvoke === "function") {
try {
const result = await tauriInvoke("plugin:dialog|ask", {
message: input.message,
...options,
});
return result === true;
} catch (error) {
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
}
}
return null;
}
function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm;
if (typeof browserConfirm !== "function") {
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
}
const promptMessage = `${input.title}\n\n${input.message}`;
return browserConfirm(promptMessage);
}
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
if (Platform.OS !== "web") {
return showNativeConfirmDialog(input);
}
const tauriResult = await showTauriConfirmDialog(input);
if (tauriResult !== null) {
return tauriResult;
}
return showWebConfirmDialog(input);
}

View File

@@ -109,7 +109,21 @@ export function useTrafficLightPadding(): { left: number; top: number } {
useEffect(() => {
if (Platform.OS !== "web" || !getIsTauriMac()) return;
let unlisten: (() => void) | undefined;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[TauriWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[TauriWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = await getTauriWindow();
@@ -117,19 +131,28 @@ export function useTrafficLightPadding(): { left: number; top: number } {
// Check initial fullscreen state
const fullscreen = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fullscreen);
// Listen for resize events which include fullscreen changes
unlisten = await win.onResized(async () => {
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = await win.isFullscreen();
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
setup();
void setup();
return () => {
unlisten?.();
disposed = true;
runCleanup();
};
}, []);

View File

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

View File

@@ -1,5 +1,9 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@getpaseo/server'
import {
getStructuredAgentResponse,
StructuredAgentResponseError,
type AgentSnapshotPayload,
} from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { readFileSync } from 'node:fs'
@@ -105,98 +109,55 @@ function loadOutputSchema(value: string): Record<string, unknown> {
return parsed as Record<string, unknown>
}
function extractFirstJsonObject(text: string): string | null {
const source = text.trim()
if (!source) {
return null
class StructuredRunStatusError extends Error {
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
super(message)
this.name = 'StructuredRunStatusError'
this.kind = kind
}
}
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
export interface StructuredResponseTimelineClient {
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
}
export async function resolveStructuredResponseMessage(options: {
client: StructuredResponseTimelineClient
agentId: string
lastMessage: string | null
}): Promise<string | null> {
const direct = options.lastMessage?.trim()
if (direct) {
return direct
}
const startIndexes: number[] = []
for (let i = 0; i < source.length; i += 1) {
if (source[i] === '{') {
startIndexes.push(i)
}
}
for (const start of startIndexes) {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < source.length; i += 1) {
const ch = source[i]!
if (inString) {
if (escaped) {
escaped = false
continue
}
if (ch === '\\') {
escaped = true
continue
}
if (ch === '"') {
inString = false
}
try {
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
direction: 'tail',
projection: 'projected',
limit: 200,
})
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
const entry = timeline.entries[index]
if (!entry || entry.item.type !== 'assistant_message') {
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === '{') {
depth += 1
continue
}
if (ch === '}') {
depth -= 1
if (depth === 0) {
const candidate = source.slice(start, i + 1).trim()
try {
JSON.parse(candidate)
return candidate
} catch {
// Keep scanning.
}
}
const text = entry.item.text.trim()
if (text.length > 0) {
return text
}
}
} catch {
// Leave empty; caller will surface a consistent structured-output failure message.
}
return null
}
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
const trimmed = lastMessage.trim()
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
let parsed: unknown
try {
parsed = JSON.parse(jsonText)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response is not valid JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response JSON must be an object',
}
throw error
}
return parsed as Record<string, unknown>
}
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
return {
...agentRunSchema,
@@ -318,6 +279,107 @@ export async function runRunCommand(
labels['ui'] = 'true'
}
if (outputSchema) {
let structuredAgent: AgentSnapshotPayload | null = null
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
if (!structuredAgent) {
structuredAgent = await client.createAgent({
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
cwd,
title: options.name,
modeId: options.mode,
model: options.model,
initialPrompt: structuredPrompt,
images,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
} else {
await client.sendMessage(structuredAgent.id, structuredPrompt)
}
const state = await client.waitForFinish(structuredAgent.id, 10 * 60 * 1000)
if (state.status === 'timeout') {
throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output')
}
if (state.status === 'permission') {
throw new StructuredRunStatusError(
'permission',
'Agent is waiting for permission before producing structured output'
)
}
if (state.status === 'error') {
throw new StructuredRunStatusError(
'error',
state.error ?? 'Agent failed before producing structured output'
)
}
const lastMessage = await resolveStructuredResponseMessage({
client,
agentId: structuredAgent.id,
lastMessage: state.lastMessage,
})
if (!lastMessage) {
throw new StructuredRunStatusError(
'empty',
'Agent finished without a structured output message'
)
}
return lastMessage
}
let output: Record<string, unknown>
try {
output = await getStructuredAgentResponse<Record<string, unknown>>({
caller: callStructuredTurn,
prompt,
schema: outputSchema,
schemaName: 'RunOutput',
maxRetries: 2,
})
} catch (err) {
if (err instanceof StructuredRunStatusError) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: err.message,
}
throw error
}
if (err instanceof StructuredAgentResponseError) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response did not match the required output schema',
details:
err.validationErrors.length > 0
? err.validationErrors.join('\n')
: err.lastResponse || 'No response',
}
throw error
}
throw err
}
if (!structuredAgent) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
}
await client.close()
return {
type: 'single',
data: toRunResult(structuredAgent, 'completed'),
schema: structuredRunSchema(output),
}
}
// Create the agent
const agent = await client.createAgent({
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
@@ -326,59 +388,12 @@ export async function runRunCommand(
modeId: options.mode,
model: options.model,
initialPrompt: prompt,
outputSchema,
images,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
if (outputSchema) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
if (state.status === 'timeout') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Timed out waiting for structured output',
}
throw error
}
if (state.status === 'permission') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent is waiting for permission before producing structured output',
}
throw error
}
if (state.status === 'error') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: state.error ?? 'Agent failed before producing structured output',
}
throw error
}
const lastMessage = state.lastMessage?.trim()
if (!lastMessage) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
}
const output = parseStructuredOutput(lastMessage)
await client.close()
return {
type: 'single',
data: toRunResult(agent, 'completed'),
schema: structuredRunSchema(output),
}
}
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)

View File

@@ -25,9 +25,6 @@ interface OnboardOptions extends DaemonStartOptions {
}
type OnboardPersistedConfig = PersistedConfig & {
providers?: PersistedConfig['providers'] & {
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
}
features?: PersistedConfig['features'] & {
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
? T & { enabled?: boolean }
@@ -103,13 +100,6 @@ function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig):
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
return {
...config,
providers: {
...config.providers,
local: {
...config.providers?.local,
autoDownload: enabled,
},
},
features: {
...config.features,
dictation: {
@@ -366,7 +356,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
const voiceStatus = voiceEnabled
? 'Voice features enabled. Local speech models will download in the background if missing.'
? 'Voice features enabled. Local speech models will be downloaded automatically if missing.'
: 'Voice features disabled. Local speech models will not be downloaded.'
log.message(voiceStatus)

View File

@@ -46,14 +46,10 @@ try {
dictation?: { enabled?: boolean }
voiceMode?: { enabled?: boolean }
}
providers?: {
local?: { autoDownload?: boolean }
}
}
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
assert(
!daemonLog.includes('Ensuring local speech models'),

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import {
resolveStructuredResponseMessage,
type StructuredResponseTimelineClient,
} from '../src/commands/agent/run.ts'
type TimelineEntry = {
item: {
type: string
text?: string
}
}
function createClient(options: {
entries?: TimelineEntry[]
throwOnFetch?: boolean
onFetch?: () => void
}): StructuredResponseTimelineClient {
return {
fetchAgentTimeline: async () => {
options.onFetch?.()
if (options.throwOnFetch) {
throw new Error('timeline unavailable')
}
return {
entries: options.entries ?? [],
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
},
}
}
console.log('=== Run Output Schema Helper Tests ===\n')
// Test 1: Direct lastMessage is returned without timeline fetch.
{
let fetchCount = 0
const client = createClient({ onFetch: () => fetchCount += 1 })
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-1',
lastMessage: ' {"summary":"ok"} ',
})
assert.strictEqual(result, '{"summary":"ok"}')
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
console.log('✓ returns direct lastMessage when present')
}
// Test 2: Falls back to latest assistant message from timeline.
{
const client = createClient({
entries: [
{ item: { type: 'user_message', text: 'prompt' } },
{ item: { type: 'assistant_message', text: '' } },
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
],
})
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-2',
lastMessage: null,
})
assert.strictEqual(result, '{"summary":"from timeline"}')
console.log('✓ falls back to latest assistant timeline entry')
}
// Test 3: Returns null when timeline has no assistant messages.
{
const client = createClient({
entries: [
{ item: { type: 'user_message', text: 'prompt' } },
{ item: { type: 'reasoning', text: 'thinking' } },
],
})
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-3',
lastMessage: null,
})
assert.strictEqual(result, null)
console.log('✓ returns null when no assistant messages exist')
}
// Test 4: Returns null if timeline fetch fails.
{
const client = createClient({ throwOnFetch: true })
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-4',
lastMessage: null,
})
assert.strictEqual(result, null)
console.log('✓ returns null when timeline fetch throws')
}
console.log('\n=== All helper tests passed ===')

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
}
const schema = JSON.stringify({
type: 'object',
properties: {
summary: { type: 'string' },
},
required: ['summary'],
additionalProperties: false,
})
const impossibleSchema = JSON.stringify({
type: 'object',
properties: {
summary: { type: 'string' },
},
required: ['summary'],
allOf: [
{ properties: { summary: { type: 'string' } } },
{ properties: { summary: { type: 'number' } } },
],
})
let ctx: E2EContext
async function setup(): Promise<void> {
ctx = await createE2ETestContext({ timeout: 180000 })
}
async function cleanup(): Promise<void> {
if (ctx) {
await ctx.stop()
}
}
async function runProviderCase(input: {
provider: 'claude' | 'codex' | 'opencode'
mode: string
model: string
}): Promise<void> {
const result = await ctx.paseo(
[
'run',
'--provider',
input.provider,
'--mode',
input.mode,
'--model',
input.model,
'--output-schema',
schema,
`Return valid JSON with a short summary for provider ${input.provider}.`,
],
{ timeout: 180000 }
)
assert.strictEqual(
result.exitCode,
0,
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
assert.strictEqual(
typeof parsed.summary,
'string',
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
)
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
}
async function test_all_providers_return_structured_output(): Promise<void> {
await runProviderCase({
provider: 'claude',
mode: 'bypassPermissions',
model: 'haiku',
})
await runProviderCase({
provider: 'codex',
mode: 'full-access',
model: 'gpt-5.3-codex',
})
await runProviderCase({
provider: 'opencode',
mode: 'default',
model: 'opencode/kimi-k2.5-free',
})
}
async function test_schema_validation_is_enforced(): Promise<void> {
const result = await ctx.paseo(
[
'run',
'--provider',
'claude',
'--mode',
'bypassPermissions',
'--model',
'haiku',
'--output-schema',
impossibleSchema,
'Return exactly {"summary":"ok"} and nothing else.',
],
{ timeout: 180000 }
)
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
const output = `${result.stdout}\n${result.stderr}`
assert(
output.includes('OUTPUT_SCHEMA_FAILED'),
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
}
async function main(): Promise<void> {
try {
await setup()
await test_all_providers_return_structured_output()
await test_schema_validation_is_enforced()
} catch (error) {
console.error(error)
process.exitCode = 1
} finally {
await cleanup()
}
}
main()

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.7",
"version": "0.1.11",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {

View File

@@ -813,6 +813,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
@@ -2517,6 +2519,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-websocket",
@@ -3114,6 +3117,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3946,6 +3973,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.11+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"

View File

@@ -26,6 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-websocket = "2"

View File

@@ -16,6 +16,7 @@
"core:window:allow-start-dragging",
"core:window:allow-set-badge-count",
"core:window:allow-toggle-maximize",
"dialog:default",
"opener:default",
"websocket:default"
]

View File

@@ -19,6 +19,7 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_websocket::init())
.setup(|app| {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.7",
"version": "0.1.11",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -9,6 +9,7 @@
"types": "./dist/server/server/exports.d.ts",
"files": [
"dist/server",
"dist/src",
"dist/scripts",
"README.md",
".env.example",
@@ -53,7 +54,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/relay": "0.1.7",
"@getpaseo/relay": "0.1.11",
"@ai-sdk/openai": "2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",

View File

@@ -1,7 +1,13 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import dotenv from "dotenv";
import { runSupervisor } from "./supervisor.js";
dotenv.config({
path: fileURLToPath(new URL("../.env", import.meta.url)),
quiet: true,
});
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(WORKER_ENTRY)) {
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);

View File

@@ -42,5 +42,5 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechMo
const logger = createRootLogger({ level: "info", format: "pretty" });
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
await ensureLocalSpeechModels({ modelsDir, modelIds, autoDownload: true, logger });
await ensureLocalSpeechModels({ modelsDir, modelIds, logger });
logger.info({ modelsDir, modelIds }, "Done downloading speech models");

View File

@@ -284,7 +284,6 @@ logger.info(
await ensureLocalSpeechModels({
modelsDir: options.modelsDir,
modelIds: options.modelIds,
autoDownload: true,
logger,
});

View File

@@ -18,12 +18,11 @@ type CliOptions = {
outPath?: string;
model: LocalSttModelId;
modelsDir: string;
autoDownload: boolean;
};
function usage(): string {
return [
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>] [--no-auto-download]",
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>]",
"",
"Examples:",
" npm run speech:transcribe:local -- ./sample.wav",
@@ -52,7 +51,6 @@ function parseArgs(argv: string[]): CliOptions {
let outPath: string | undefined;
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
let modelsDir = defaultModelsDir;
let autoDownload = true;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
@@ -87,11 +85,6 @@ function parseArgs(argv: string[]): CliOptions {
continue;
}
if (arg === "--no-auto-download") {
autoDownload = false;
continue;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
@@ -108,7 +101,6 @@ function parseArgs(argv: string[]): CliOptions {
...(outPath ? { outPath } : {}),
model,
modelsDir,
autoDownload,
};
}
@@ -139,7 +131,6 @@ async function main(): Promise<void> {
providers,
local: {
modelsDir: options.modelsDir,
autoDownload: options.autoDownload,
models: {
dictationStt: options.model,
voiceStt: options.model,

View File

@@ -351,6 +351,150 @@ describe("AgentManager", () => {
expect(afterHydrate).toEqual(beforeReload);
});
test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class DelayedPersistenceSession extends TestAgentSession {
private persistenceReady = false;
private interrupted = false;
private releaseGate: (() => void) | null = null;
private readonly gate = new Promise<void>((resolve) => {
this.releaseGate = resolve;
});
constructor(
config: AgentSessionConfig,
private readonly stableSessionId: string,
initiallyReady = false
) {
super(config);
this.persistenceReady = initiallyReady;
}
async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
this.persistenceReady = true;
yield {
type: "thread_started",
provider: this.provider,
sessionId: this.stableSessionId,
};
await this.gate;
if (this.interrupted) {
yield { type: "turn_canceled", provider: this.provider, reason: "Interrupted" };
return;
}
yield { type: "turn_completed", provider: this.provider };
}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.persistenceReady ? this.stableSessionId : null,
model: null,
modeId: null,
};
}
describePersistence() {
if (!this.persistenceReady) {
return null;
}
return {
provider: this.provider,
sessionId: this.stableSessionId,
};
}
async interrupt(): Promise<void> {
this.interrupted = true;
this.releaseGate?.();
}
async close(): Promise<void> {
this.interrupted = true;
this.releaseGate?.();
}
}
class DelayedPersistenceClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
createSessionCalls = 0;
resumeSessionCalls = 0;
private nextSessionNumber = 1;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const sessionId = `delayed-session-${this.nextSessionNumber++}`;
this.createSessionCalls += 1;
return new DelayedPersistenceSession(config, sessionId);
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
): Promise<AgentSession> {
this.resumeSessionCalls += 1;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged: AgentSessionConfig = {
...metadata,
...overrides,
provider: "codex",
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
};
return new DelayedPersistenceSession(merged, handle.sessionId, true);
}
}
const client = new DelayedPersistenceClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000114",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
expect(snapshot.persistence).toBeNull();
const stream = manager.streamAgent(snapshot.id, "hello");
const first = await stream.next();
expect(first.done).toBe(false);
expect(first.value?.type).toBe("turn_started");
const second = await stream.next();
expect(second.done).toBe(false);
expect(second.value?.type).toBe("thread_started");
const active = manager.getAgent(snapshot.id);
expect(active?.lifecycle).toBe("running");
expect(active?.persistence?.sessionId).toBe("delayed-session-1");
const reloaded = await manager.reloadAgentSession(snapshot.id, {
systemPrompt: "voice mode on",
});
expect(client.createSessionCalls).toBe(1);
expect(client.resumeSessionCalls).toBe(1);
expect(reloaded.persistence?.sessionId).toBe("delayed-session-1");
// Drain stream after cancellation to ensure clean shutdown.
while (true) {
const next = await stream.next();
if (next.done) {
break;
}
}
});
test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-"));
const storagePath = join(workdir, "agents");

View File

@@ -666,7 +666,11 @@ export class AgentManager {
agentId: string,
overrides?: Partial<AgentSessionConfig>
): Promise<ManagedAgent> {
const existing = this.requireAgent(agentId);
let existing = this.requireAgent(agentId);
if (existing.lifecycle === "running" || existing.pendingRun) {
await this.cancelAgentRun(agentId);
existing = this.requireAgent(agentId);
}
const timelineState = this.ensureTimelineState(existing);
const preservedTimeline = [...existing.timeline];
const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row }));
@@ -1561,9 +1565,13 @@ export class AgentManager {
// Update persistence with the new session ID from the provider.
// persistence.sessionId is the single source of truth for session identity.
{
const previousSessionId = agent.persistence?.sessionId ?? null;
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
}
}
break;

View File

@@ -4,8 +4,10 @@ import type { Logger } from "pino";
import type { AgentManager } from "./agent-manager.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
StructuredAgentResponseError,
generateStructuredAgentResponse,
generateStructuredAgentResponseWithFallback,
} from "./agent-response-loop.js";
import { validateBranchSlug } from "../../utils/worktree.js";
import {
@@ -14,12 +16,8 @@ import {
type CheckoutStatusResult,
} from "../../utils/checkout-git.js";
const AUTO_GEN_PROVIDER = "codex" as const;
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
const AUTO_GEN_REASONING_EFFORT = "low";
export type AgentMetadataGeneratorDeps = {
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
getCheckoutStatus?: typeof getCheckoutStatus;
renameCurrentBranch?: typeof renameCurrentBranch;
};
@@ -148,7 +146,9 @@ export async function generateAndApplyAgentMetadata(
return;
}
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
const generator =
options.deps?.generateStructuredAgentResponseWithFallback ??
generateStructuredAgentResponseWithFallback;
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
@@ -157,21 +157,22 @@ export async function generateAndApplyAgentMetadata(
try {
result = await generator({
manager: options.agentManager,
agentConfig: {
provider: AUTO_GEN_PROVIDER,
model: AUTO_GEN_MODEL,
thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
cwd: options.cwd,
title: "Agent metadata generator",
internal: true,
},
cwd: options.cwd,
prompt: buildPrompt(needs),
schema,
schemaName: "AgentMetadata",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
agentConfigOverrides: {
title: "Agent metadata generator",
internal: true,
},
});
} catch (error) {
if (error instanceof StructuredAgentResponseError) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Structured metadata generation failed"

View File

@@ -2,9 +2,12 @@ import { describe, it, expect } from "vitest";
import { z } from "zod";
import {
getStructuredAgentResponse,
generateStructuredAgentResponseWithFallback,
StructuredAgentFallbackError,
StructuredAgentResponseError,
type AgentCaller,
} from "./agent-response-loop.js";
import type { AgentManager } from "./agent-manager.js";
function createScriptedCaller(responses: string[]) {
const prompts: string[] = [];
@@ -142,3 +145,134 @@ describe("getStructuredAgentResponse", () => {
expect(result).toEqual({ value: 42 });
});
});
describe("generateStructuredAgentResponseWithFallback", () => {
const schema = z.object({ summary: z.string() });
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
return {
listProviderAvailability: async () => availability,
} as unknown as AgentManager;
}
it("uses the first available provider in the waterfall", async () => {
const calls: Array<{ provider: string; model?: string }> = [];
const manager = createManager([
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: true, error: null },
{ provider: "opencode", available: true, error: null },
]);
const result = await generateStructuredAgentResponseWithFallback({
manager,
cwd: "/tmp/project",
prompt: "Return JSON",
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
],
runner: async (options) => {
calls.push({
provider: options.agentConfig.provider,
model: options.agentConfig.model ?? undefined,
});
return { summary: "ok" };
},
});
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
});
it("skips unavailable providers and uses the next available one", async () => {
const calls: Array<{ provider: string; model?: string }> = [];
const manager = createManager([
{ provider: "claude", available: false, error: "missing auth" },
{ provider: "codex", available: true, error: null },
{ provider: "opencode", available: true, error: null },
]);
const result = await generateStructuredAgentResponseWithFallback({
manager,
cwd: "/tmp/project",
prompt: "Return JSON",
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
],
runner: async (options) => {
calls.push({
provider: options.agentConfig.provider,
model: options.agentConfig.model ?? undefined,
});
return { summary: "ok" };
},
});
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
});
it("falls back when an available provider fails", async () => {
const calls: Array<{ provider: string; model?: string }> = [];
const manager = createManager([
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: true, error: null },
{ provider: "opencode", available: true, error: null },
]);
const result = await generateStructuredAgentResponseWithFallback({
manager,
cwd: "/tmp/project",
prompt: "Return JSON",
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
],
runner: async (options) => {
calls.push({
provider: options.agentConfig.provider,
model: options.agentConfig.model ?? undefined,
});
if (options.agentConfig.provider === "claude") {
throw new Error("claude failed");
}
return { summary: "ok" };
},
});
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
]);
});
it("throws a fallback error when all providers are unavailable or fail", async () => {
const manager = createManager([
{ provider: "claude", available: false, error: "missing auth" },
{ provider: "codex", available: true, error: null },
{ provider: "opencode", available: false, error: "not installed" },
]);
await expect(
generateStructuredAgentResponseWithFallback({
manager,
cwd: "/tmp/project",
prompt: "Return JSON",
schema,
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
],
runner: async () => {
throw new Error("failed");
},
})
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
});
});

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
import type { AgentManager } from "./agent-manager.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
@@ -21,6 +21,43 @@ export class StructuredAgentResponseError extends Error {
}
}
export type StructuredGenerationProvider = {
provider: AgentProvider;
model?: string;
thinkingOptionId?: string;
};
export type StructuredGenerationAttempt = {
provider: AgentProvider;
model: string | null;
available: boolean;
error: string | null;
};
export class StructuredAgentFallbackError extends Error {
readonly attempts: StructuredGenerationAttempt[];
constructor(attempts: StructuredGenerationAttempt[]) {
const summary = attempts
.map((attempt) => {
const modelSuffix = attempt.model ? ` (${attempt.model})` : "";
if (!attempt.available) {
return `${attempt.provider}${modelSuffix}: unavailable${attempt.error ? ` (${attempt.error})` : ""}`;
}
return `${attempt.provider}${modelSuffix}: failed${attempt.error ? ` (${attempt.error})` : ""}`;
})
.join("; ");
super(
summary.length > 0
? `Structured generation failed for all providers: ${summary}`
: "Structured generation failed for all providers"
);
this.name = "StructuredAgentFallbackError";
this.attempts = attempts;
}
}
export interface StructuredAgentResponseOptions<T> {
caller: AgentCaller;
prompt: string;
@@ -39,6 +76,27 @@ export interface StructuredAgentGenerationOptions<T> {
schemaName?: string;
}
export interface StructuredAgentGenerationWithFallbackOptions<T> {
manager: AgentManager;
cwd: string;
prompt: string;
schema: z.ZodType<T> | JsonSchema;
providers: readonly StructuredGenerationProvider[];
agentConfigOverrides?: Omit<
AgentSessionConfig,
"provider" | "cwd" | "model" | "thinkingOptionId"
>;
maxRetries?: number;
schemaName?: string;
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
}
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
] as const;
interface SchemaValidator<T> {
jsonSchema: JsonSchema;
validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] };
@@ -293,3 +351,82 @@ export async function generateStructuredAgentResponse<T>(
}
}
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export async function generateStructuredAgentResponseWithFallback<T>(
options: StructuredAgentGenerationWithFallbackOptions<T>
): Promise<T> {
const {
manager,
cwd,
prompt,
schema,
providers,
agentConfigOverrides,
maxRetries,
schemaName,
runner,
} = options;
if (providers.length === 0) {
throw new StructuredAgentFallbackError([]);
}
const runStructured =
runner ??
((input: StructuredAgentGenerationOptions<T>) =>
generateStructuredAgentResponse<T>(input));
const availability = await manager.listProviderAvailability();
const availabilityByProvider = new Map(
availability.map((entry) => [entry.provider, entry])
);
const attempts: StructuredGenerationAttempt[] = [];
for (const candidate of providers) {
const availabilityEntry = availabilityByProvider.get(candidate.provider);
if (availabilityEntry && !availabilityEntry.available) {
attempts.push({
provider: candidate.provider,
model: candidate.model ?? null,
available: false,
error: availabilityEntry.error ?? null,
});
continue;
}
try {
const result = await runStructured({
manager,
prompt,
schema,
maxRetries,
schemaName,
agentConfig: {
...agentConfigOverrides,
provider: candidate.provider,
cwd,
...(candidate.model ? { model: candidate.model } : {}),
...(candidate.thinkingOptionId
? { thinkingOptionId: candidate.thinkingOptionId }
: {}),
},
});
return result;
} catch (error) {
attempts.push({
provider: candidate.provider,
model: candidate.model ?? null,
available: true,
error: errorMessage(error),
});
}
}
throw new StructuredAgentFallbackError(attempts);
}

View File

@@ -1428,7 +1428,14 @@ class ClaudeAgentSession implements AgentSession {
switch (message.type) {
case "system":
if (message.subtype === "init") {
this.handleSystemMessage(message);
const threadSessionId = this.handleSystemMessage(message);
if (threadSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: threadSessionId,
});
}
} else if (message.subtype === "status") {
const status = (message as { status?: string }).status;
if (status === "compacting") {
@@ -1513,9 +1520,9 @@ class ClaudeAgentSession implements AgentSession {
return events;
}
private handleSystemMessage(message: SDKSystemMessage): void {
private handleSystemMessage(message: SDKSystemMessage): string | null {
if (message.subtype !== "init") {
return;
return null;
}
const msg = message as unknown as {
@@ -1533,13 +1540,15 @@ class ClaudeAgentSession implements AgentSession {
: "";
const newSessionId = newSessionIdRaw.trim();
if (!newSessionId) {
return;
return null;
}
const existingSessionId = this.claudeSessionId;
let threadStartedSessionId: string | null = null;
if (existingSessionId === null) {
// First time setting session ID (empty → filled) - this is expected
this.claudeSessionId = newSessionId;
threadStartedSessionId = newSessionId;
this.logger.debug(
{ sessionId: newSessionId },
"Claude session ID set for the first time"
@@ -1578,6 +1587,7 @@ class ClaudeAgentSession implements AgentSession {
// Invalidate cached runtime info so it picks up the new model
this.cachedRuntimeInfo = null;
}
return threadStartedSessionId;
}
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {

View File

@@ -63,7 +63,7 @@ import type {
AgentProvider,
} from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import {
createVoiceMcpSocketBridgeManager,
@@ -137,16 +137,6 @@ export async function createPaseoDaemon(
): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" });
// Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active.
const lockState = await isLocked(config.paseoHome);
if (lockState.locked) {
const existingLock = lockState.info;
throw new PidLockError(
`Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`,
existingLock
);
}
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
await acquirePidLock(config.paseoHome, config.listen);

View File

@@ -186,7 +186,8 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
PASEO_HOME: tempHome,
PASEO_LISTEN: `0.0.0.0:${port}`,
OPENAI_API_KEY: "",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOG_FORMAT: "json",
};

View File

@@ -0,0 +1,107 @@
import { describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { getFullAccessConfig } from "./agent-configs.js";
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
import { isCommandAvailable } from "../agent/provider-launch-config.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-claude-"));
}
describe("daemon E2E (real claude) - send while running recovery", () => {
test.runIf(isCommandAvailable("claude"))(
"clears input processing when the interrupt transition is missed",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await primary.connect();
await secondary.connect();
primary.subscribeAgentUpdates({ subscriptionId: "primary" });
secondary.subscribeAgentUpdates({ subscriptionId: "secondary" });
const agent = await primary.createAgent({
cwd,
title: "stuck-repro-real-claude",
...getFullAccessConfig("claude"),
});
await primary.sendMessage(
agent.id,
"Run bash command sleep 30, wait for completion, then reply done."
);
await primary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000
);
let isProcessing = true;
let previousIsRunning = true;
let latestUpdatedAt = Date.now();
await primary.close();
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000
);
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await reconnected.connect();
reconnected.subscribeAgentUpdates({ subscriptionId: "reconnected" });
reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
return;
}
if (message.payload.agent.id !== agent.id) {
return;
}
const next = applyAgentInputProcessingTransition({
snapshot: message.payload.agent,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
});
await secondary.waitForFinish(agent.id, 180_000);
// Sending while running should clear processing even if reconnect misses the
// not-running -> running transition.
expect(isProcessing).toBe(false);
} finally {
await reconnected.close();
}
} finally {
await secondary.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
300_000
);
});

View File

@@ -0,0 +1,42 @@
import type { AgentSnapshotPayload } from "../messages.js";
export function applyAgentInputProcessingTransition(input: {
snapshot: AgentSnapshotPayload;
currentIsProcessing: boolean;
previousIsRunning: boolean;
latestUpdatedAt: number;
}): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } {
const updatedAt = new Date(input.snapshot.updatedAt).getTime();
if (updatedAt < input.latestUpdatedAt) {
return {
isProcessing: input.currentIsProcessing,
previousIsRunning: input.previousIsRunning,
latestUpdatedAt: input.latestUpdatedAt,
};
}
const isRunning = input.snapshot.status === "running";
const wasRunning = input.previousIsRunning;
let isProcessing = input.currentIsProcessing;
if (isProcessing) {
const hasEnteredRunning = !wasRunning && isRunning;
const hasFreshRunningUpdateWhileRunning =
wasRunning && isRunning && updatedAt > input.latestUpdatedAt;
const hasStoppedRunning = wasRunning && !isRunning;
if (
hasEnteredRunning ||
hasFreshRunningUpdateWhileRunning ||
hasStoppedRunning
) {
isProcessing = false;
}
}
return {
isProcessing,
previousIsRunning: isRunning,
latestUpdatedAt: updatedAt,
};
}

View File

@@ -0,0 +1,104 @@
import { describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { getFullAccessConfig } from "./agent-configs.js";
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
import { isCommandAvailable } from "../agent/provider-launch-config.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-"));
}
describe("daemon E2E (real codex) - send while running recovery", () => {
test.runIf(isCommandAvailable("codex"))(
"clears input processing when the interrupt transition is missed",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await primary.connect();
await secondary.connect();
primary.subscribeAgentUpdates({ subscriptionId: "primary" });
secondary.subscribeAgentUpdates({ subscriptionId: "secondary" });
const agent = await primary.createAgent({
cwd,
title: "stuck-repro-real-codex",
...getFullAccessConfig("codex"),
});
await primary.sendMessage(agent.id, "Run: sleep 30");
await primary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000
);
let isProcessing = true;
let previousIsRunning = true;
let latestUpdatedAt = Date.now();
await primary.close();
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000
);
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await reconnected.connect();
reconnected.subscribeAgentUpdates({ subscriptionId: "reconnected" });
reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
return;
}
if (message.payload.agent.id !== agent.id) {
return;
}
const next = applyAgentInputProcessingTransition({
snapshot: message.payload.agent,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
});
await secondary.waitForFinish(agent.id, 120_000);
// Sending while running should clear processing even if reconnect misses the
// not-running -> running transition.
expect(isProcessing).toBe(false);
} finally {
await reconnected.close();
}
} finally {
await secondary.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
240_000
);
});

View File

@@ -34,6 +34,20 @@ export type {
// Agent activity curator for CLI logs
export { curateAgentActivity } from "./agent/activity-curator.js";
export {
getStructuredAgentResponse,
StructuredAgentResponseError,
StructuredAgentFallbackError,
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
generateStructuredAgentResponseWithFallback,
type AgentCaller,
type JsonSchema,
type StructuredGenerationAttempt,
type StructuredGenerationProvider,
type StructuredAgentGenerationOptions,
type StructuredAgentGenerationWithFallbackOptions,
type StructuredAgentResponseOptions,
} from "./agent/agent-response-loop.js";
// WebSocket message types for CLI streaming
export type {

View File

@@ -1,9 +1,3 @@
import dotenv from "dotenv";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
import { createPaseoDaemon } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";

View File

@@ -22,7 +22,6 @@ const ProviderCredentialsSchema = z
const LocalSpeechProviderSchema = z
.object({
modelsDir: z.string().min(1).optional(),
autoDownload: z.boolean().optional(),
})
.strict();
@@ -175,6 +174,34 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "config" });
}
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed;
}
const root = { ...(parsed as Record<string, unknown>) };
const providers = root.providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
return root;
}
const providersRecord = { ...(providers as Record<string, unknown>) };
const local = providersRecord.local;
if (!local || typeof local !== "object" || Array.isArray(local)) {
root.providers = providersRecord;
return root;
}
const localRecord = { ...(local as Record<string, unknown>) };
if ("autoDownload" in localRecord) {
delete localRecord.autoDownload;
}
providersRecord.local = localRecord;
root.providers = providersRecord;
return root;
}
export function loadPersistedConfig(
paseoHome: string,
logger?: LoggerLike
@@ -209,7 +236,8 @@ export function loadPersistedConfig(
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
}
const result = PersistedConfigSchema.safeParse(parsed);
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
const result = PersistedConfigSchema.safeParse(migrated);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)

View File

@@ -81,8 +81,10 @@ import {
} from "./agent/timeline-append.js";
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
StructuredAgentResponseError,
generateStructuredAgentResponse,
generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js";
import type {
AgentPermissionResponse,
@@ -166,12 +168,6 @@ const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
/**
* Default model used for auto-generating commit messages and PR descriptions.
* Uses Claude Haiku for speed and cost efficiency.
*/
const AUTO_GEN_MODEL = "haiku";
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) {
return null;
@@ -1183,6 +1179,31 @@ export class Session {
}
}
private async emitCurrentAgentUpdatesForSubscription(): Promise<void> {
const subscription = this.agentUpdatesSubscription;
if (!subscription) {
return;
}
try {
const agents = await this.listAgentPayloads({
labels: subscription.filter?.labels,
});
for (const agent of agents) {
const project = await this.buildProjectPlacement(agent.cwd);
this.emit({
type: "agent_update",
payload: { kind: "upsert", agent, project },
});
}
} catch (error) {
this.sessionLogger.error(
{ err: error },
"Failed to emit current agent updates for subscription bootstrap"
);
}
}
/**
* Main entry point for processing session messages
*/
@@ -1214,6 +1235,7 @@ export class Session {
subscriptionId: msg.subscriptionId,
filter: msg.filter,
};
await this.emitCurrentAgentUpdatesForSubscription();
break;
case "unsubscribe_agent_updates":
@@ -2853,7 +2875,6 @@ export class Session {
await ensureLocalSpeechModels({
modelsDir,
modelIds,
autoDownload: true,
logger: this.sessionLogger,
});
this.emit({
@@ -3017,23 +3038,25 @@ export class Session {
patch.length > 0 ? patch : "(No diff available)",
].join("\n");
try {
const result = await generateStructuredAgentResponse({
const result = await generateStructuredAgentResponseWithFallback({
manager: this.agentManager,
agentConfig: {
provider: "claude",
model: AUTO_GEN_MODEL,
cwd,
title: "Commit generator",
internal: true,
},
cwd,
prompt,
schema,
schemaName: "CommitMessage",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
agentConfigOverrides: {
title: "Commit generator",
internal: true,
},
});
return result.message;
} catch (error) {
if (error instanceof StructuredAgentResponseError) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
return "Update files";
}
throw error;
@@ -3082,22 +3105,24 @@ export class Session {
patch.length > 0 ? patch : "(No diff available)",
].join("\n");
try {
return await generateStructuredAgentResponse({
return await generateStructuredAgentResponseWithFallback({
manager: this.agentManager,
agentConfig: {
provider: "claude",
model: AUTO_GEN_MODEL,
cwd,
title: "PR generator",
internal: true,
},
cwd,
prompt,
schema,
schemaName: "PullRequest",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
agentConfigOverrides: {
title: "PR generator",
internal: true,
},
});
} catch (error) {
if (error instanceof StructuredAgentResponseError) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
return {
title: "Update changes",
body: "Automated PR generated by Paseo.",

View File

@@ -24,7 +24,6 @@ export type LocalSpeechModelConfig = {
export type LocalSpeechProviderConfig = {
modelsDir: string;
autoDownload?: boolean;
models: LocalSpeechModelConfig;
};
@@ -36,17 +35,6 @@ export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
const BooleanStringSchema = z
.string()
.trim()
.toLowerCase()
.pipe(z.enum(["1", "0", "true", "false", "yes", "no"]))
.transform((value) => value === "1" || value === "true" || value === "yes");
const OptionalBooleanFlagSchema = z
.union([z.boolean(), BooleanStringSchema])
.optional();
const NumberLikeSchema = z.union([
z.number(),
z.string().trim().min(1),
@@ -63,7 +51,6 @@ const OptionalIntegerSchema = NumberLikeSchema
const LocalSpeechResolutionSchema = z.object({
includeProviderConfig: z.boolean(),
modelsDir: z.string().trim().min(1),
autoDownload: OptionalBooleanFlagSchema.default(true),
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
@@ -98,7 +85,7 @@ function shouldIncludeLocalProviderConfig(params: {
return (
localRequestedByFeature ||
params.env.PASEO_LOCAL_MODELS_DIR !== undefined ||
params.persisted.providers?.local !== undefined
params.persisted.providers?.local?.modelsDir !== undefined
);
}
@@ -116,9 +103,6 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_LOCAL_MODELS_DIR ??
params.persisted.providers?.local?.modelsDir ??
path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
autoDownload:
params.env.PASEO_LOCAL_AUTO_DOWNLOAD ??
params.persisted.providers?.local?.autoDownload,
dictationLocalSttModel:
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
persistedLocalFeatureModel(
@@ -160,7 +144,6 @@ export function resolveLocalSpeechConfig(params: {
parsed.includeProviderConfig
? {
modelsDir: parsed.modelsDir,
autoDownload: parsed.autoDownload,
models: {
dictationStt: parsed.dictationLocalSttModel,
voiceStt: parsed.voiceLocalSttModel,

View File

@@ -36,13 +36,11 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo
export async function ensureLocalSpeechModels(options: {
modelsDir: string;
modelIds: LocalSpeechModelId[];
autoDownload?: boolean;
logger: import("pino").Logger;
}): Promise<Record<LocalSpeechModelId, string>> {
return ensureSherpaOnnxModels({
modelsDir: options.modelsDir,
modelIds: options.modelIds,
autoDownload: options.autoDownload ?? true,
logger: options.logger,
});
}

View File

@@ -36,7 +36,6 @@ type ResolvedLocalModels = {
type LocalSpeechAvailability = {
configured: boolean;
modelsDir: string | null;
autoDownload: boolean | null;
};
export type InitializedLocalSpeech = {
@@ -79,7 +78,6 @@ export function getLocalSpeechAvailability(
return {
configured: Boolean(localConfig),
modelsDir: localConfig?.modelsDir ?? null,
autoDownload: localConfig?.autoDownload ?? null,
};
}
@@ -186,12 +184,10 @@ export async function initializeLocalSpeechServices(params: {
providers: RequestedSpeechProviders;
speechConfig: PaseoSpeechConfig | null;
logger: Logger;
modelEnsureAutoDownload?: boolean;
}): Promise<InitializedLocalSpeech> {
const { providers, logger, speechConfig } = params;
const localConfig = speechConfig?.local ?? null;
const localModels = resolveConfiguredLocalModels(speechConfig);
const modelEnsureAutoDownload = params.modelEnsureAutoDownload ?? (localConfig?.autoDownload ?? true);
let sttService: SpeechToTextProvider | null = null;
let ttsService: TextToSpeechProvider | null = null;
@@ -209,28 +205,25 @@ export async function initializeLocalSpeechServices(params: {
{
modelsDir: localConfig.modelsDir,
modelIds: requiredLocalModelIds,
autoDownload: modelEnsureAutoDownload,
},
"Ensuring local speech models"
);
await ensureLocalSpeechModels({
modelsDir: localConfig.modelsDir,
modelIds: requiredLocalModelIds,
autoDownload: modelEnsureAutoDownload,
logger,
});
} catch (err) {
logger.error(
logger.warn(
{
err,
modelsDir: localConfig.modelsDir,
modelIds: requiredLocalModelIds,
autoDownload: modelEnsureAutoDownload,
hint:
"Use `paseo speech models` to inspect status and " +
"`paseo speech download --model <MODEL_ID>` to fetch missing models.",
},
"Failed to ensure local speech models"
"Local speech model bootstrap failed"
);
}
}
@@ -256,14 +249,14 @@ export async function initializeLocalSpeechServices(params: {
localSttEngines.set(modelId, created);
return created;
} catch (err) {
logger.error(
logger.warn(
{
err,
modelsDir: localConfig.modelsDir,
modelId,
hint: buildModelDownloadHint(modelId),
},
"Failed to initialize local STT engine (models missing or invalid)"
"Local STT engine unavailable"
);
return null;
}
@@ -340,14 +333,14 @@ export async function initializeLocalSpeechServices(params: {
}
ttsService = localVoiceTtsProvider;
} catch (err) {
logger.error(
logger.warn(
{
err,
modelsDir: localConfig.modelsDir,
modelId: localModels.voiceLocalTtsModel,
hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
},
"Failed to initialize local TTS engine (models missing or invalid)"
"Local TTS engine unavailable"
);
}
}

View File

@@ -35,25 +35,12 @@ describe("sherpa model downloader", () => {
const out = await ensureSherpaOnnxModel({
modelsDir,
modelId: "kitten-nano-en-v0_1-fp16",
autoDownload: false,
logger,
});
expect(out).toBe(modelDir);
});
test("ensureSherpaOnnxModel throws when missing and autoDownload is false", async () => {
const modelsDir = makeTmpDir();
await expect(
ensureSherpaOnnxModel({
modelsDir,
modelId: "zipformer-bilingual-zh-en-2023-02-20",
autoDownload: false,
logger,
})
).rejects.toThrow(/auto-download/i);
});
test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => {
const modelsDir = makeTmpDir();
const infoMessages: string[] = [];
@@ -82,7 +69,6 @@ describe("sherpa model downloader", () => {
await ensureSherpaOnnxModel({
modelsDir,
modelId: "pocket-tts-onnx-int8",
autoDownload: true,
logger: loggerWithSpy,
});
} finally {

View File

@@ -11,7 +11,6 @@ import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.
export type EnsureSherpaOnnxModelOptions = {
modelsDir: string;
modelId: SherpaOnnxModelId;
autoDownload: boolean;
logger: pino.Logger;
};
@@ -104,13 +103,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
return modelDir;
}
if (!options.autoDownload) {
throw new Error(
`Missing local speech model files for ${options.modelId} in ${modelDir}. ` +
`Set PASEO_LOCAL_AUTO_DOWNLOAD=1 to auto-download.`
);
}
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
try {
@@ -207,7 +199,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
export async function ensureSherpaOnnxModels(options: {
modelsDir: string;
modelIds: SherpaOnnxModelId[];
autoDownload: boolean;
logger: pino.Logger;
}): Promise<Record<SherpaOnnxModelId, string>> {
const uniq = Array.from(new Set(options.modelIds));
@@ -216,7 +207,6 @@ export async function ensureSherpaOnnxModels(options: {
out[id] = await ensureSherpaOnnxModel({
modelsDir: options.modelsDir,
modelId: id,
autoDownload: options.autoDownload,
logger: options.logger,
});
}

View File

@@ -106,7 +106,6 @@ describe("speech models (download E2E)", () => {
await ensureSherpaOnnxModels({
modelsDir,
modelIds,
autoDownload: true,
logger,
});
@@ -121,7 +120,6 @@ describe("speech models (download E2E)", () => {
},
local: {
modelsDir,
autoDownload: false,
models: {
dictationStt:
set === "parakeet-pocket"

View File

@@ -35,7 +35,6 @@ describe("resolveSpeechConfig", () => {
});
expect(result.speech.local).toEqual({
modelsDir: path.join(paseoHome, "models", "local-speech"),
autoDownload: true,
models: {
dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8",
@@ -67,7 +66,6 @@ describe("resolveSpeechConfig", () => {
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
OPENAI_API_KEY: "env-key",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_DICTATION_STT_PROVIDER: "local",
@@ -82,7 +80,6 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.local).toEqual({
modelsDir: "/tmp/models",
autoDownload: false,
models: {
dictationStt: "zipformer-bilingual-zh-en-2023-02-20",
voiceStt: "parakeet-tdt-0.6b-v3-int8",

View File

@@ -79,7 +79,6 @@ describe("initializeSpeechRuntime readiness", () => {
availability: {
configured: false,
modelsDir: null,
autoDownload: null,
},
cleanup: () => {},
});
@@ -115,7 +114,6 @@ describe("initializeSpeechRuntime readiness", () => {
availability: {
configured: false,
modelsDir: null,
autoDownload: null,
},
cleanup: () => {},
});

View File

@@ -205,7 +205,6 @@ function buildVoiceFeatureReadiness(params: {
missingLocalModelIds: LocalSpeechModelId[];
backgroundDownloadInProgress: boolean;
backgroundDownloadError: string | null;
localAutoDownloadEnabled: boolean;
}): SpeechReadinessState {
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
if (!enabled) {
@@ -246,7 +245,7 @@ function buildVoiceFeatureReadiness(params: {
available: false,
reasonCode: "models_missing",
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
retryable: params.localAutoDownloadEnabled,
retryable: true,
missingModelIds,
};
}
@@ -328,7 +327,6 @@ export async function initializeSpeechRuntime(params: {
const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig);
const requestedProviders = describeRequestedProviders(providers);
const localAutoDownloadEnabled = speechConfig?.local?.autoDownload ?? true;
validateOpenAiCredentialRequirements({
providers,
@@ -382,7 +380,6 @@ export async function initializeSpeechRuntime(params: {
missingLocalModelIds,
backgroundDownloadInProgress,
backgroundDownloadError,
localAutoDownloadEnabled,
});
return {
generatedAt: new Date().toISOString(),
@@ -459,12 +456,11 @@ export async function initializeSpeechRuntime(params: {
});
};
const reconcileServices = async (modelEnsureAutoDownload: boolean): Promise<void> => {
const reconcileServices = async (): Promise<void> => {
const nextLocalSpeech = await initializeLocalSpeechServices({
providers,
speechConfig,
logger,
modelEnsureAutoDownload,
});
const nextOpenAiSpeech = initializeOpenAiSpeechServices({
providers,
@@ -521,13 +517,13 @@ export async function initializeSpeechRuntime(params: {
}
};
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => {
const runReconcile = async (): Promise<void> => {
if (reconcileInFlight) {
await reconcileInFlight;
publishReadinessIfChanged();
return;
}
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => {
reconcileInFlight = reconcileServices().finally(() => {
reconcileInFlight = null;
});
await reconcileInFlight;
@@ -550,7 +546,7 @@ export async function initializeSpeechRuntime(params: {
}
const modelsDir = localModelConfig?.modelsDir ?? null;
const modelIds = [...missingLocalModelIds];
if (!modelsDir || modelIds.length === 0 || !localAutoDownloadEnabled) {
if (!modelsDir || modelIds.length === 0) {
return;
}
@@ -571,10 +567,9 @@ export async function initializeSpeechRuntime(params: {
await ensureLocalSpeechModels({
modelsDir,
modelIds,
autoDownload: true,
logger,
});
await runReconcile({ modelEnsureAutoDownload: false });
await runReconcile();
backgroundDownloadError = null;
} catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error);
@@ -610,12 +605,11 @@ export async function initializeSpeechRuntime(params: {
missingLocalModelIds.length === 0 &&
!backgroundDownloadInProgress
) {
await runReconcile({ modelEnsureAutoDownload: false });
await runReconcile();
}
if (
missingLocalModelIds.length > 0 &&
localAutoDownloadEnabled &&
!backgroundDownloadInProgress &&
!backgroundDownloadError
) {
@@ -629,10 +623,10 @@ export async function initializeSpeechRuntime(params: {
}
};
await runReconcile({ modelEnsureAutoDownload: false });
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0 && localAutoDownloadEnabled) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
}
scheduleMonitor();

View File

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

View File

@@ -95,9 +95,6 @@
"modelsDir": {
"type": "string",
"minLength": 1
},
"autoDownload": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -197,6 +194,9 @@
"dictation": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"stt": {
"type": "object",
"properties": {
@@ -230,6 +230,9 @@
"voiceMode": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"llm": {
"type": "object",
"properties": {

View File

@@ -177,7 +177,7 @@ docker run --rm -i \\
<li><code className="font-mono">OPENAI_API_KEY</code> override OpenAI provider key</li>
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> override voice LLM provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, <code className="font-mono">opencode</code>)</li>
<li><code className="font-mono">PASEO_DICTATION_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_TTS_PROVIDER</code> override voice provider selection (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li>
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> and <code className="font-mono">PASEO_LOCAL_AUTO_DOWNLOAD</code> control local model directory and download behavior</li>
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> control local model directory</li>
<li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> override local dictation STT model</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> override local voice STT/TTS models</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> optional local voice TTS tuning</li>

View File

@@ -55,7 +55,7 @@ function VoiceDocs() {
</p>
<p className="text-white/60 leading-relaxed">
Missing models are downloaded at daemon startup into{' '}
<code className="font-mono">$PASEO_HOME/models/local-speech</code> when auto-download is enabled.
<code className="font-mono">$PASEO_HOME/models/local-speech</code>.
Downloads happen only for missing files.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
@@ -71,8 +71,7 @@ function VoiceDocs() {
},
"providers": {
"local": {
"modelsDir": "~/.paseo/models/local-speech",
"autoDownload": true
"modelsDir": "~/.paseo/models/local-speech"
}
}
}`}
@@ -107,7 +106,7 @@ function VoiceDocs() {
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li><code className="font-mono">OPENAI_API_KEY</code> OpenAI speech credentials</li>
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> voice agent provider override</li>
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code>, <code className="font-mono">PASEO_LOCAL_AUTO_DOWNLOAD</code> local model storage and download policy</li>
<li><code className="font-mono">PASEO_LOCAL_MODELS_DIR</code> local model storage directory</li>
<li><code className="font-mono">PASEO_DICTATION_LOCAL_STT_MODEL</code> local dictation STT model ID</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_STT_MODEL</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_MODEL</code> local voice STT/TTS model IDs</li>
<li><code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEAKER_ID</code>, <code className="font-mono">PASEO_VOICE_LOCAL_TTS_SPEED</code> optional local voice TTS tuning</li>

View File

@@ -112,7 +112,7 @@ function Hero() {
return (
<div className="space-y-6">
<h1 className="text-3xl md:text-5xl font-medium tracking-tight">
Run Codex and Claude Code locally from your phone and desktop
Run Codex and Claude Code on your machine from your phone and desktop
</h1>
<p className="text-white/70 text-lg leading-relaxed">
Bring your dev environment anywhere, with voice support built in.