Compare commits

...

53 Commits

Author SHA1 Message Date
Mohamed Boudra
e0e4f228eb fix(server): guard paseo:ready IPC behind PASEO_SUPERVISED env var
The process.send check alone was insufficient because vitest also runs
workers with IPC channels. Now the supervisor sets PASEO_SUPERVISED=1
on the worker env, and bootstrap checks for it before sending.
2026-03-30 12:44:05 +07:00
github-actions[bot]
f6630e16ff fix: update lockfile signatures and Nix hash 2026-03-30 05:40:49 +00:00
Mohamed Boudra
82a235dada chore(release): cut 0.1.38 2026-03-30 12:39:23 +07:00
Mohamed Boudra
3784357b7e docs: add 0.1.38 changelog entry 2026-03-30 12:39:12 +07:00
Mohamed Boudra
8d0a9f9224 fix(server): fix daemon startup race and reduce log bloat
The desktop app could time out connecting to the daemon on first launch
because the PID file advertised a listen address before the HTTP server
was actually listening. The supervisor now writes the PID lock with
listen: null and updates it only after the worker sends a paseo:ready
IPC message confirming the server is listening.

- Rename daemon-runner.ts to supervisor-entrypoint.ts
- PID lock acquired with listen: null, updated via paseo:ready IPC
- Worker sends paseo:ready after httpServer.listen() resolves
- Desktop polls until listen is non-null before returning to the app
- Remove PASEO_PID_LOCK_MODE and external lock mode
- Remove unnecessary env overrides (PASEO_HOME, PASEO_CORS_ORIGINS) from
  desktop daemon spawn
- Reduce trace log bloat: inbound/outbound WebSocket messages now log
  only message type and payload size instead of full payloads
- Supervisor restarts worker on SIGKILL (covers OOM)
2026-03-30 12:37:07 +07:00
Mohamed Boudra
e758734807 feat(website): update marketing copy and section styling
Rework multi-provider and self-hosted sections with clearer copy.
Scale up self-hosted diagram cards to match multi-provider sizing.
2026-03-30 12:31:42 +07:00
Mohamed Boudra
352a4e793a feat(website): redeploy on new GitHub releases
The website fetches the latest release version at build time, so it
needs to redeploy when a new release is published to pick up updated
download links.
2026-03-30 08:48:06 +07:00
Mohamed Boudra
8047394154 docs: add 0.1.37 changelog entry 2026-03-29 23:28:00 +07:00
Mohamed Boudra
e5fd61f131 feat(app): add empty states for sidebar, sessions, and open-project
Sidebar shows a card with "No projects yet" and an Add project ghost
button. Sessions screen shows "No sessions yet" with a ghost Back
button. Open-project screen uses MenuHeader borderless, shared Button,
and shows introductory text when no projects exist.
2026-03-29 23:10:45 +07:00
Mohamed Boudra
487af3c822 feat(app): add borderless prop to ScreenHeader and MenuHeader 2026-03-29 23:10:40 +07:00
Mohamed Boudra
a7439c8e56 feat(app): enhance Button ghost variant with hover and flexible leftIcon
Ghost variant text+icon are foregroundMuted by default, foreground on
hover. leftIcon now accepts a ReactElement, ComponentType, or render
function so the Button can control icon color internally.
2026-03-29 23:10:35 +07:00
Mohamed Boudra
930660074d fix(server): treat bare slash queries as search terms in project picker
Queries like "faro/main" were treated as literal paths relative to ~,
so the picker tried to list ~/faro/ (which doesn't exist) instead of
searching the home tree for paths containing "faro/main". Only treat
queries as paths when explicitly rooted with ~, ~/, ./, or /.
2026-03-29 23:10:15 +07:00
Mohamed Boudra
fd030e8673 docs: clarify installation paths and components (#153)
Rewrite Getting Started in README and website docs to explain the
daemon, put agent CLI prerequisites first, and split setup into
Desktop App (recommended, bundles daemon) and CLI/headless paths.
Add "Components at a glance" section to ARCHITECTURE.md.
2026-03-29 22:37:53 +07:00
Mohamed Boudra
bb73147efd feat(app): desktop startup sequence with error recovery (#153)
Replace the silent daemon bootstrap failure path with a multi-phase
startup screen that shows progress and surfaces errors. Uses Expo
Router Stack.Protected to gate app screens behind bootstrap completion,
keeping the Stack mounted at all times to avoid layout remounts.

- bootstrapDesktop() returns structured result instead of swallowing errors
- addConnectionFromListenAndWaitForOnline() waits for real connection, not just probe
- Startup screen shows stacked progress steps with checkmark transitions
- Error state shows daemon logs, copy button, GitHub issue link, docs link, retry
- External URLs open in system browser via openExternalUrl
2026-03-29 22:29:16 +07:00
Mohamed Boudra
04b21f089c Fix mobile dropdown close and draft thinking persistence
Closes #150

Closes #151
2026-03-29 21:17:31 +07:00
Mohamed Boudra
fb42c8eea2 fix(website): fetch desktop version from latest GitHub release
The download page was showing pre-release versions because it read
the version directly from package.json. Now fetches the latest
non-prerelease from the GitHub Releases API at build time, with
fallback to package.json for local dev.
2026-03-29 21:12:30 +07:00
github-actions[bot]
971f74e2ab fix: update lockfile signatures and Nix hash 2026-03-29 13:41:46 +00:00
github-actions[bot]
4318aa2bb1 fix: update lockfile signatures and Nix hash 2026-03-29 20:39:03 +07:00
Mohamed Boudra
a1761363fa fix(website): respect draft frontmatter field when filtering posts
Posts with `draft: true` in frontmatter were shown because isDraft only
checked the file path for `/drafts/`. Now checks both path and frontmatter.
2026-03-29 20:39:03 +07:00
github-actions[bot]
096417d5ac fix: update lockfile signatures and Nix hash 2026-03-29 10:54:20 +00:00
Mohamed Boudra
35446e01a2 docs(skills): add paseo archive command to CLI reference 2026-03-29 17:39:50 +07:00
Mohamed Boudra
6c3559e90f refactor(server): extract CheckoutDiffManager from Session into daemon-global service
Checkout-diff watch targets were per-Session, so multiple clients (phone,
desktop, browser) watching the same workspace duplicated fs.watch sets and
snapshot computation. Move all checkout-diff subscription machinery into a
singleton CheckoutDiffManager that deduplicates watchers and snapshots
across sessions — same pattern as AgentManager.

Session now keeps only a Map<subscriptionId, unsubscribe> and thin message
handler wrappers. Shared utilities (resolveCheckoutGitDir, toCheckoutError,
READ_ONLY_GIT_ENV) extracted to checkout-git-utils.ts.
2026-03-29 17:39:47 +07:00
Mohamed Boudra
409ab466ca docs(website): add draft blog posts 2026-03-29 16:44:51 +07:00
Mohamed Boudra
efb8c8f229 feat(server): cache getPullRequestStatus with TTL and in-flight dedup
Adds @isaacs/ttlcache to avoid repeated gh CLI calls for the same
working directory. Concurrent lookups for the same cwd share a single
in-flight promise. Cache expires after 30s by default.
2026-03-29 16:42:39 +07:00
Mohamed Boudra
b1d663557b refactor(daemon): replace daemon-launch.log with electron-log in desktop, remove from CLI/server
The file-based daemon-launch.log was added as temporary instrumentation
for debugging Windows startup. Desktop now logs lifecycle events through
electron-log; CLI and server supervisor drop the launch logging entirely.
2026-03-29 16:42:31 +07:00
Mohamed Boudra
a460c5e9b9 fix(website): only show blog drafts when explicitly requested
The validateSearch round-trip caused drafts to always show because
`false !== undefined` is true. Check for explicit truthy values instead.
2026-03-29 14:43:23 +07:00
Mohamed Boudra
831e3289e7 chore(windows): instrument detached daemon launch 2026-03-29 14:38:18 +07:00
Mohamed Boudra
094864d779 feat: add chat and website updates 2026-03-29 12:49:05 +07:00
Mohamed Boudra
147482d6ed feat(desktop): sync title bar theme with resolved app theme 2026-03-29 09:26:21 +07:00
Mohamed Boudra
921ddf47a8 refactor(desktop): move title bar overlay logic to window-manager and add setTitleBarTheme IPC 2026-03-29 09:26:18 +07:00
Mohamed Boudra
f287c7f972 fix(app): default theme setting to auto instead of dark 2026-03-29 09:26:15 +07:00
Mohamed Boudra
9eb1f93296 fix(app): reduce sidebar footer icon size from lg to md 2026-03-29 09:24:46 +07:00
Mohamed Boudra
95d56ddf15 fix(server): resolve claude from current windows path 2026-03-28 23:54:50 +07:00
Mohamed Boudra
62b1e94257 chore(server): instrument daemon bootstrap gap 2026-03-28 23:05:21 +07:00
github-actions[bot]
01bab92fed fix: update lockfile signatures and Nix hash 2026-03-28 14:42:25 +00:00
Mohamed Boudra
dae677edeb Merge branch 'main' of github.com:getpaseo/paseo 2026-03-28 21:41:19 +07:00
Mohamed Boudra
03a5323755 Merge branch 'initial-chat' 2026-03-28 21:39:45 +07:00
Mohamed Boudra
2e9f935dbc Redesign landing page with interactive mockups and new sections 2026-03-28 21:39:37 +07:00
Mohamed Boudra
93845db70a Extract chat mention handling from session 2026-03-28 21:38:53 +07:00
Mohamed Boudra
d5085294df fix(desktop): update banner dismiss button and install feedback
Move the X dismiss button to a floating circle in the top-left corner
so it no longer overlaps the banner text. Show the banner during
"installing" and "error" states so the user gets feedback after
clicking "Install & restart" — previously the banner vanished because
those statuses were not in the render condition. Add a retry button
for the error state.
2026-03-28 18:43:49 +07:00
Mohamed Boudra
a3aed9b8b6 Fix desktop terminal link and context menu behavior 2026-03-28 11:14:43 +07:00
Mohamed Boudra
7ca428677c Make chat mentions inline 2026-03-28 10:51:22 +07:00
Mohamed Boudra
9ad3e7661b Fix mobile sidebar reset after theme switch (#152) 2026-03-28 11:50:24 +08:00
Mohamed Boudra
05a4e8f5a3 fix(cli): resolve daemon executable path without wmic 2026-03-28 10:47:53 +07:00
Mohamed Boudra
1b0fe4aa47 fix(server): preserve packaged node runtime for supervised daemon worker 2026-03-28 09:26:32 +07:00
Mohamed Boudra
29876acae5 feat(skills): rewrite skills to use CLI, reframe orchestrator as chat-first team lead
- paseo: add loop, schedule, chat command reference sections
- paseo-loop: replace loop.sh with paseo loop CLI, document model selection and archive
- paseo-chat: replace chat.sh with paseo chat CLI
- paseo-orchestrator: reframe as team orchestrator using chat rooms as coordination backbone,
  agents as disposable workers, schedule heartbeat for oversight
- paseo-handoff/committee: fix --mode bypass → --mode bypassPermissions
- Delete loop.sh (541 lines) and chat.sh (635 lines) bash scripts
2026-03-28 01:13:42 +07:00
Mohamed Boudra
657b3e1ccd feat(loop): add worker/verifier model selection and archive support
Loop runs can now specify separate provider/model for worker and verifier
agents (--provider, --model, --verify-provider, --verify-model). The
--archive flag preserves agent conversation history after each iteration
instead of destroying them.
2026-03-28 01:10:05 +07:00
Mohamed Boudra
a2ab0af3f1 feat(cli): distinguish local and connected daemon status 2026-03-28 00:31:18 +07:00
Mohamed Boudra
bb9e181c20 fix(desktop): remove packaged preload log bridge import 2026-03-28 00:25:23 +07:00
Mohamed Boudra
7bc1f04e27 fix(desktop): restore windows daemon bootstrap logging 2026-03-28 00:25:23 +07:00
Mohamed Boudra
394b9cac13 fix: hardcode paseo://app as allowed CORS origin in daemon bootstrap
The desktop app uses the paseo:// custom protocol scheme, but the
origin was only passed via PASEO_CORS_ORIGINS when the desktop started
the daemon itself. If the daemon was started by the CLI, the origin
was missing and the Electron renderer's WebSocket connection was
rejected.

Also removes file:// and null from allowed origins — any page loaded
via file:// could connect to the daemon, which is a security gap.
2026-03-28 00:25:23 +07:00
Mohamed Boudra
be41911083 feat: add loop, schedule, and chat CLI commands (#149)
* Add metrics collection and terminal performance tests

* feat: add loop, schedule, and chat commands with slash-namespaced RPC

Introduce three new server-side features with CLI command groups:

- `paseo loop` — iterative agent execution with verify-check/verify-prompt
- `paseo schedule` — recurring tasks on interval or cron cadence
- `paseo chat` — chat rooms for agent-to-agent coordination

All features use new slash-namespaced RPC methods (e.g. `loop/run`,
`schedule/create`, `chat/post`) instead of flat message types, backed
by file-based persistence and wired through the existing WebSocket
session dispatch.

* test: stabilize loop schedule chat rollout
2026-03-28 00:25:04 +07:00
github-actions[bot]
8f11902cb7 fix: update lockfile signatures and Nix hash 2026-03-27 14:13:21 +00:00
173 changed files with 12491 additions and 3388 deletions

View File

@@ -9,6 +9,8 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:

View File

@@ -1,5 +1,21 @@
# Changelog
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed

View File

@@ -26,20 +26,32 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
## Getting Started
### Desktop app
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
Run the daemon on any machine:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
```bash
npm install -g @getpaseo/cli
paseo
```
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)

View File

@@ -31,6 +31,14 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-jBl1E7cwEf5XKXFQip/ZlEa/zNQoz0Hq5t0hqjp8Qe4=";
npmDepsHash = "sha256-Cz3xidzBIWER4ktn3wWzT9PDm9PnipVA7XnsTQC440U=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -114,7 +114,7 @@ buildNpmPackage rec {
fi
done
# Copy server scripts (daemon-runner, supervisor) needed by CLI
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/

58
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.37",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.37",
"version": "0.1.38",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -25126,6 +25126,15 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
@@ -34851,16 +34860,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.37",
"version": "0.1.38",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.37",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.38",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34976,11 +34985,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.37",
"version": "0.1.38",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/relay": "0.1.38",
"@getpaseo/server": "0.1.38",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35021,11 +35030,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.37",
"version": "0.1.38",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.37",
"@getpaseo/server": "0.1.37",
"@getpaseo/cli": "0.1.38",
"@getpaseo/server": "0.1.38",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35059,7 +35068,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.37",
"version": "0.1.38",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35260,7 +35269,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.37",
"version": "0.1.38",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35286,7 +35295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.37",
"version": "0.1.38",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35302,13 +35311,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.37",
"version": "0.1.38",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.37",
"@getpaseo/relay": "0.1.37",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/relay": "0.1.38",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35351,6 +35361,15 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
"integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=12"
}
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
@@ -35696,13 +35715,14 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.37",
"version": "0.1.38",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, ActivityIndicator, Text } from "react-native";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -26,12 +26,13 @@ import {
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -68,6 +69,7 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -79,7 +81,18 @@ import {
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
const HostRuntimeBootstrapContext = createContext(false);
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
function PushNotificationRouter() {
const router = useRouter();
@@ -207,49 +220,99 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
useEffect(() => {
let cancelled = false;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
await store.bootstrap({ manageBuiltInDaemon: true });
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
}
return;
}
if (cancelled) {
return;
}
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
if (!cancelled) {
setPhase("online");
setError(null);
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init()
.then(() => {
if (!cancelled) {
setReady(true);
}
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setReady(true);
}
});
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
return () => {
cancelled = true;
};
}, []);
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
return (
<HostRuntimeBootstrapContext.Provider value={ready}>
<HostRuntimeBootstrapContext.Provider value={state}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
function useStoreReady(): boolean {
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
return useContext(HostRuntimeBootstrapContext);
}
@@ -410,26 +473,30 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const isLoading = settingsLoading || !storeReady;
const systemColorScheme = useColorScheme();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (isLoading) return;
if (settingsLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
}
}, [isLoading, settings.theme]);
}, [settingsLoading, settings.theme]);
if (isLoading) {
const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
}
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
});
}, [settingsLoading, resolvedTheme]);
return (
<VoiceProvider>
@@ -532,6 +599,38 @@ function FaviconStatusSync() {
return null;
}
function RootStack() {
const storeReady = useStoreReady();
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="index" />
</Stack>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
@@ -552,57 +651,6 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
@@ -619,28 +667,7 @@ export default function RootLayout() {
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
<RootStack />
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>

View File

@@ -1,18 +1,65 @@
import { useEffect } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHosts,
} from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [pathname, router]);
return null;
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}

View File

@@ -8,6 +8,7 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -609,6 +610,7 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -709,6 +711,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
@@ -719,6 +732,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});

View File

@@ -10,6 +10,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
borderless?: boolean;
}
interface SidebarMenuToggleProps {
@@ -75,7 +76,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -90,6 +91,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
}
right={rightContent}
leftStyle={styles.left}
borderless={borderless}
/>
);
}

View File

@@ -15,13 +15,14 @@ interface ScreenHeaderProps {
right?: ReactNode;
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
}
/**
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -47,6 +48,7 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
},
borderless && styles.borderless,
]}
{...dragHandlers}
>
@@ -87,4 +89,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
borderless: {
borderBottomWidth: 0,
},
}));

View File

@@ -520,6 +520,7 @@ function MobileSidebar({
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
@@ -556,7 +557,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -581,7 +582,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -701,6 +702,7 @@ function DesktopSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
/>
)}
@@ -736,7 +738,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -761,7 +763,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}

View File

@@ -36,6 +36,7 @@ import {
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -73,6 +74,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -113,6 +115,7 @@ interface SidebarWorkspaceListProps {
isRefreshing?: boolean;
onRefresh?: () => void;
onWorkspacePress?: () => void;
onAddProject?: () => void;
listFooterComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -1631,6 +1634,7 @@ export function SidebarWorkspaceList({
isRefreshing = false,
onRefresh,
onWorkspacePress,
onAddProject,
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
@@ -1900,7 +1904,18 @@ export function SidebarWorkspaceList({
const content = (
<>
{projects.length === 0 ? (
<Text style={styles.emptyText}>No projects yet</Text>
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={onAddProject}
>
Add project
</Button>
</View>
) : (
<DraggableList
testID="sidebar-project-list"
@@ -1963,11 +1978,27 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
emptyContainer: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
emptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginTop: theme.spacing[8],
marginHorizontal: theme.spacing[2],
},
projectRow: {
minHeight: 36,

View File

@@ -565,6 +565,18 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
}
const hasSelection = Boolean(window.getSelection()?.toString());
void showContextMenu({
kind: "terminal",
hasSelection,
});
};
return (
<div
@@ -586,6 +598,10 @@ export default function TerminalEmulator({
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
>
<div
ref={hostRef}

View File

@@ -1,11 +1,19 @@
import type { PropsWithChildren, ReactElement } from "react";
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "sm" | "md" | "lg";
type LeftIcon =
| ReactElement
| ComponentType<{ color: string; size: number }>
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
@@ -67,6 +75,12 @@ const styles = StyleSheet.create((theme) => ({
textDestructive: {
color: theme.colors.palette.white,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
export function Button({
@@ -83,11 +97,14 @@ export function Button({
Omit<PressableProps, "style"> & {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: ReactElement | null;
leftIcon?: LeftIcon;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle =
variant === "default"
? styles.default
@@ -100,19 +117,47 @@ export function Button({
: styles.destructive;
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
];
function renderIcon() {
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return <View><Icon color={color} size={iconSize} /></View>;
}
return (
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [
styles.base,
sizeStyle,
@@ -122,7 +167,7 @@ export function Button({
style,
]}
>
{leftIcon ? <View>{leftIcon}</View> : null}
{renderIcon()}
<Text style={resolvedTextStyle}>{children}</Text>
</Pressable>
);

View File

@@ -278,7 +278,10 @@ export function DropdownMenuContent({
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
setClosing(true);
// Avoid leaving an invisible full-screen Modal mounted on native when
// the exit animation callback does not fire.
setClosing(false);
setModalVisible(false);
}
}, [open, modalVisible]);

View File

@@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => {

View File

@@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
return;
}
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
if (isOpen) {
translateX.value = withTiming(0, {
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => {

View File

@@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string;
listen: string | null;
hostname: string | null;
pid: number | null;
home: string;
@@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
return {
serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen) ?? "",
listen: toStringOrNull(raw.listen),
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",

View File

@@ -27,3 +27,12 @@ export async function isDesktopFullscreen(): Promise<boolean> {
}
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
return;
}
await win.setTitleBarTheme(theme);
}

View File

@@ -37,6 +37,13 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopMenuBridge {
showContextMenu?: (input?: {
kind?: "terminal";
hasSelection?: boolean;
}) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -44,6 +51,7 @@ export interface DesktopWindowBridge {
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -73,6 +81,7 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
}
declare global {

View File

@@ -10,8 +10,15 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const {
isDesktop,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -33,24 +40,36 @@ export function UpdateBanner() {
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed") return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.banner}>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<View style={styles.textSection}>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
</View>
<View style={styles.actions}>
@@ -61,7 +80,7 @@ export function UpdateBanner() {
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && (
{!isInstalled && !isError && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
@@ -76,6 +95,15 @@ export function UpdateBanner() {
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
@@ -109,15 +137,21 @@ const styles = StyleSheet.create((theme) => ({
},
closeButton: {
position: "absolute",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,

View File

@@ -13,6 +13,7 @@ import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
useFormPreferences,
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
@@ -693,26 +694,23 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
const nextProviderPrefs: ProviderPreferences = {
model: modelId || undefined,
mode: formState.modeId || undefined,
thinkingByModel: {
...existingProviderPrefs?.thinkingByModel,
...(modelId && formState.thinkingOptionId
? { [modelId]: formState.thinkingOptionId }
: {}),
},
};
await updatePreferences({
const nextPreferences = mergeProviderPreferences({
preferences: preferences ?? {},
provider: formState.provider,
providerPreferences: {
...preferences?.providerPreferences,
[formState.provider]: nextProviderPrefs,
},
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
});
await updatePreferences(nextPreferences);
}, [
availableModels,
formState.model,

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
expect(
mergeProviderPreferences({
preferences: {},
provider: "claude",
updates: { model: "claude-opus-4-6" },
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-opus-4-6",
},
},
});
});
it("merges thinking preferences by model without dropping existing entries", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
},
},
},
},
provider: "claude",
updates: {
thinkingByModel: {
"claude-opus-4-6": "high",
},
},
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
"claude-opus-4-6": "high",
},
},
},
});
});
});

View File

@@ -2,6 +2,7 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
@@ -35,6 +36,36 @@ export interface UseFormPreferencesReturn {
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
},
},
};
}
export function useFormPreferences(): UseFormPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({

View File

@@ -30,6 +30,16 @@ describe("use-settings", () => {
);
});
it("defaults theme to auto when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.theme).toBe("auto");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {

View File

@@ -12,7 +12,7 @@ export interface AppSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "dark",
theme: "auto",
manageBuiltInDaemon: true,
};

View File

@@ -15,7 +15,10 @@ import {
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import {
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
@@ -33,6 +36,10 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRuntimeBootstrapResult =
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
| { ok: false; error: string };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1073,6 +1080,7 @@ const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1161,20 +1169,40 @@ export class HostRuntimeStore {
}
}
private async bootstrapDesktop(): Promise<void> {
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection || !daemon.serverId) {
return;
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return {
ok: false,
error: "Desktop daemon did not return a listen address.",
};
}
await this.upsertHostConnection({
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
});
if (!serverId) {
return {
ok: false,
error: "Desktop daemon did not return a server id.",
};
}
if (!connectionFromListen(listenAddress)) {
return {
ok: false,
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
};
}
return {
ok: true,
listenAddress,
serverId,
hostname: daemon.hostname,
};
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
return {
ok: false,
error: toErrorMessage(error),
};
}
}
@@ -1280,6 +1308,36 @@ export class HostRuntimeStore {
return this.upsertConnectionFromOffer(offer);
}
async addConnectionFromListenAndWaitForOnline(input: {
listenAddress: string;
serverId: string;
hostname: string | null;
timeoutMs?: number;
}): Promise<HostProfile> {
const normalizedListenAddress = input.listenAddress.trim();
const serverId = input.serverId.trim();
const connection = connectionFromListen(normalizedListenAddress);
if (!connection) {
throw new Error(`Unsupported listen address: ${input.listenAddress}`);
}
if (!serverId) {
throw new Error("Desktop daemon did not return a server id.");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.hostname ?? undefined,
connection,
});
await this.waitForConnectionOnline({
serverId,
connectionId: connection.id,
timeoutMs: input.timeoutMs,
});
return profile;
}
async renameHost(serverId: string, label: string): Promise<void> {
const next = this.hosts.map((h) =>
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
@@ -1441,6 +1499,100 @@ export class HostRuntimeStore {
}
}
private waitForConnectionOnline(input: {
serverId: string;
connectionId: string;
timeoutMs?: number;
}): Promise<void> {
const { serverId, connectionId } = input;
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (unsubscribe: (() => void) | null): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
unsubscribe?.();
};
const settle = (
unsubscribe: (() => void) | null,
outcome: { ok: true } | { ok: false; error: Error },
): void => {
if (settled) {
return;
}
settled = true;
cleanup(unsubscribe);
if (outcome.ok) {
resolve();
} else {
reject(outcome.error);
}
};
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
const snapshot = this.getSnapshot(serverId);
if (!snapshot) {
return {
ok: false,
error: new Error(`Unknown host runtime for serverId ${serverId}`),
};
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "online"
) {
return { ok: true };
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "error"
) {
return {
ok: false,
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
};
}
return null;
};
const unsubscribe = this.subscribe(serverId, () => {
const outcome = readSnapshot();
if (outcome) {
settle(unsubscribe, outcome);
}
});
timeoutHandle = setTimeout(() => {
settle(unsubscribe, {
ok: false,
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
});
}, timeoutMs);
const initialOutcome = readSnapshot();
if (initialOutcome) {
settle(unsubscribe, initialOutcome);
return;
}
void this.runProbeCycleNow(serverId).catch((error) => {
settle(unsubscribe, {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
});
});
});
}
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {

View File

@@ -1,27 +1,22 @@
import { useEffect } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { View, Text } from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { useSessionStore } from "@/stores/session-store";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const trafficLightPadding = useTrafficLightPadding();
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
const openAgentList = usePanelStore((s) => s.openAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
@@ -32,22 +27,24 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: collapsedSidebarInset.left, paddingRight: collapsedSidebarInset.right }]}>
<SidebarMenuToggle />
</View>
<MenuHeader borderless />
<View style={styles.content}>
<PaseoLogo size={56} />
<Text style={styles.heading}>What shall we build today?</Text>
<Pressable
style={({ hovered }) => [styles.openButton, hovered && styles.openButtonHovered]}
onPress={() => {
void openProjectPicker();
}}
testID="open-project-submit"
>
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.openButtonText}>Add a project</Text>
</Pressable>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
Add a project
</Button>
</View>
</View>
</View>
);
@@ -59,43 +56,32 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
menuToggle: {
position: "absolute",
top: theme.spacing[3],
left: theme.spacing[3],
zIndex: 1,
},
content: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
gap: 0,
padding: theme.spacing[6],
},
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
},
heading: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
openButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
openButtonHovered: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
openButtonText: {
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));

View File

@@ -1,10 +1,14 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View } from "react-native";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
const isFocused = useIsFocused();
@@ -44,13 +48,26 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title="Sessions" />
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
{sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
>
Back
</Button>
</View>
) : (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
)}
</View>
);
}
@@ -60,4 +77,15 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
},
}));

View File

@@ -896,12 +896,6 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
},
[updateSettings],
);

View File

@@ -1,29 +1,318 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
type StartupSplashScreenProps = {
bootstrapState?: {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
};
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
},
status: {
containerError: {
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
maxWidth: 520,
width: "100%",
},
errorContent: {
alignItems: "stretch",
maxWidth: 720,
width: "100%",
gap: theme.spacing[6],
},
errorHeader: {
alignItems: "flex-start",
},
title: {
marginTop: theme.spacing[8],
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
titleError: {
textAlign: "left",
},
subtitleRow: {
marginTop: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
progressSteps: {
marginTop: theme.spacing[4],
gap: theme.spacing[3],
width: "100%",
},
progressStepRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
subtitle: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
subtitleInline: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
errorDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
errorMessage: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontFamily: Fonts.mono,
},
logsMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
logsContainer: {
height: 200,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
logsScroll: {
flexGrow: 0,
},
logsContent: {
padding: theme.spacing[4],
},
logsText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[3],
flexWrap: "wrap",
},
}));
export function StartupSplashScreen() {
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
useEffect(() => {
if (!isError) {
setDaemonLogs(null);
setLogsError(null);
setIsLoadingLogs(false);
return;
}
let isCancelled = false;
setIsLoadingLogs(true);
setLogsError(null);
void getDesktopDaemonLogs()
.then((logs) => {
if (isCancelled) {
return;
}
setDaemonLogs(logs);
})
.catch((error) => {
if (isCancelled) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setDaemonLogs(null);
setLogsError(`Unable to load daemon logs: ${message}`);
})
.finally(() => {
if (!isCancelled) {
setIsLoadingLogs(false);
}
});
return () => {
isCancelled = true;
};
}, [isError]);
const progressSteps =
phase === "starting-daemon"
? [{ key: "starting-daemon", label: "Starting local server...", status: "active" as const }]
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connecting to local server...", status: "active" as const },
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connected to local server", status: "complete" as const },
];
const logsText = useMemo(() => {
if (isLoadingLogs) {
return "Loading daemon logs...";
}
if (daemonLogs?.contents) {
return daemonLogs.contents;
}
if (logsError) {
return logsError;
}
return "No daemon logs available.";
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
const handleCopyLogs = () => {
const payload = daemonLogs?.logPath
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
: logsText;
void Clipboard.setStringAsync(payload);
};
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up</Text>
</View>
);
}
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<View style={styles.progressSteps}>
{progressSteps.map((step) => (
<View key={step.key} style={styles.progressStepRow}>
{step.status === "complete" ? (
<Check size={18} color={theme.colors.success} />
) : (
<ActivityIndicator color={theme.colors.accent} />
)}
<Text style={styles.subtitleInline}>{step.label}</Text>
</View>
))}
</View>
</View>
</View>
);
}
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up</Text>
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
</View>
);
}

View File

@@ -8,6 +8,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -168,7 +169,12 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void openExternalUrl(uri);
}),
);
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});

View File

@@ -0,0 +1,41 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}

View File

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

View File

@@ -2,8 +2,11 @@ import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
@@ -137,6 +140,15 @@ export function createCli(): Command {
// Daemon commands
program.addCommand(createDaemonCommand());
// Chat commands
program.addCommand(createChatCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
// Permission commands
program.addCommand(createPermitCommand());

View File

@@ -0,0 +1,31 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export interface ChatCreateOptions extends ChatCommandOptions {
purpose?: string;
}
export async function runCreateCommand(
name: string,
options: ChatCreateOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.createChatRoom({
name,
purpose: options.purpose,
});
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,24 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runDeleteCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.deleteChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,69 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runDeleteCommand } from "./delete.js";
import { runPostCommand } from "./post.js";
import { runReadCommand } from "./read.js";
import { runWaitCommand } from "./wait.js";
export function createChatCommand(): Command {
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
addJsonAndDaemonHostOptions(
chat
.command("create")
.description("Create a chat room")
.argument("<name>", "Room name (must be unique)")
.option("--purpose <text>", "Room purpose/description"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
chat.command("ls").description("List chat rooms"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
chat
.command("inspect")
.description("Inspect a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
chat
.command("delete")
.description("Delete a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
chat
.command("post")
.description("Post a chat message")
.argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(
chat
.command("read")
.description("Read chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--limit <n>", "Maximum number of messages to return")
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
.option("--agent <agent-id>", "Filter by author agent ID"),
).action(withOutput(runReadCommand));
addJsonAndDaemonHostOptions(
chat
.command("wait")
.description("Wait for new chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--timeout <duration>", "Maximum wait time"),
).action(withOutput(runWaitCommand));
return chat;
}

View File

@@ -0,0 +1,24 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runInspectCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.inspectChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,23 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runLsCommand(
options: ChatCommandOptions,
_command: Command,
): Promise<ListResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.listChatRooms();
return {
type: "list",
data: payload.rooms.map(toChatRoomRow),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,41 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
resolveChatAuthorAgentId,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatPostOptions extends ChatCommandOptions {
replyTo?: string;
}
export async function runPostCommand(
room: string,
body: string,
options: ChatPostOptions,
_command: Command,
): Promise<SingleResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.postChatMessage({
room,
body,
authorAgentId: resolveChatAuthorAgentId(),
replyToMessageId: options.replyTo,
});
const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]);
return {
type: "single",
data: message!,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,60 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseSinceValue,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatReadOptions extends ChatCommandOptions {
limit?: string;
since?: string;
agent?: string;
}
function parseLimit(value?: string): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw {
code: "INVALID_LIMIT",
message: "Invalid --limit value",
details: "Use a non-negative integer.",
};
}
return parsed;
}
export async function runReadCommand(
room: string,
options: ChatReadOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.readChatMessages({
room,
limit: parseLimit(options.limit),
since: parseSinceValue(options.since),
authorAgentId: options.agent,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,126 @@
import type { OutputSchema } from "../../output/index.js";
import type { AnyCommandResult, OutputOptions } from "../../output/index.js";
export interface ChatRoomRow {
name: string;
id: string;
purpose: string;
messages: number;
lastMessageAt: string;
}
export interface ChatMessageRow {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
export const chatRoomSchema: OutputSchema<ChatRoomRow> = {
idField: "id",
columns: [
{ header: "NAME", field: "name", width: 22 },
{ header: "ID", field: "id", width: 36 },
{ header: "PURPOSE", field: "purpose", width: 30 },
{ header: "MESSAGES", field: "messages", width: 10, align: "right" },
{ header: "LAST MESSAGE", field: "lastMessageAt", width: 24 },
],
};
export const chatMessageSchema: OutputSchema<ChatMessageRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 36 },
{ header: "AUTHOR", field: "author", width: 16 },
{ header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 },
{ header: "CREATED", field: "createdAt", width: 24 },
{ header: "REPLY TO", field: "replyTo", width: 36 },
{ header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 },
{ header: "MESSAGE", field: "body", width: 60 },
],
renderHuman: renderChatTranscript,
};
function renderChatTranscript(
result: AnyCommandResult<ChatMessageRow>,
_options: OutputOptions,
): string {
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return "";
}
return data.map(renderChatMessageBlock).join("\n\n");
}
function renderChatMessageBlock(message: ChatMessageRow): string {
const authorLabel = message.authorName
? `${message.authorName} (${message.author})`
: message.author;
const lines = [`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`];
if (message.replyTo !== "-") {
lines.push(`│ reply-to: msg ${message.replyTo}`);
}
if (message.mentionLabels.length > 0) {
lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`);
}
lines.push("│");
const bodyLines = message.body.split(/\r?\n/);
for (const line of bodyLines) {
lines.push(`${line}`);
}
lines.push("│");
lines.push("└─");
return lines.join("\n");
}
function formatTimestamp(input: string): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) {
return input;
}
return date.toISOString().replace("T", " ").replace(".000Z", "Z");
}
export function toChatRoomRow(room: {
id: string;
name: string;
purpose: string | null;
messageCount: number;
lastMessageAt: string | null;
}): ChatRoomRow {
return {
id: room.id,
name: room.name,
purpose: room.purpose ?? "-",
messages: room.messageCount,
lastMessageAt: room.lastMessageAt ?? "-",
};
}
export function toChatMessageRow(message: {
id: string;
authorAgentId: string;
createdAt: string;
replyToMessageId: string | null;
mentionAgentIds: string[];
body: string;
}): ChatMessageRow {
return {
id: message.id,
author: message.authorAgentId,
authorName: null,
createdAt: message.createdAt,
replyTo: message.replyToMessageId ?? "-",
mentionAgentIds: message.mentionAgentIds,
mentionLabels: message.mentionAgentIds,
body: message.body,
};
}

View File

@@ -0,0 +1,127 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { parseDuration } from "../../utils/duration.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { ChatMessageRow } from "./schema.js";
export interface ChatCommandOptions extends CommandOptions {
host?: string;
}
export async function connectChatClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
agentIds.add(message.author);
for (const mentionId of message.mentionAgentIds) {
agentIds.add(mentionId);
}
}
if (agentIds.size === 0) {
return messages;
}
const payload = await client.fetchAgents({
filter: { includeArchived: true },
});
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();
if (title) {
agentNames.set(entry.agent.id, title);
}
}
return messages.map((message) => ({
...message,
authorName: agentNames.get(message.author) ?? null,
mentionLabels: message.mentionAgentIds.map((agentId) => {
const name = agentNames.get(agentId);
return name ? `${name} (${agentId})` : agentId;
}),
}));
}
export function toChatCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export function parseSinceValue(input?: string): string | undefined {
if (!input) {
return undefined;
}
try {
const durationMs = parseDuration(input);
return new Date(Date.now() - durationMs).toISOString();
} catch {
const timestamp = new Date(input);
if (Number.isNaN(timestamp.getTime())) {
const error: CommandError = {
code: "INVALID_SINCE",
message: "Invalid --since value",
details: "Use a duration like 10m or an ISO timestamp.",
};
throw error;
}
return timestamp.toISOString();
}
}
export function parseTimeoutMs(input?: string): number | undefined {
if (!input) {
return undefined;
}
try {
const timeoutMs = parseDuration(input);
if (timeoutMs <= 0) {
throw new Error("Timeout must be positive");
}
return timeoutMs;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: "Invalid timeout value",
details: message,
};
throw error;
}
}
export function resolveChatAuthorAgentId(): string {
const agentId = process.env.PASEO_AGENT_ID?.trim();
return agentId && agentId.length > 0 ? agentId : "manual";
}

View File

@@ -0,0 +1,47 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseTimeoutMs,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const latest = await client.readChatMessages({
room,
limit: 1,
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: parseTimeoutMs(options.timeout),
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -124,11 +124,16 @@ function resolveDaemonRunnerEntry(): string {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") {
const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js");
const distRunner = path.join(
currentDir,
"dist",
"scripts",
"supervisor-entrypoint.js",
);
if (existsSync(distRunner)) {
return distRunner;
}
return path.join(currentDir, "scripts", "daemon-runner.ts");
return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
}
} catch {
// Continue searching up if package.json exists but is invalid.

View File

@@ -35,29 +35,75 @@ function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const result = spawnSync(
"wmic",
["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
function runProcessProbe(command: string, args: string[]): {
resolved: string | null;
error?: string;
} {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` };
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
return {
nodePath: null,
error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`,
resolved: null,
error: details ? `${command} failed: ${details}` : `${command} exited with code ${result.status ?? 1}`,
};
}
// wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n"
const match = result.stdout.match(/ExecutablePath=(.+)/);
const resolved = match?.[1]?.trim();
return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" };
const resolved = result.stdout.trim();
return resolved ? { resolved } : { resolved: null, error: `${command} returned no executable path` };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const probes: Array<{ label: string; command: string; args: string[]; parseValue?: (stdout: string) => string | null }> = [
{
label: "powershell-cim",
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").ExecutablePath`,
],
},
{
label: "powershell-process",
command: "powershell",
args: ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).Path`],
},
{
label: "wmic",
command: "wmic",
args: ["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
parseValue: (stdout) => {
const match = stdout.match(/ExecutablePath=(.+)/);
return match?.[1]?.trim() ?? null;
},
},
];
const errors: string[] = [];
for (const probe of probes) {
const result = runProcessProbe(probe.command, probe.args);
if (result.resolved) {
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
if (resolved) {
return { nodePath: resolved };
}
errors.push(`${probe.label} returned no executable path`);
continue;
}
if (result.error) {
errors.push(`${probe.label}: ${result.error}`);
}
}
return { nodePath: null, error: errors.join("; ") || "could not resolve executable path from PID" };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {

View File

@@ -15,7 +15,8 @@ interface ProviderBinaryStatus {
interface DaemonStatus {
serverId: string | null;
status: "running" | "stopped" | "unresponsive";
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "not_probed";
home: string;
listen: string;
hostname: string | null;
@@ -85,11 +86,16 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === "Status") {
if (item.key === "Local Daemon") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green";
if (item.value === "not_probed") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
if (item.value === "not found") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
@@ -106,7 +112,8 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Status", value: status.status },
{ key: "Local Daemon", value: status.localDaemon },
{ key: "Connected Daemon", value: status.connectedDaemon },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Hostname", value: status.hostname ?? "-" },
@@ -199,6 +206,7 @@ export async function runStatusCommand(
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode: string;
@@ -211,38 +219,60 @@ export async function runStatusCommand(
daemonNode = "unknown (no PID available)";
}
const cliNode = process.execPath;
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
localDaemon = "stale_pid";
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (state.running) {
const host = resolveTcpHostFromListen(state.listen);
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
} catch {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
} finally {
await client.close().catch(() => {});
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
connectedDaemon = "reachable";
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
if (!state.running) {
daemonNode = "unknown (API reachable, PID unresolved)";
note = appendNote(
note,
state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
);
}
} else {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
} catch {
if (state.running) {
localDaemon = "unresponsive";
}
note = appendNote(
note,
state.running
? `Local daemon PID is running but API requests to ${host} failed`
: `Connected daemon websocket is reachable at ${host} but fetch_agents failed`,
);
} finally {
await client.close().catch(() => {});
}
} else if (state.running) {
connectedDaemon = "unreachable";
localDaemon = "unresponsive";
note = appendNote(
note,
`Local daemon PID is running but websocket at ${host} is not reachable`,
);
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
connectedDaemon = "unreachable";
}
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
const cliVersion = resolveCliVersion();
@@ -258,7 +288,8 @@ export async function runStatusCommand(
const daemonStatus: DaemonStatus = {
serverId,
status,
localDaemon,
connectedDaemon,
home: state.home,
listen: state.listen,
hostname: state.pidInfo?.hostname ?? null,

View File

@@ -0,0 +1,34 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js";
import { addLoopRunOptions, runLoopRunCommand } from "./run.js";
import { addLoopLsOptions, runLoopLsCommand } from "./ls.js";
import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js";
import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js";
import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
export function createLoopCommand(): Command {
const loop = new Command("loop").description("Run iterative worker loops");
addJsonAndDaemonHostOptions(
addLoopRunOptions(loop.command("run")),
).action(withOutput(runLoopRunCommand));
addJsonAndDaemonHostOptions(
addLoopLsOptions(loop.command("ls")),
).action(withOutput(runLoopLsCommand));
addJsonAndDaemonHostOptions(
addLoopInspectOptions(loop.command("inspect")),
).action(withOutput(runLoopInspectCommand));
addDaemonHostOption(
addLoopLogsOptions(loop.command("logs")),
).action(runLoopLogsCommand);
addJsonAndDaemonHostOptions(
addLoopStopOptions(loop.command("stop")),
).action(withOutput(runLoopStopCommand));
return loop;
}

View File

@@ -0,0 +1,118 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
ListResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface InspectRow {
key: string;
value: string;
}
export interface LoopInspectOptions extends CommandOptions {}
export function addLoopInspectOptions(command: Command): Command {
return command.description("Show loop details and iteration history").argument("<id>", "Loop ID");
}
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => loop,
};
}
function toRows(loop: LoopRecord): InspectRow[] {
return [
{ key: "Id", value: loop.id },
{ key: "Name", value: loop.name ?? "null" },
{ key: "Status", value: loop.status },
{ key: "Cwd", value: loop.cwd },
{ key: "Provider", value: loop.provider },
{ key: "Model", value: loop.model ?? "null" },
{ key: "WorkerProvider", value: loop.workerProvider ?? "null" },
{ key: "WorkerModel", value: loop.workerModel ?? "null" },
{ key: "VerifierProvider", value: loop.verifierProvider ?? "null" },
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
{ key: "Prompt", value: loop.prompt },
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
{ key: "VerifyChecks", value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]" },
{ key: "Archive", value: String(loop.archive) },
{ key: "SleepMs", value: String(loop.sleepMs) },
{ key: "MaxIterations", value: loop.maxIterations === null ? "null" : String(loop.maxIterations) },
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
{ key: "CreatedAt", value: loop.createdAt },
{ key: "UpdatedAt", value: loop.updatedAt },
{ key: "CompletedAt", value: loop.completedAt ?? "null" },
{
key: "Iterations",
value:
loop.iterations.length === 0
? "[]"
: loop.iterations
.map((iteration) => {
const summary = [
`#${iteration.index}`,
iteration.status,
iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null,
iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null,
iteration.failureReason ? `reason=${iteration.failureReason}` : null,
]
.filter(Boolean)
.join(" ");
return summary;
})
.join(" | "),
},
];
}
export type LoopInspectResult = ListResult<InspectRow>;
export async function runLoopInspectCommand(
id: string,
options: LoopInspectOptions,
_command: Command,
): Promise<LoopInspectResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopInspect(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "list",
data: toRows(payload.loop),
schema: createInspectSchema(payload.loop),
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_INSPECT_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}

View File

@@ -0,0 +1,82 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { LoopDaemonClient, LoopLogEntry } from "./types.js";
export interface LoopLogsOptions extends CommandOptions {
pollInterval?: string;
}
export function addLoopLogsOptions(command: Command): Command {
return command
.description("Stream loop logs")
.argument("<id>", "Loop ID")
.option("--poll-interval <ms>", "Polling interval in milliseconds", "1000");
}
function parsePollInterval(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_POLL_INTERVAL",
message: "--poll-interval must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function renderLogEntry(entry: LoopLogEntry): string {
const prefix = [
entry.timestamp,
entry.source,
entry.iteration === null ? null : `iteration=${entry.iteration}`,
entry.level === "error" ? "ERROR" : null,
]
.filter(Boolean)
.join(" ");
return `${prefix}\n${entry.text}`;
}
export async function runLoopLogsCommand(
id: string,
options: LoopLogsOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host as string | undefined });
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
let cursor = 0;
try {
for (;;) {
const payload = await client.loopLogs(id, cursor);
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
cursor = payload.nextCursor;
for (const entry of payload.entries) {
console.log(renderLogEntry(entry));
}
if (payload.loop.status !== "running") {
await client.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Failed to stream loop logs: ${message}`);
process.exit(1);
}
}

View File

@@ -0,0 +1,88 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
ListResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopListItem } from "./types.js";
interface LoopListRow {
id: string;
name: string | null;
status: string;
cwd: string;
updated: string;
activeIteration: string;
}
export interface LoopLsOptions extends CommandOptions {}
export const loopLsSchema: OutputSchema<LoopListRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
{ header: "CWD", field: "cwd", width: 40 },
{ header: "UPDATED", field: "updated", width: 24 },
],
};
export function addLoopLsOptions(command: Command): Command {
return command.description("List loops");
}
function toRow(loop: LoopListItem): LoopListRow {
return {
id: loop.id,
name: loop.name,
status: loop.status,
cwd: loop.cwd,
updated: loop.updatedAt,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopLsResult = ListResult<LoopListRow>;
export async function runLoopLsCommand(
options: LoopLsOptions,
_command: Command,
): Promise<LoopLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopList();
await client.close();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.loops.map(toRow),
schema: loopLsSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_LIST_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}

View File

@@ -0,0 +1,161 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { parseDuration } from "../../utils/duration.js";
import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js";
export interface LoopRunRow {
id: string;
status: string;
name: string | null;
cwd: string;
}
export interface LoopRunOptions extends CommandOptions {
provider?: "claude" | "codex" | "opencode";
model?: string;
verifyProvider?: "claude" | "codex" | "opencode";
verifyModel?: string;
verify?: string;
verifyCheck?: string[];
archive?: boolean;
name?: string;
sleep?: string;
maxIterations?: string;
maxTime?: string;
}
export const loopRunSchema: OutputSchema<LoopRunRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CWD", field: "cwd", width: 40 },
],
};
export function addLoopRunOptions(command: Command): Command {
return command
.description("Start a loop")
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify <prompt>", "Verifier agent prompt")
.option(
"--verify-check <command>",
"Shell command that must exit 0 (repeatable)",
collectMultiple,
[],
)
.option("--archive", "Archive worker and verifier agents after each iteration")
.option("--name <name>", "Optional loop name")
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
.option("--max-iterations <n>", "Maximum number of iterations")
.option("--max-time <duration>", "Maximum total runtime (for example: 1h, 30m)");
}
function toRow(loop: LoopRecord): LoopRunRow {
return {
id: loop.id,
status: loop.status,
name: loop.name,
cwd: loop.cwd,
};
}
function parseMaxIterations(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_MAX_ITERATIONS",
message: "--max-iterations must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput {
const verifyPrompt = options.verify?.trim();
if (options.verify !== undefined && !verifyPrompt) {
throw {
code: "INVALID_VERIFY_PROMPT",
message: "--verify cannot be empty",
} satisfies CommandError;
}
return {
prompt,
cwd: process.cwd(),
...(options.provider ? { provider: options.provider } : {}),
...(options.model?.trim() ? { model: options.model.trim() } : {}),
...(options.verifyProvider ? { verifierProvider: options.verifyProvider } : {}),
...(options.verifyModel?.trim() ? { verifierModel: options.verifyModel.trim() } : {}),
...(verifyPrompt ? { verifyPrompt } : {}),
...(options.verifyCheck && options.verifyCheck.length > 0
? { verifyChecks: options.verifyCheck }
: {}),
...(options.archive ? { archive: true } : {}),
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(options.sleep ? { sleepMs: parseDuration(options.sleep) } : {}),
...(options.maxIterations ? { maxIterations: parseMaxIterations(options.maxIterations) } : {}),
...(options.maxTime ? { maxTimeMs: parseDuration(options.maxTime) } : {}),
};
}
export type LoopRunResult = SingleResult<LoopRunRow>;
export async function runLoopRunCommand(
prompt: string,
options: LoopRunOptions,
_command: Command,
): Promise<LoopRunResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
const input = buildLoopRunInput(prompt, options);
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopRun(input);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? "Loop creation failed");
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopRunSchema,
};
} catch (error) {
await client.close().catch(() => {});
if (error && typeof error === "object" && "code" in error) {
throw error;
}
throw {
code: "LOOP_RUN_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}

View File

@@ -0,0 +1,80 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface LoopStopRow {
id: string;
status: string;
activeIteration: string;
}
export interface LoopStopOptions extends CommandOptions {}
export const loopStopSchema: OutputSchema<LoopStopRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
],
};
export function addLoopStopOptions(command: Command): Command {
return command.description("Stop a running loop").argument("<id>", "Loop ID");
}
function toRow(loop: LoopRecord): LoopStopRow {
return {
id: loop.id,
status: loop.status,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopStopResult = SingleResult<LoopStopRow>;
export async function runLoopStopCommand(
id: string,
options: LoopStopOptions,
_command: Command,
): Promise<LoopStopResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopStop(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopStopSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_STOP_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}

View File

@@ -0,0 +1,147 @@
export type LoopStatus = "running" | "succeeded" | "failed" | "stopped";
export interface LoopLogEntry {
seq: number;
timestamp: string;
iteration: number | null;
source: "loop" | "worker" | "verifier" | "verify-check";
level: "info" | "error";
text: string;
}
export interface LoopVerifyCheckResult {
command: string;
exitCode: number;
passed: boolean;
stdout: string;
stderr: string;
startedAt: string;
completedAt: string;
}
export interface LoopVerifyPromptResult {
passed: boolean;
reason: string;
verifierAgentId: string | null;
startedAt: string;
completedAt: string;
}
export interface LoopIterationRecord {
index: number;
workerAgentId: string | null;
workerStartedAt: string;
workerCompletedAt: string | null;
verifierAgentId: string | null;
status: "running" | "succeeded" | "failed" | "stopped";
workerOutcome: "completed" | "failed" | "canceled" | null;
failureReason: string | null;
verifyChecks: LoopVerifyCheckResult[];
verifyPrompt: LoopVerifyPromptResult | null;
}
export interface LoopRecord {
id: string;
name: string | null;
prompt: string;
cwd: string;
provider: "claude" | "codex" | "opencode";
model: string | null;
workerProvider: "claude" | "codex" | "opencode" | null;
workerModel: string | null;
verifierProvider: "claude" | "codex" | "opencode" | null;
verifierModel: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
archive: boolean;
sleepMs: number;
maxIterations: number | null;
maxTimeMs: number | null;
status: LoopStatus;
createdAt: string;
updatedAt: string;
startedAt: string;
completedAt: string | null;
stopRequestedAt: string | null;
iterations: LoopIterationRecord[];
logs: LoopLogEntry[];
nextLogSeq: number;
activeIteration: number | null;
activeWorkerAgentId: string | null;
activeVerifierAgentId: string | null;
}
export interface LoopListItem {
id: string;
name: string | null;
status: LoopStatus;
cwd: string;
createdAt: string;
updatedAt: string;
activeIteration: number | null;
}
export interface LoopLogsResult {
loop: LoopRecord;
entries: LoopLogEntry[];
nextCursor: number;
}
export interface LoopRunPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopListPayload {
requestId: string;
loops: LoopListItem[];
error: string | null;
}
export interface LoopInspectPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopLogsPayload {
requestId: string;
loop: LoopRecord | null;
entries: LoopLogEntry[];
nextCursor: number;
error: string | null;
}
export interface LoopStopPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: "claude" | "codex" | "opencode";
model?: string;
workerProvider?: "claude" | "codex" | "opencode";
workerModel?: string;
verifierProvider?: "claude" | "codex" | "opencode";
verifierModel?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
name?: string;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
}
export interface LoopDaemonClient {
loopRun(input: LoopRunInput): Promise<LoopRunPayload>;
loopList(): Promise<LoopListPayload>;
loopInspect(id: string): Promise<LoopInspectPayload>;
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
loopStop(id: string): Promise<LoopStopPayload>;
close(): Promise<void>;
}

View File

@@ -0,0 +1,52 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
parseScheduleCreateInput,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}
export async function runCreateCommand(
prompt: string,
options: ScheduleCreateOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const input = parseScheduleCreateInput({
prompt,
every: options.every,
cron: options.cron,
name: options.name,
target: options.target,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
});
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleCreate(input);
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? "Schedule creation failed");
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_CREATE_FAILED", "create schedule", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,43 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import type { OutputSchema } from "../../output/index.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
interface ScheduleDeleteRow {
id: string;
status: string;
}
const scheduleDeleteSchema: OutputSchema<ScheduleDeleteRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 12 },
],
};
export async function runDeleteCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleDeleteRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "single",
data: {
id: payload.scheduleId,
status: "deleted",
},
schema: scheduleDeleteSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_DELETE_FAILED", "delete schedule", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,68 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runLogsCommand } from "./logs.js";
import { runPauseCommand } from "./pause.js";
import { runResumeCommand } from "./resume.js";
import { runDeleteCommand } from "./delete.js";
export function createScheduleCommand(): Command {
const schedule = new Command("schedule").description("Manage recurring schedules");
addJsonAndDaemonHostOptions(
schedule
.command("create")
.description("Create a schedule")
.argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
schedule.command("ls").description("List schedules"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("inspect")
.description("Inspect a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
schedule
.command("logs")
.description("Show recent schedule run logs")
.argument("<id>", "Schedule ID"),
).action(withOutput(runLogsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("pause")
.description("Pause a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runPauseCommand));
addJsonAndDaemonHostOptions(
schedule
.command("resume")
.description("Resume a paused schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runResumeCommand));
addJsonAndDaemonHostOptions(
schedule
.command("delete")
.description("Delete a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runDeleteCommand));
return schedule;
}

View File

@@ -0,0 +1,32 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
createScheduleInspectRows,
createScheduleInspectSchema,
type ScheduleInspectRow,
} from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runInspectCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleInspectRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Schedule not found: ${id}`);
}
const rows = createScheduleInspectRows(payload.schedule);
return {
type: "list",
data: rows,
schema: createScheduleInspectSchema(payload.schedule),
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_INSPECT_FAILED", "inspect schedule", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,27 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runLogsCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleLogRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleLogs({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.runs.map(toScheduleLogRow),
schema: scheduleLogSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_LOGS_FAILED", "read schedule logs", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,32 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runLsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.schedules.map(toScheduleRow),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_LIST_FAILED", "list schedules", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,33 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runPauseCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.schedulePause({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to pause schedule: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_PAUSE_FAILED", "pause schedule", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,33 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runResumeCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleResume({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to resume schedule: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_RESUME_FAILED", "resume schedule", error);
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,88 @@
import type { OutputSchema } from "../../output/index.js";
import { formatTarget, type ScheduleRow } from "./shared.js";
import type { ScheduleRecord, ScheduleRunRecord } from "./types.js";
export const scheduleSchema: OutputSchema<ScheduleRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CADENCE", field: "cadence", width: 20 },
{ header: "TARGET", field: "target", width: 20 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "NEXT RUN", field: "nextRunAt", width: 24 },
],
};
export interface ScheduleInspectRow {
key: string;
value: string;
}
export function createScheduleInspectSchema(record: ScheduleRecord): OutputSchema<ScheduleInspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => record,
};
}
export interface ScheduleLogRow {
id: string;
status: string;
startedAt: string;
agentId: string | null;
output: string | null;
error: string | null;
}
export const scheduleLogSchema: OutputSchema<ScheduleLogRow> = {
idField: "id",
columns: [
{ header: "RUN ID", field: "id", width: 14 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "STARTED", field: "startedAt", width: 24 },
{ header: "AGENT", field: "agentId", width: 12 },
{ header: "OUTPUT", field: "output", width: 40 },
{ header: "ERROR", field: "error", width: 40 },
],
};
export function toScheduleLogRow(run: ScheduleRunRecord): ScheduleLogRow {
return {
id: run.id,
status: run.status,
startedAt: run.startedAt,
agentId: run.agentId ? run.agentId.slice(0, 7) : null,
output: run.output,
error: run.error,
};
}
export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleInspectRow[] {
return [
{ key: "Id", value: schedule.id },
{ key: "Name", value: schedule.name ?? "null" },
{ key: "Prompt", value: schedule.prompt },
{
key: "Cadence",
value:
schedule.cadence.type === "cron"
? `cron:${schedule.cadence.expression}`
: `every:${schedule.cadence.everyMs}ms`,
},
{ key: "Target", value: formatTarget(schedule.target) },
{ key: "Status", value: schedule.status },
{ key: "CreatedAt", value: schedule.createdAt },
{ key: "UpdatedAt", value: schedule.updatedAt },
{ key: "NextRunAt", value: schedule.nextRunAt ?? "null" },
{ key: "LastRunAt", value: schedule.lastRunAt ?? "null" },
{ key: "PausedAt", value: schedule.pausedAt ?? "null" },
{ key: "ExpiresAt", value: schedule.expiresAt ?? "null" },
{ key: "MaxRuns", value: schedule.maxRuns == null ? "null" : `${schedule.maxRuns}` },
{ key: "RunCount", value: `${schedule.runs.length}` },
];
}

View File

@@ -0,0 +1,195 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type {
CreateScheduleInput,
ScheduleCadence,
ScheduleDaemonClient,
ScheduleListItem,
ScheduleRecord,
ScheduleTarget,
} from "./types.js";
import { parseDuration } from "../../utils/duration.js";
export interface ScheduleCommandOptions extends CommandOptions {
host?: string;
}
export async function connectScheduleClient(
host: string | undefined,
): Promise<{ client: ScheduleDaemonClient; host: string }> {
const resolvedHost = getDaemonHost({ host });
try {
const client = (await connectToDaemon({
host,
})) as unknown as ScheduleDaemonClient;
return { client, host: resolvedHost };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${resolvedHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
export function toScheduleCommandError(
code: string,
action: string,
error: unknown,
): CommandError {
if (error && typeof error === "object" && "code" in error) {
return error as CommandError;
}
const message = error instanceof Error ? error.message : String(error);
return {
code,
message: `Failed to ${action}: ${message}`,
};
}
export function formatCadence(cadence: ScheduleCadence): string {
if (cadence.type === "cron") {
return `cron:${cadence.expression}`;
}
return `every:${formatDurationMs(cadence.everyMs)}`;
}
export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]): string {
if (target.type === "self") {
return `self:${target.agentId.slice(0, 7)}`;
}
if (target.type === "agent") {
return `agent:${target.agentId.slice(0, 7)}`;
}
return `new-agent:${target.config.provider}`;
}
export function formatDurationMs(durationMs: number): string {
const parts: string[] = [];
let remainingMs = durationMs;
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
if (hours > 0) {
parts.push(`${hours}h`);
remainingMs -= hours * 60 * 60 * 1000;
}
const minutes = Math.floor(remainingMs / (60 * 1000));
if (minutes > 0) {
parts.push(`${minutes}m`);
remainingMs -= minutes * 60 * 1000;
}
const seconds = Math.floor(remainingMs / 1000);
if (seconds > 0 || parts.length === 0) {
parts.push(`${seconds}s`);
}
return parts.join("");
}
export function parseScheduleCreateInput(options: {
prompt: string;
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}): CreateScheduleInput {
const prompt = options.prompt.trim();
if (!prompt) {
throw {
code: "INVALID_PROMPT",
message: "Schedule prompt cannot be empty",
} satisfies CommandError;
}
const cadenceCount = Number(options.every !== undefined) + Number(options.cron !== undefined);
if (cadenceCount !== 1) {
throw {
code: "INVALID_CADENCE",
message: "Specify exactly one of --every or --cron",
} satisfies CommandError;
}
const cadence: ScheduleCadence = options.every
? { type: "every", everyMs: parseDuration(options.every) }
: { type: "cron", expression: options.cron!.trim() };
const targetValue = options.target?.trim();
let target: ScheduleTarget;
if (!targetValue || targetValue === "self") {
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (currentAgentId) {
target = { type: "self", agentId: currentAgentId };
} else {
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
}
} else if (targetValue === "new-agent") {
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
} else {
target = {
type: "agent",
agentId: targetValue,
};
}
const maxRuns =
options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
const expiresAt =
options.expiresIn === undefined
? undefined
: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString();
return {
prompt,
cadence,
target,
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(maxRuns !== undefined ? { maxRuns } : {}),
...(expiresAt ? { expiresAt } : {}),
};
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
} satisfies CommandError;
}
return parsed;
}
export interface ScheduleRow {
id: string;
name: string | null;
cadence: string;
target: string;
status: string;
nextRunAt: string | null;
lastRunAt: string | null;
}
export function toScheduleRow(schedule: ScheduleListItem | ScheduleRecord): ScheduleRow {
return {
id: schedule.id,
name: schedule.name,
cadence: formatCadence(schedule.cadence),
target: formatTarget(schedule.target),
status: schedule.status,
nextRunAt: schedule.nextRunAt,
lastRunAt: schedule.lastRunAt,
};
}

View File

@@ -0,0 +1,141 @@
export type ScheduleStatus = "active" | "paused" | "completed";
export type ScheduleCadence =
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
};
export type ScheduleTarget =
| {
type: "self";
agentId: string;
}
| {
type: "agent";
agentId: string;
}
| {
type: "new-agent";
config: {
provider: "claude" | "codex" | "opencode";
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
};
};
export interface ScheduleRunRecord {
id: string;
scheduledFor: string;
startedAt: string;
endedAt: string | null;
status: "running" | "succeeded" | "failed";
agentId: string | null;
output: string | null;
error: string | null;
}
export interface ScheduleRecord {
id: string;
name: string | null;
prompt: string;
cadence: ScheduleCadence;
target: Exclude<ScheduleTarget, { type: "self" }>;
status: ScheduleStatus;
createdAt: string;
updatedAt: string;
nextRunAt: string | null;
lastRunAt: string | null;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
runs: ScheduleRunRecord[];
}
export interface ScheduleListItem {
id: string;
name: string | null;
cadence: ScheduleCadence;
target: Exclude<ScheduleTarget, { type: "self" }>;
status: ScheduleStatus;
nextRunAt: string | null;
lastRunAt: string | null;
createdAt: string;
updatedAt: string;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
}
export interface CreateScheduleInput {
prompt: string;
name?: string;
cadence: ScheduleCadence;
target: ScheduleTarget;
maxRuns?: number;
expiresAt?: string;
}
export interface ScheduleCreatePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleListPayload {
requestId: string;
schedules: ScheduleListItem[];
error: string | null;
}
export interface ScheduleInspectPayload {
requestId: string;
schedule: ScheduleRecord | null;
error: string | null;
}
export interface ScheduleLogsPayload {
requestId: string;
runs: ScheduleRunRecord[];
error: string | null;
}
export interface SchedulePausePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleResumePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleDeletePayload {
requestId: string;
scheduleId: string;
error: string | null;
}
export interface ScheduleDaemonClient {
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
scheduleList(): Promise<ScheduleListPayload>;
scheduleInspect(input: { id: string }): Promise<ScheduleInspectPayload>;
scheduleLogs(input: { id: string }): Promise<ScheduleLogsPayload>;
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
close(): Promise<void>;
}

View File

@@ -40,6 +40,9 @@ export function render<T>(
return renderYaml(result, opts);
case "table":
default:
if (result.schema.renderHuman) {
return result.schema.renderHuman(result, opts);
}
return renderTable(result, opts);
}
}

View File

@@ -40,6 +40,8 @@ export interface OutputSchema<T> {
idField: keyof T | ((item: T) => string);
/** Column definitions for table output */
columns: ColumnDef<T>[];
/** Optional: custom renderer for human/table output */
renderHuman?: (result: AnyCommandResult<T>, options: OutputOptions) => string;
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown;
}

View File

@@ -29,6 +29,17 @@ interface Agent {
provider: string;
}
interface ChatMessage {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
// Schema for agents
const agentSchema: OutputSchema<Agent> = {
idField: "id",
@@ -211,6 +222,36 @@ test("render uses table format by default", () => {
assert.ok(output.includes("ID"), "Should use table format with headers");
});
test("render uses custom human renderer when provided", () => {
const chatSchema: OutputSchema<ChatMessage> = {
idField: "id",
columns: [{ header: "ID", field: "id" }],
renderHuman: (result) => {
const data = result.type === "list" ? result.data : [result.data];
return data.map((message) => `msg ${message.id}: ${message.body}`).join("\n");
},
};
const chatResult: ListResult<ChatMessage> = {
type: "list",
data: [
{
id: "m1",
author: "agent-1",
authorName: "Planner",
createdAt: "2026-03-29T10:00:00Z",
replyTo: "-",
mentionAgentIds: [],
mentionLabels: [],
body: "hello",
},
],
schema: chatSchema,
};
const output = render(chatResult, { noColor: true });
assert.strictEqual(output, "msg m1: hello", "Should use custom human renderer");
});
test("render uses json format when specified", () => {
const output = render(listResult, { format: "json" });
const parsed = JSON.parse(output);

View File

@@ -31,11 +31,6 @@ type ProviderModel = {
};
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-sonnet-4-5-20250929",
model: "Sonnet 4.5",
descriptionFragment: "Best for everyday tasks",
},
{
id: "claude-sonnet-4-6",
model: "Sonnet 4.6",
@@ -47,7 +42,7 @@ const EXPECTED_CLAUDE_MODELS = [
descriptionFragment: "Most capable",
},
{
id: "claude-haiku-4-5-20251001",
id: "claude-haiku-4-5",
model: "Haiku 4.5",
descriptionFragment: "Fastest",
},
@@ -207,10 +202,13 @@ try {
ids.includes("opencode/gpt-5-nano"),
"opencode output should include opencode/gpt-5-nano",
);
assert(ids.includes("openai/o3-mini"), "opencode output should include openai/o3-mini");
assert(
ids.includes("openai/gpt-5.3-codex-spark"),
"opencode output should include openai/gpt-5.3-codex-spark",
ids.some((id) => id.startsWith("openrouter/openai/")),
"opencode output should include OpenRouter OpenAI models",
);
assert(
ids.includes("openrouter/openai/gpt-5.3-codex"),
"opencode output should include openrouter/openai/gpt-5.3-codex",
);
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
@@ -269,8 +267,8 @@ try {
"--quiet should print the current Claude catalog IDs",
);
assert(
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-5-20250929"),
"captured --json output should still include the Claude default model id",
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-6"),
"captured --json output should include the current Claude everyday model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
}

View File

@@ -2,7 +2,7 @@
/**
* Regression: `paseo daemon stop` must stop supervised dev daemons
* without allowing daemon-runner to respawn a new worker process.
* without allowing the supervisor entrypoint to respawn a new worker process.
*/
import assert from "node:assert";
@@ -120,20 +120,24 @@ let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
supervisorProcess = spawn(
"npx",
["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
stdio: ["ignore", "pipe", "pipe"],
});
);
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
@@ -161,8 +165,9 @@ try {
const command = readProcessCommand(daemonPid);
assert(command !== null, "pid lock pid should resolve to a running process command");
assert(
command.includes("daemon-runner.ts") || command.includes("daemon-runner.js"),
`pid lock pid should be daemon-runner process, got: ${command}`,
command.includes("supervisor-entrypoint.ts") ||
command.includes("supervisor-entrypoint.js"),
`pid lock pid should be supervisor-entrypoint process, got: ${command}`,
);
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`);
@@ -186,7 +191,7 @@ try {
await waitFor(
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
15000,
"daemon-runner supervisor remained running after stop",
"supervisor-entrypoint process remained running after stop",
);
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env npx tsx
/**
* Regression: a single SIGINT sent to a supervised daemon-runner must allow
* Regression: a single SIGINT sent to the supervised supervisor entrypoint must allow
* graceful daemon lifecycle shutdown to complete (no early forced exit path).
*/
@@ -131,21 +131,25 @@ let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
supervisorProcess = spawn(
"npx",
["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
);
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);

View File

@@ -62,7 +62,7 @@ try {
"-e",
// Keep the process alive long enough for stop command assertions.
"setInterval(() => {}, 1000)",
"daemon-runner.ts",
"supervisor-entrypoint.ts",
],
{
env: {

View File

@@ -122,20 +122,24 @@ let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
supervisorProcess = spawn(
"npx",
["tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
stdio: ["ignore", "pipe", "pipe"],
});
);
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);

View File

@@ -134,11 +134,6 @@ try {
env: {
...process.env,
...testEnv,
// This test validates direct unsupervised worker ownership semantics.
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
// which would delegate lock ownership away from this process and make
// daemon status checks fail to observe a running owner PID.
PASEO_PID_LOCK_MODE: "self",
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { rm } from "node:fs/promises";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log("=== Chat Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
try {
{
console.log("Test 1: chat create/ls/inspect work");
const created = await ctx.paseo(["chat", "create", "coord-room", "--purpose", "Coordination"]);
assert.strictEqual(created.exitCode, 0, created.stderr);
assert(created.stdout.includes("coord-room"), created.stdout);
const listed = await ctx.paseo(["chat", "ls"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
assert(listed.stdout.includes("coord-room"), listed.stdout);
const inspected = await ctx.paseo(["chat", "inspect", "coord-room"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
assert(inspected.stdout.includes("Coordination"), inspected.stdout);
console.log("chat create/ls/inspect work\n");
}
{
console.log("Test 2: chat post/read/wait work");
const posted = await ctx.paseo([
"chat",
"post",
"coord-room",
"first message for @agent-1",
], {
env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000111" },
});
assert.strictEqual(posted.exitCode, 0, posted.stderr);
assert(posted.stdout.includes("first message"), posted.stdout);
assert(posted.stdout.includes("00000000-0000-4000-8000-000000000111"), posted.stdout);
const read = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10"]);
assert.strictEqual(read.exitCode, 0, read.stderr);
assert(read.stdout.includes("first message"), read.stdout);
assert(read.stdout.includes("00000000-0000-4000-8000-000000000111"), read.stdout);
const readJson = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10", "--json"]);
assert.strictEqual(readJson.exitCode, 0, readJson.stderr);
const readPayload = JSON.parse(readJson.stdout);
assert.strictEqual(readPayload[0]?.author, "00000000-0000-4000-8000-000000000111");
const waitPromise = ctx.paseo(["chat", "wait", "coord-room", "--timeout", "5s"]);
await new Promise((resolve) => setTimeout(resolve, 250));
const secondPost = await ctx.paseo(["chat", "post", "coord-room", "second message"]);
assert.strictEqual(secondPost.exitCode, 0, secondPost.stderr);
const waited = await waitPromise;
assert.strictEqual(waited.exitCode, 0, waited.stderr);
assert(waited.stdout.includes("second message"), waited.stdout);
console.log("chat post/read/wait work\n");
}
{
console.log("Test 3: duplicate room create fails");
const duplicate = await ctx.paseo(["chat", "create", "coord-room"]);
assert.notStrictEqual(duplicate.exitCode, 0, "duplicate create should fail");
const combined = `${duplicate.stdout}\n${duplicate.stderr}`;
assert(combined.toLowerCase().includes("already exists"), combined);
console.log("duplicate room create fails\n");
}
{
console.log("Test 4: chat delete works");
const deleted = await ctx.paseo(["chat", "delete", "coord-room"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
assert(deleted.stdout.includes("coord-room"), deleted.stdout);
console.log("chat delete works\n");
}
} finally {
await ctx.stop();
await rm(ctx.paseoHome, { recursive: true, force: true });
await rm(ctx.workDir, { recursive: true, force: true });
}
console.log("=== Chat Command Tests Passed ===");

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { rm } from "node:fs/promises";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
console.log("=== Loop And Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
try {
{
console.log("Test 1: schedule create/ls/inspect/pause/resume/delete work");
const created = await ctx.paseo(
["schedule", "create", "Review new PRs", "--every", "5m", "--name", "review-prs", "--json"],
{ timeout: 30000 },
);
assert.strictEqual(created.exitCode, 0, created.stderr);
const createdJson = JSON.parse(created.stdout);
assert.strictEqual(createdJson.name, "review-prs");
assert.strictEqual(createdJson.cadence, "every:5m");
const listed = await ctx.paseo(["schedule", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
assert(listedJson.some((item: { id: string }) => item.id === createdJson.id), listed.stdout);
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
const inspectedJson = JSON.parse(inspected.stdout);
assert.strictEqual(inspectedJson.status, "active");
assert.strictEqual(inspectedJson.prompt, "Review new PRs");
const paused = await ctx.paseo(["schedule", "pause", createdJson.id, "--json"]);
assert.strictEqual(paused.exitCode, 0, paused.stderr);
assert.strictEqual(JSON.parse(paused.stdout).status, "paused");
const resumed = await ctx.paseo(["schedule", "resume", createdJson.id, "--json"]);
assert.strictEqual(resumed.exitCode, 0, resumed.stderr);
assert.strictEqual(JSON.parse(resumed.stdout).status, "active");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id);
console.log("schedule commands work\n");
}
{
console.log("Test 2: loop run/ls/inspect/logs/stop work");
const run = await ctx.paseo(
["loop", "run", "Return any response", "--name", "smoke-loop", "--verify-check", "true", "--json"],
{ timeout: 30000 },
);
assert.strictEqual(run.exitCode, 0, run.stderr);
const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop");
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
assert(listedJson.some((item: { id: string }) => item.id === runJson.id), listed.stdout);
let status = "running";
for (let attempt = 0; attempt < 40; attempt += 1) {
const inspect = await ctx.paseo(["loop", "inspect", runJson.id, "--json"]);
assert.strictEqual(inspect.exitCode, 0, inspect.stderr);
const inspectJson = JSON.parse(inspect.stdout);
status = inspectJson.status;
if (status !== "running") {
assert.strictEqual(status, "succeeded", inspect.stdout);
break;
}
await sleep(250);
}
assert.strictEqual(status, "succeeded");
const logs = await ctx.paseo(["loop", "logs", runJson.id], { timeout: 15000 });
assert.strictEqual(logs.exitCode, 0, logs.stderr);
assert(logs.stdout.includes("verify-check"), logs.stdout);
const stopped = await ctx.paseo(["loop", "stop", runJson.id, "--json"]);
assert.strictEqual(stopped.exitCode, 0, stopped.stderr);
const stoppedJson = JSON.parse(stopped.stdout);
assert(["succeeded", "stopped"].includes(stoppedJson.status), stopped.stdout);
console.log("loop commands work\n");
}
} finally {
await ctx.stop();
await rm(ctx.paseoHome, { recursive: true, force: true });
await rm(ctx.workDir, { recursive: true, force: true });
}
console.log("=== Loop And Schedule Command Tests Passed ===");

View File

@@ -333,6 +333,7 @@ export async function runPaseoCli(
options?: {
timeout?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000;
@@ -348,6 +349,7 @@ export async function runPaseoCli(
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
...options?.env,
},
cwd,
stdio: ["ignore", "pipe", "pipe"],
@@ -399,7 +401,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom
/** Run a paseo CLI command against this daemon */
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
) => Promise<{
exitCode: number;
stdout: string;
@@ -409,7 +411,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom
> {
const ctx = await startTestDaemon({ timeout: options?.timeout });
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) =>
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }) =>
runPaseoCli(ctx, args, opts);
return {

View File

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

View File

@@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { app, ipcMain } from "electron";
import { loadConfig, resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server";
import log from "electron-log/main";
import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server";
import {
copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile,
@@ -26,14 +27,13 @@ const STARTUP_POLL_MAX_ATTEMPTS = 150;
const STOP_TIMEOUT_MS = 15_000;
const KILL_TIMEOUT_MS = 3_000;
const DETACHED_STARTUP_GRACE_MS = 1200;
const DEFAULT_ELECTRON_DEV_SERVER_URL = "http://localhost:8081";
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string;
listen: string | null;
hostname: string | null;
pid: number | null;
home: string;
@@ -140,33 +140,14 @@ function tailFile(filePath: string, lines = 50): string {
}
}
function buildDesktopDaemonCorsOriginsEnv(): string | undefined {
const origins = new Set(
(process.env.PASEO_CORS_ORIGINS ?? "")
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0),
);
origins.add("paseo://app");
const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL;
try {
const parsed = new URL(devServerUrl);
origins.add(parsed.origin);
if (parsed.hostname === "localhost") {
origins.add(`${parsed.protocol}//127.0.0.1${parsed.port ? `:${parsed.port}` : ""}`);
} else if (parsed.hostname === "127.0.0.1") {
origins.add(`${parsed.protocol}//localhost${parsed.port ? `:${parsed.port}` : ""}`);
}
} catch {
// Ignore malformed dev server URLs and preserve any explicit env configuration.
}
return origins.size > 0 ? Array.from(origins).join(",") : undefined;
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
log.info("[desktop daemon]", message, {
pid: process.pid,
...(details ?? {}),
});
}
function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
@@ -240,12 +221,11 @@ function resolveDesktopAppVersion(): string {
function resolveStatus(): DesktopDaemonStatus {
const home = getPaseoHome();
const config = loadConfig(home, { env: process.env });
const pidPath = pidFilePath();
let pid: number | null = null;
let hostname: string | null = null;
let listen: string = config.listen;
let listen: string | null = null;
try {
if (existsSync(pidPath)) {
@@ -260,7 +240,7 @@ function resolveStatus(): DesktopDaemonStatus {
: typeof parsed.sockPath === "string"
? (parsed.sockPath as string)
: null;
if (pidListen) listen = pidListen;
listen = pidListen;
}
}
} catch {
@@ -291,18 +271,20 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = resolveStatus();
if (current.status === "running") return current;
const home = getPaseoHome();
const daemonRunner = resolveDaemonRunnerEntrypoint();
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
const invocation = createNodeEntrypointInvocation({
entrypoint: daemonRunner,
argvMode: "node-script",
args: [],
baseEnv: {
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
},
baseEnv: process.env,
});
logDesktopDaemonLifecycle("starting detached daemon", {
appIsPackaged: app.isPackaged,
daemonRunnerEntry: daemonRunner.entryPath,
daemonRunnerExecArgv: daemonRunner.execArgv,
command: invocation.command,
args: invocation.args,
});
const child: ChildProcess = spawn(
@@ -315,6 +297,12 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
},
);
logDesktopDaemonLifecycle("detached spawn returned", {
childPid: child.pid ?? null,
spawnfile: child.spawnfile,
spawnargs: child.spawnargs,
});
child.unref();
// Wait for process to survive the grace period
@@ -329,15 +317,26 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS);
child.once("error", () => {
logDesktopDaemonLifecycle("detached child emitted error during grace period", {
childPid: child.pid ?? null,
});
clearTimeout(timer);
finish(true);
});
child.once("exit", () => {
logDesktopDaemonLifecycle("detached child emitted exit during grace period", {
childPid: child.pid ?? null,
});
clearTimeout(timer);
finish(true);
});
});
logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly,
});
if (exitedEarly) {
const logs = tailFile(logFilePath(), 15);
throw new Error(`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`);
@@ -346,7 +345,16 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
// Poll for PID file with server ID
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
const status = resolveStatus();
if (status.status === "running" && status.serverId) return status;
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
logDesktopDaemonLifecycle("polling daemon status after detached start", {
attempt: attempt + 1,
status: status.status,
pid: status.pid,
listen: status.listen,
serverId: status.serverId || null,
});
}
if (status.status === "running" && status.serverId && status.listen) return status;
await sleep(STARTUP_POLL_INTERVAL_MS);
}
@@ -397,6 +405,9 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
try {
if (!status.listen) {
throw new Error("Daemon listen target is unavailable.");
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
@@ -438,6 +449,10 @@ async function getLocalDaemonVersion(): Promise<{
};
}
if (!status.listen) {
return { version: null, error: "Daemon listen target is unavailable." };
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` };

View File

@@ -107,7 +107,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
"server",
"dist",
"scripts",
"daemon-runner.js",
"supervisor-entrypoint.js",
),
}),
execArgv: [],
@@ -115,7 +115,12 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
}
const serverPackage = resolveServerPackageInfo();
const distRunner = path.join(serverPackage.root, "dist", "scripts", "daemon-runner.js");
const distRunner = path.join(
serverPackage.root,
"dist",
"scripts",
"supervisor-entrypoint.js",
);
if (existsSync(distRunner)) {
return {
entryPath: distRunner,
@@ -126,7 +131,7 @@ export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
return {
entryPath: assertPathExists({
label: "Daemon runner source",
filePath: path.join(serverPackage.root, "scripts", "daemon-runner.ts"),
filePath: path.join(serverPackage.root, "scripts", "supervisor-entrypoint.ts"),
}),
execArgv: ["--import", "tsx"],
};

View File

@@ -1,4 +1,9 @@
import { app, Menu, BrowserWindow } from "electron";
import { app, Menu, BrowserWindow, ipcMain } from "electron";
type ShowContextMenuInput = {
kind?: "terminal";
hasSelection?: boolean;
};
function withBrowserWindow(
callback: (win: BrowserWindow) => void,
@@ -89,4 +94,36 @@ export function setupApplicationMenu(): void {
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) {
return;
}
if (input?.kind !== "terminal") {
return;
}
const menu = Menu.buildFromTemplate([
{
label: "Copy",
role: "copy",
enabled: input.hasSelection === true,
},
{
label: "Paste",
role: "paste",
},
{
type: "separator",
},
{
label: "Select All",
role: "selectAll",
},
]);
menu.popup({ window: win });
});
}

View File

@@ -1,5 +1,5 @@
import log from "electron-log/main";
log.initialize();
log.initialize({ spyRendererConsole: true });
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -13,6 +13,8 @@ import {
import { closeAllTransportSessions } from "./daemon/local-transport.js";
import {
registerWindowManager,
getTitleBarOverlayOptions,
resolveSystemWindowTheme,
setupWindowResizeEvents,
setupDragDropPrevention,
} from "./window/window-manager.js";
@@ -96,11 +98,7 @@ async function createMainWindow(): Promise<void> {
...(isMac
? { trafficLightPosition: { x: 16, y: 14 } }
: {
titleBarOverlay: {
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
},
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
autoHideMenuBar: true,
}),
webPreferences: {
@@ -112,7 +110,6 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});

View File

@@ -1,4 +1,3 @@
import "electron-log/preload";
import { contextBridge, ipcRenderer } from "electron";
type EventHandler = (payload: unknown) => void;
@@ -27,6 +26,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
endMove: () => ipcRenderer.send("paseo:window:endMove"),
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
setTitleBarTheme: (theme: "light" | "dark") =>
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
onResized: (handler: EventHandler): (() => void) => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
handler(payload);
@@ -52,4 +53,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
opener: {
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
},
menu: {
showContextMenu: (input?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:menu:showContextMenu", input),
},
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { readBadgeCount } from "./window-manager";
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
describe("window-manager", () => {
describe("readBadgeCount", () => {
@@ -20,4 +20,35 @@ describe("window-manager", () => {
expect(readBadgeCount({ count: 2 })).toBe(0);
});
});
describe("readWindowTheme", () => {
it("accepts supported title bar themes", () => {
expect(readWindowTheme("light")).toBe("light");
expect(readWindowTheme("dark")).toBe("dark");
});
it("rejects invalid title bar themes", () => {
expect(readWindowTheme(undefined)).toBeNull();
expect(readWindowTheme("auto")).toBeNull();
expect(readWindowTheme("system")).toBeNull();
});
});
describe("getTitleBarOverlayOptions", () => {
it("returns light title bar overlay colors", () => {
expect(getTitleBarOverlayOptions("light")).toEqual({
color: "#ffffff",
symbolColor: "#09090b",
height: 48,
});
});
it("returns dark title bar overlay colors", () => {
expect(getTitleBarOverlayOptions("dark")).toEqual({
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
import {
createWindowMoveState,
readWindowMovePayload,
@@ -14,6 +14,28 @@ export function readBadgeCount(input: unknown): number {
return input;
}
export type WindowTheme = "light" | "dark";
export function readWindowTheme(input: unknown): WindowTheme | null {
if (input === "light" || input === "dark") {
return input;
}
return null;
}
export function resolveSystemWindowTheme(): WindowTheme {
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
}
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
if (theme === "dark") {
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
}
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
}
export function registerWindowManager(): void {
// ---------------------------------------------------------------------------
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
@@ -101,6 +123,20 @@ export function registerWindowManager(): void {
}
}
});
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || process.platform === "darwin") {
return;
}
const nextTheme = readWindowTheme(theme);
if (!nextTheme) {
return;
}
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
});
}
export function setupWindowResizeEvents(win: BrowserWindow): void {

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ dotenv.config({
quiet: true,
});
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url));
const result = spawnSync(
process.execPath,
["--inspect", "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", "--report-directory=/tmp/paseo-reports", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],

View File

@@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest";
describe("supervision parity", () => {
test("has exactly one runtime callsite for runSupervisor", () => {
const daemonRunner = readFileSync(new URL("./daemon-runner.ts", import.meta.url), "utf8");
const daemonRunner = readFileSync(
new URL("./supervisor-entrypoint.ts", import.meta.url),
"utf8",
);
const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");
const daemonRunnerCalls = (daemonRunner.match(/\brunSupervisor\s*\(/g) ?? []).length;

View File

@@ -1,7 +1,12 @@
import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { loadConfig } from "../src/server/config.js";
import { acquirePidLock, PidLockError, releasePidLock } from "../src/server/pid-lock.js";
import path from "node:path";
import {
acquirePidLock,
PidLockError,
releasePidLock,
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
@@ -55,21 +60,34 @@ function resolveWorkerExecArgv(workerEntry: string): string[] {
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
}
function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): string | null {
const packageMarker = `${path.sep}node_modules${path.sep}@getpaseo${path.sep}server${path.sep}`;
const markerIndex = currentScriptPath.lastIndexOf(packageMarker);
if (markerIndex === -1) {
return null;
}
const appRoot = currentScriptPath.slice(0, markerIndex);
const runnerPath = path.join(appRoot, "dist", "daemon", "node-entrypoint-runner.js");
return existsSync(runnerPath) ? runnerPath : null;
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerEnv: NodeJS.ProcessEnv = {
...process.env,
PASEO_PID_LOCK_MODE: "external",
};
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" };
const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1"
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
: null;
applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv);
const daemonConfig = loadConfig(paseoHome, { env: workerEnv });
try {
await acquirePidLock(paseoHome, daemonConfig.listen, {
await acquirePidLock(paseoHome, null, {
ownerPid: process.pid,
});
} catch (error) {
@@ -100,8 +118,26 @@ async function main(): Promise<void> {
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv,
workerExecArgv: resolveWorkerExecArgv(workerEntry),
workerExecArgv,
resolveWorkerSpawnSpec: packagedNodeEntrypointRunner
? (resolvedWorkerEntry) => ({
command: process.execPath,
args: [
packagedNodeEntrypointRunner,
"node-script",
resolvedWorkerEntry,
...config.workerArgs,
],
env: {
...workerEnv,
ELECTRON_RUN_AS_NODE: "1",
},
})
: undefined,
restartOnCrash: config.devMode,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},
onSupervisorExit: releaseLock,
});
}

View File

@@ -1,9 +1,13 @@
import { fork, type ChildProcess } from "child_process";
import { fork, spawn, type ChildProcess } from "child_process";
type WorkerLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:ready";
listen: string;
}
| {
type: "paseo:restart";
reason?: string;
@@ -16,6 +20,12 @@ type SupervisorOptions = {
workerArgs?: string[];
workerEnv?: NodeJS.ProcessEnv;
workerExecArgv?: string[];
resolveWorkerSpawnSpec?: (workerEntry: string) => {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
} | null;
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
};
@@ -32,6 +42,13 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
if (type === "paseo:shutdown") {
return { type: "paseo:shutdown" };
}
if (type === "paseo:ready") {
const listen = (msg as { listen?: unknown }).listen;
if (typeof listen !== "string" || listen.trim().length === 0) {
return null;
}
return { type: "paseo:ready", listen };
}
if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason;
return {
@@ -47,6 +64,7 @@ export function runSupervisor(options: SupervisorOptions): void {
const workerArgs = options.workerArgs ?? process.argv.slice(2);
const workerEnv = options.workerEnv ?? process.env;
const workerExecArgv = options.workerExecArgv ?? ["--import", "tsx"];
const resolveWorkerSpawnSpec = options.resolveWorkerSpawnSpec;
let child: ChildProcess | null = null;
let restarting = false;
@@ -84,11 +102,19 @@ export function runSupervisor(options: SupervisorOptions): void {
return;
}
child = fork(workerEntry, workerArgs, {
stdio: "inherit",
env: workerEnv,
execArgv: workerExecArgv,
});
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
if (spawnSpec) {
child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: spawnSpec.env ?? workerEnv,
});
} else {
child = fork(workerEntry, workerArgs, {
stdio: "inherit",
env: workerEnv,
execArgv: workerExecArgv,
});
}
child.on("message", (msg: unknown) => {
const lifecycleMessage = parseLifecycleMessage(msg);
@@ -96,6 +122,16 @@ export function runSupervisor(options: SupervisorOptions): void {
return;
}
if (lifecycleMessage.type === "paseo:ready") {
Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch(
(error) => {
const message = error instanceof Error ? error.message : String(error);
log(`Worker ready callback failed: ${message}`);
},
);
return;
}
if (lifecycleMessage.type === "paseo:shutdown") {
requestShutdown("Shutdown requested by worker");
return;
@@ -113,7 +149,11 @@ export function runSupervisor(options: SupervisorOptions): void {
return;
}
if (restarting || (restartOnCrash && code !== 0 && code !== null)) {
const crashed =
restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL"));
if (restarting || crashed) {
restarting = false;
log(`Worker exited (${exitDescriptor}). Restarting worker...`);
spawnWorker();

View File

@@ -241,6 +241,82 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
>["payload"];
type ChatListPayload = Extract<
SessionOutboundMessage,
{ type: "chat/list/response" }
>["payload"];
type ChatInspectPayload = Extract<
SessionOutboundMessage,
{ type: "chat/inspect/response" }
>["payload"];
type ChatDeletePayload = Extract<
SessionOutboundMessage,
{ type: "chat/delete/response" }
>["payload"];
type ChatPostPayload = Extract<
SessionOutboundMessage,
{ type: "chat/post/response" }
>["payload"];
type ChatReadPayload = Extract<
SessionOutboundMessage,
{ type: "chat/read/response" }
>["payload"];
type ChatWaitPayload = Extract<
SessionOutboundMessage,
{ type: "chat/wait/response" }
>["payload"];
type LoopRunPayload = Extract<
SessionOutboundMessage,
{ type: "loop/run/response" }
>["payload"];
type LoopListPayload = Extract<
SessionOutboundMessage,
{ type: "loop/list/response" }
>["payload"];
type LoopInspectPayload = Extract<
SessionOutboundMessage,
{ type: "loop/inspect/response" }
>["payload"];
type LoopLogsPayload = Extract<
SessionOutboundMessage,
{ type: "loop/logs/response" }
>["payload"];
type LoopStopPayload = Extract<
SessionOutboundMessage,
{ type: "loop/stop/response" }
>["payload"];
type ScheduleCreatePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/create/response" }
>["payload"];
type ScheduleListPayload = Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"];
type ScheduleInspectPayload = Extract<
SessionOutboundMessage,
{ type: "schedule/inspect/response" }
>["payload"];
type ScheduleLogsPayload = Extract<
SessionOutboundMessage,
{ type: "schedule/logs/response" }
>["payload"];
type SchedulePausePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/pause/response" }
>["payload"];
type ScheduleResumePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/resume/response" }
>["payload"];
type ScheduleDeletePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/delete/response" }
>["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
@@ -277,6 +353,110 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requ
};
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload["pageInfo"];
export type CreateChatRoomOptions = {
name: string;
purpose?: string | null;
requestId?: string;
};
export type InspectChatRoomOptions = {
room: string;
requestId?: string;
};
export type DeleteChatRoomOptions = {
room: string;
requestId?: string;
};
export type PostChatMessageOptions = {
room: string;
body: string;
authorAgentId?: string;
replyToMessageId?: string | null;
requestId?: string;
};
export type ReadChatMessagesOptions = {
room: string;
limit?: number;
since?: string;
authorAgentId?: string;
requestId?: string;
};
export type WaitForChatMessagesOptions = {
room: string;
afterMessageId?: string | null;
timeoutMs?: number;
requestId?: string;
};
export type RunLoopOptions = {
prompt: string;
cwd: string;
verifyPrompt?: string | null;
verifyChecks?: string[];
name?: string | null;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
requestId?: string;
};
export type InspectLoopOptions = {
id: string;
requestId?: string;
};
export type LoopLogsOptions = {
id: string;
afterSeq?: number;
requestId?: string;
};
export type StopLoopOptions = {
id: string;
requestId?: string;
};
export type CreateScheduleOptions = {
prompt: string;
name?: string | null;
cadence:
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
};
target:
| {
type: "self";
agentId: string;
}
| {
type: "agent";
agentId: string;
}
| {
type: "new-agent";
config: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
extra?: AgentSessionConfig["extra"];
systemPrompt?: string;
mcpServers?: AgentSessionConfig["mcpServers"];
};
};
maxRuns?: number;
expiresAt?: string;
requestId?: string;
};
export type InspectScheduleOptions = {
id: string;
requestId?: string;
};
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
@@ -2677,6 +2857,261 @@ export class DaemonClient {
});
}
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/create",
name: options.name,
...(options.purpose ? { purpose: options.purpose } : {}),
},
responseType: "chat/create/response",
timeout: 10000,
});
}
async listChatRooms(requestId?: string): Promise<ChatListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "chat/list",
},
responseType: "chat/list/response",
timeout: 10000,
});
}
async inspectChatRoom(options: InspectChatRoomOptions): Promise<ChatInspectPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/inspect",
room: options.room,
},
responseType: "chat/inspect/response",
timeout: 10000,
});
}
async deleteChatRoom(options: DeleteChatRoomOptions): Promise<ChatDeletePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/delete",
room: options.room,
},
responseType: "chat/delete/response",
timeout: 10000,
});
}
async postChatMessage(options: PostChatMessageOptions): Promise<ChatPostPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/post",
room: options.room,
body: options.body,
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
},
responseType: "chat/post/response",
timeout: 10000,
});
}
async readChatMessages(options: ReadChatMessagesOptions): Promise<ChatReadPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/read",
room: options.room,
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.since ? { since: options.since } : {}),
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
},
responseType: "chat/read/response",
timeout: 10000,
});
}
async waitForChatMessages(options: WaitForChatMessagesOptions): Promise<ChatWaitPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/wait",
room: options.room,
...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}),
...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}),
},
responseType: "chat/wait/response",
timeout: (options.timeoutMs ?? 0) + 10000,
});
}
async scheduleCreate(options: CreateScheduleOptions): Promise<ScheduleCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/create",
prompt: options.prompt,
cadence: options.cadence,
target: options.target,
...(options.name ? { name: options.name } : {}),
...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}),
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
},
responseType: "schedule/create/response",
timeout: 10000,
});
}
async scheduleList(requestId?: string): Promise<ScheduleListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "schedule/list",
},
responseType: "schedule/list/response",
timeout: 10000,
});
}
async scheduleInspect(options: InspectScheduleOptions): Promise<ScheduleInspectPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/inspect",
scheduleId: options.id,
},
responseType: "schedule/inspect/response",
timeout: 10000,
});
}
async scheduleLogs(options: InspectScheduleOptions): Promise<ScheduleLogsPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/logs",
scheduleId: options.id,
},
responseType: "schedule/logs/response",
timeout: 10000,
});
}
async schedulePause(options: InspectScheduleOptions): Promise<SchedulePausePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/pause",
scheduleId: options.id,
},
responseType: "schedule/pause/response",
timeout: 10000,
});
}
async scheduleResume(options: InspectScheduleOptions): Promise<ScheduleResumePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/resume",
scheduleId: options.id,
},
responseType: "schedule/resume/response",
timeout: 10000,
});
}
async scheduleDelete(options: InspectScheduleOptions): Promise<ScheduleDeletePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/delete",
scheduleId: options.id,
},
responseType: "schedule/delete/response",
timeout: 10000,
});
}
async loopRun(options: RunLoopOptions): Promise<LoopRunPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "loop/run",
prompt: options.prompt,
cwd: options.cwd,
...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}),
...(options.verifyChecks && options.verifyChecks.length > 0
? { verifyChecks: options.verifyChecks }
: {}),
...(options.name ? { name: options.name } : {}),
...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}),
...(typeof options.maxIterations === "number"
? { maxIterations: options.maxIterations }
: {}),
...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}),
},
responseType: "loop/run/response",
timeout: 15000,
});
}
async loopList(requestId?: string): Promise<LoopListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "loop/list",
},
responseType: "loop/list/response",
timeout: 10000,
});
}
async loopInspect(options: string | InspectLoopOptions): Promise<LoopInspectPayload> {
const normalized = typeof options === "string" ? { id: options } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/inspect",
id: normalized.id,
},
responseType: "loop/inspect/response",
timeout: 10000,
});
}
async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise<LoopLogsPayload> {
const normalized =
typeof options === "string" ? { id: options, afterSeq } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/logs",
id: normalized.id,
...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}),
},
responseType: "loop/logs/response",
timeout: 10000,
});
}
async loopStop(options: string | StopLoopOptions): Promise<LoopStopPayload> {
const normalized = typeof options === "string" ? { id: options } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/stop",
id: normalized.id,
},
responseType: "loop/stop/response",
timeout: 10000,
});
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.terminalStreamListeners.add(handler);
return () => {

View File

@@ -1,3 +1,5 @@
import type { AgentAttentionReason } from "../shared/agent-attention-notification.js";
export type ClientAttentionState = {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
@@ -5,8 +7,6 @@ export type ClientAttentionState = {
appVisible: boolean;
};
export type AgentAttentionReason = "finished" | "error" | "permission";
type ComputeClientNotificationInput = {
clientState: ClientAttentionState;
allClientStates: ClientAttentionState[];

Some files were not shown because too many files have changed in this diff Show More