mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e4f228eb | ||
|
|
f6630e16ff | ||
|
|
82a235dada | ||
|
|
3784357b7e | ||
|
|
8d0a9f9224 | ||
|
|
e758734807 | ||
|
|
352a4e793a | ||
|
|
8047394154 | ||
|
|
e5fd61f131 | ||
|
|
487af3c822 | ||
|
|
a7439c8e56 | ||
|
|
930660074d | ||
|
|
fd030e8673 | ||
|
|
bb73147efd | ||
|
|
04b21f089c | ||
|
|
fb42c8eea2 | ||
|
|
971f74e2ab | ||
|
|
4318aa2bb1 | ||
|
|
a1761363fa | ||
|
|
096417d5ac | ||
|
|
35446e01a2 | ||
|
|
6c3559e90f | ||
|
|
409ab466ca | ||
|
|
efb8c8f229 | ||
|
|
b1d663557b | ||
|
|
a460c5e9b9 | ||
|
|
831e3289e7 | ||
|
|
094864d779 | ||
|
|
147482d6ed | ||
|
|
921ddf47a8 | ||
|
|
f287c7f972 | ||
|
|
9eb1f93296 | ||
|
|
95d56ddf15 | ||
|
|
62b1e94257 | ||
|
|
01bab92fed | ||
|
|
dae677edeb | ||
|
|
03a5323755 | ||
|
|
2e9f935dbc | ||
|
|
93845db70a | ||
|
|
d5085294df | ||
|
|
a3aed9b8b6 | ||
|
|
7ca428677c | ||
|
|
9ad3e7661b | ||
|
|
05a4e8f5a3 | ||
|
|
1b0fe4aa47 | ||
|
|
29876acae5 | ||
|
|
657b3e1ccd | ||
|
|
a2ab0af3f1 | ||
|
|
bb9e181c20 | ||
|
|
7bc1f04e27 | ||
|
|
394b9cac13 | ||
|
|
be41911083 | ||
|
|
8f11902cb7 | ||
|
|
e2b2b7c701 | ||
|
|
af35cccb5f | ||
|
|
6342057a6f | ||
|
|
89923b5f76 | ||
|
|
54b5a22688 | ||
|
|
ab3443fd52 | ||
|
|
95ad879f82 | ||
|
|
80e153f861 | ||
|
|
dfa376f5ca | ||
|
|
76c357c88c | ||
|
|
5adde123e3 | ||
|
|
57291b1fd9 | ||
|
|
89f285ffd0 | ||
|
|
e6c06e2263 | ||
|
|
ca443b3ecf | ||
|
|
d8e9298222 | ||
|
|
c667aca3e0 | ||
|
|
7892b3cbe1 | ||
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 |
2
.github/workflows/deploy-website.yml
vendored
2
.github/workflows/deploy-website.yml
vendored
@@ -9,6 +9,8 @@ on:
|
||||
- 'package-lock.json'
|
||||
- 'patches/**'
|
||||
- '.github/workflows/deploy-website.yml'
|
||||
release:
|
||||
types: [published, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
6
.github/workflows/desktop-release.yml
vendored
6
.github/workflows/desktop-release.yml
vendored
@@ -124,7 +124,7 @@ jobs:
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="release"
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="release"
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="release"
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
|
||||
45
.github/workflows/fix-nix-hash.yml
vendored
Normal file
45
.github/workflows/fix-nix-hash.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Fix Nix hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fix-nix-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Fix lockfile and update hash
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
59
.github/workflows/nix-build.yml
vendored
Normal file
59
.github/workflows/nix-build.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Verify lockfile is complete
|
||||
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||
run: |
|
||||
node scripts/fix-lockfile.mjs package-lock.json
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check npmDepsHash is up to date
|
||||
run: ./scripts/update-nix.sh --check
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
||||
# 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
|
||||
- Fixed Windows drive-letter path handling across the codebase.
|
||||
- Fixed stale Nix hash with automatic lockfile-change detection.
|
||||
|
||||
### Added
|
||||
- Added metrics collection and terminal performance tests.
|
||||
|
||||
## 0.1.35 - 2026-03-26
|
||||
|
||||
### Improved
|
||||
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||
- Codex file deletions now display correctly as removed lines in diffs.
|
||||
- OpenCode questions are now surfaced in the permission UI.
|
||||
|
||||
### Fixed
|
||||
- Fixed queued prompt dispatch after idle transition.
|
||||
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||
|
||||
### Added
|
||||
- Added support for Nix and NixOS installation.
|
||||
|
||||
## 0.1.34 - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
84
README.md
84
README.md
@@ -4,40 +4,100 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">One interface for all your coding agents.</p>
|
||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
|
||||
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.
|
||||
|
||||
### Headless / server mode
|
||||
### Prerequisites
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
You need at least one agent CLI installed and configured with your credentials:
|
||||
|
||||
- [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 the desktop app, mobile app, or CLI.
|
||||
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)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
paseo send abc123 "also add tests" # follow-up task
|
||||
|
||||
# run on a remote daemon
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## Orchestration skills (Unstable)
|
||||
|
||||
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
```
|
||||
|
||||
Then use them in any agent conversation:
|
||||
|
||||
```bash
|
||||
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||
|
||||
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||
|
||||
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||
# Very opinionated and expects both Codex and Claude to work.
|
||||
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Quick monorepo package map:
|
||||
@@ -57,8 +117,12 @@ npm run dev
|
||||
# run individual surfaces
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
||||
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:check # Validate release
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
@@ -25,6 +25,7 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
|
||||
```bash
|
||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||
# ... test builds from the draft release assets ...
|
||||
npm run release:finalize # Publish npm, promote draft to published
|
||||
```
|
||||
|
||||
@@ -32,6 +33,7 @@ npm run release:finalize # Publish npm, promote draft to published
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
@@ -62,12 +64,23 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
||||
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||
|
||||
## Changelog format
|
||||
|
||||
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] `npm run release:patch` completes successfully
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# Agent Event Stream Redesign
|
||||
|
||||
Status: **Implemented** (2026-03-24)
|
||||
|
||||
## Problem
|
||||
|
||||
The Claude provider had three event paths delivering the same events to the agent-manager:
|
||||
|
||||
1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`)
|
||||
2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump
|
||||
3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`)
|
||||
|
||||
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
|
||||
|
||||
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
|
||||
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
|
||||
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
|
||||
|
||||
Codex and OpenCode were stable because they had ONE event path with no routing decision.
|
||||
|
||||
## Design
|
||||
|
||||
### Core principle
|
||||
|
||||
One event source per provider session. Identity-based turn ownership, not timing-based routing.
|
||||
|
||||
### Provider contract (`AgentSession`)
|
||||
|
||||
```typescript
|
||||
interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
|
||||
// Turn lifecycle
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
interrupt(): Promise<void>;
|
||||
|
||||
// Event delivery (push-based)
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
|
||||
// History (hydration only — never live dispatch)
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
|
||||
// Run (uses startTurn + subscribe internally)
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
|
||||
// Session metadata (unchanged)
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Method contracts
|
||||
|
||||
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
|
||||
|
||||
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
|
||||
|
||||
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
|
||||
|
||||
#### `subscribe(callback): () => void`
|
||||
|
||||
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
|
||||
|
||||
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
|
||||
|
||||
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
|
||||
|
||||
#### `interrupt(): Promise<void>`
|
||||
|
||||
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
|
||||
|
||||
### Provider-side guarantees
|
||||
|
||||
1. **Per-session ordering**: callbacks invoked in provider event order
|
||||
2. **No concurrent callback execution**: serialized delivery per session
|
||||
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
|
||||
4. **Callback error isolation**: subscriber throws → provider logs and continues
|
||||
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
|
||||
|
||||
### Event tagging
|
||||
|
||||
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
|
||||
|
||||
### User message dedup
|
||||
|
||||
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
|
||||
|
||||
## Manager
|
||||
|
||||
### Single subscription per session
|
||||
|
||||
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
|
||||
|
||||
### Lifecycle projection from turn identity
|
||||
|
||||
- After `startTurn()` resolves: foreground turn is active
|
||||
- On `turn_started` for active foreground turnId: lifecycle = `running`
|
||||
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
|
||||
- On autonomous `turn_started`: lifecycle = `running`
|
||||
- On autonomous terminal: lifecycle = `idle` or `error`
|
||||
|
||||
### `streamAgent()` as filtered view
|
||||
|
||||
```typescript
|
||||
async *streamAgent(agentId, prompt, options) {
|
||||
const { turnId } = await session.startTurn(prompt, options);
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
|
||||
// Foreground turn waiter yields events matching this turnId
|
||||
// Ends when terminal event for turnId arrives
|
||||
}
|
||||
```
|
||||
|
||||
### State model
|
||||
|
||||
| Concept | Implementation |
|
||||
|---------|---------------|
|
||||
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
|
||||
| Lifecycle projection | From turn events via turnId matching |
|
||||
| Cancellation | `session.interrupt()` + await waiter settlement |
|
||||
|
||||
## What was deleted
|
||||
|
||||
- `stream()` from `AgentSession` interface and all providers
|
||||
- `Pushable<T>` async queue from all providers
|
||||
- `streamLiveEvents()` capability
|
||||
- `activeForegroundTurn` + foreground queue in Claude provider
|
||||
- `liveEventQueue` in Claude provider
|
||||
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
|
||||
- `startLiveEventPump()` in manager
|
||||
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
|
||||
- `shouldSuppressLiveUserMessageEcho()` in manager
|
||||
- `startLiveHistoryPolling()` for live dispatch
|
||||
- `snapHistoryOffsetToEnd()`
|
||||
- `pendingRun` as iterator reference
|
||||
|
||||
## Integration tests
|
||||
|
||||
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
|
||||
|
||||
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
|
||||
|
||||
| Test | What it verifies |
|
||||
|------|-----------------|
|
||||
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
|
||||
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
|
||||
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
|
||||
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
|
||||
| Interruption | Start long task → interrupt → turn_canceled arrives |
|
||||
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
|
||||
| Fast-fail | Quick error produces clean terminal, no stale events |
|
||||
| User message dedup | Exactly one user_message with matching text in event log |
|
||||
|
||||
### Invariants (asserted on every test)
|
||||
|
||||
1. For each foreground turnId, exactly ONE `user_message` event
|
||||
2. Every `turn_started` has exactly one matching terminal
|
||||
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
|
||||
4. Autonomous turns between foreground turns are visible with distinct turnIds
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772963539,
|
||||
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
59
flake.nix
Normal file
59
flake.nix
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
}:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [ ./nix/module.nix ];
|
||||
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.python3
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
172
nix/module.nix
Normal file
172
nix/module.nix
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
package = lib.mkPackageOption pkgs "paseo" { };
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "User account under which Paseo runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "Group under which Paseo runs.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/${cfg.user}/.paseo";
|
||||
defaultText = lib.literalExpression ''
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/''${cfg.user}/.paseo"
|
||||
'';
|
||||
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 6767;
|
||||
description = "Port for the Paseo daemon to listen on.";
|
||||
};
|
||||
|
||||
listenAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the Paseo daemon to bind to.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||
|
||||
Set to `true` to allow any host (not recommended).
|
||||
'';
|
||||
};
|
||||
|
||||
relay = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
};
|
||||
};
|
||||
|
||||
inheritUserEnvironment = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.user != "paseo";
|
||||
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||
description = ''
|
||||
Whether to include the user's profile PATH in the service environment.
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
}
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.paseo = {
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
|
||||
ExecStart =
|
||||
"${cfg.package}/bin/paseo-server"
|
||||
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||
KillSignal = "SIGTERM";
|
||||
TimeoutStopSec = 15;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
144
nix/package.nix
Normal file
144
nix/package.nix
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||
# Exclude test fixtures and debug files
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# 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-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).
|
||||
# We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
dontNpmBuild = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/paseo
|
||||
|
||||
# Copy root package metadata
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# 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/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
119
package-lock.json
generated
119
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -16718,6 +16718,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-log": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
|
||||
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.8.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
|
||||
@@ -25117,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",
|
||||
@@ -34842,16 +34860,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.34",
|
||||
"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.34",
|
||||
"@getpaseo/highlight": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@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",
|
||||
@@ -34937,6 +34955,8 @@
|
||||
},
|
||||
"packages/app/node_modules/expo-clipboard": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -34956,6 +34976,8 @@
|
||||
},
|
||||
"packages/app/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -34963,11 +34985,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@getpaseo/relay": "0.1.38",
|
||||
"@getpaseo/server": "0.1.38",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -34987,6 +35009,8 @@
|
||||
},
|
||||
"packages/cli/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -34997,6 +35021,8 @@
|
||||
},
|
||||
"packages/cli/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35004,10 +35030,12 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@getpaseo/cli": "0.1.38",
|
||||
"@getpaseo/server": "0.1.38",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -35040,7 +35068,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35241,7 +35269,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35267,7 +35295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35283,13 +35311,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.34",
|
||||
"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.34",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@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",
|
||||
@@ -35332,8 +35361,19 @@
|
||||
"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",
|
||||
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
@@ -35355,6 +35395,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -35369,6 +35411,8 @@
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
@@ -35415,6 +35459,8 @@
|
||||
},
|
||||
"packages/server/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
@@ -35426,6 +35472,8 @@
|
||||
},
|
||||
"packages/server/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -35440,6 +35488,8 @@
|
||||
},
|
||||
"packages/server/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35450,6 +35500,8 @@
|
||||
},
|
||||
"packages/server/node_modules/body-parser": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
@@ -35468,6 +35520,8 @@
|
||||
},
|
||||
"packages/server/node_modules/content-disposition": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
@@ -35478,6 +35532,8 @@
|
||||
},
|
||||
"packages/server/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
@@ -35485,6 +35541,8 @@
|
||||
},
|
||||
"packages/server/node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
@@ -35500,6 +35558,8 @@
|
||||
},
|
||||
"packages/server/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -35507,6 +35567,8 @@
|
||||
},
|
||||
"packages/server/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -35514,6 +35576,8 @@
|
||||
},
|
||||
"packages/server/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35524,6 +35588,8 @@
|
||||
},
|
||||
"packages/server/node_modules/mime-types": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
@@ -35534,6 +35600,8 @@
|
||||
},
|
||||
"packages/server/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -35572,6 +35640,8 @@
|
||||
},
|
||||
"packages/server/node_modules/send": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
@@ -35592,6 +35662,8 @@
|
||||
},
|
||||
"packages/server/node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -35605,6 +35677,8 @@
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@@ -35618,6 +35692,8 @@
|
||||
},
|
||||
"packages/server/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
@@ -35630,6 +35706,8 @@
|
||||
},
|
||||
"packages/server/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -35637,13 +35715,14 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.34",
|
||||
"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",
|
||||
@@ -35663,6 +35742,8 @@
|
||||
},
|
||||
"packages/website/node_modules/@types/node": {
|
||||
"version": "22.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
|
||||
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.38",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -49,13 +49,13 @@
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
||||
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
@@ -68,6 +68,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"homepage": "https://paseo.sh",
|
||||
"keywords": [
|
||||
"openai",
|
||||
"realtime",
|
||||
@@ -76,7 +77,14 @@
|
||||
"development",
|
||||
"mcp"
|
||||
],
|
||||
"author": "moboudra",
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
|
||||
@@ -448,12 +448,15 @@ export default async function globalSetup() {
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||
PASEO_DICTATION_ENABLED: "1",
|
||||
PASEO_VOICE_MODE_ENABLED: "1",
|
||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||
...(openAiUsable
|
||||
? {
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
}
|
||||
: {}),
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
|
||||
228
packages/app/e2e/helpers/terminal-perf.ts
Normal file
228
packages/app/e2e/helpers/terminal-perf.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export type TerminalPerfDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
): void;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
};
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => TerminalPerfDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||
}
|
||||
|
||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const term = (window as any).__paseoTerminal;
|
||||
if (!term) {
|
||||
return "";
|
||||
}
|
||||
const buf = term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const line = buf.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForTerminalContent(
|
||||
page: Page,
|
||||
predicate: (text: string) => boolean,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const text = await getTerminalBufferText(page);
|
||||
if (predicate(text)) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export async function navigateToTerminal(
|
||||
page: Page,
|
||||
input: { cwd: string; terminalId: string },
|
||||
): Promise<void> {
|
||||
// Boot the app at the workspace route directly.
|
||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||
// so the daemon registry is already configured when the app starts.
|
||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||
await page.goto(workspaceRoute);
|
||||
|
||||
// Wait for daemon connection (sidebar shows host label)
|
||||
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// The workspace should now query listTerminals and discover our terminal.
|
||||
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||
|
||||
if (!surfaceVisible) {
|
||||
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
if (tabExists) {
|
||||
await terminalTab.click();
|
||||
} else {
|
||||
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await newTerminalBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for terminal surface to be visible
|
||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// Wait for loading overlay to disappear (terminal attached)
|
||||
await page
|
||||
.locator('[data-testid="terminal-attach-loading"]')
|
||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||
.catch(() => {
|
||||
// overlay may never appear if attachment is instant
|
||||
});
|
||||
|
||||
await terminalSurface.click();
|
||||
}
|
||||
|
||||
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||
const tag = sentinel ?? `READY_${Date.now()}`;
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
|
||||
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
|
||||
|
||||
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
export type LatencySample = {
|
||||
char: string;
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Measures keystroke echo round-trip latency.
|
||||
*
|
||||
* Starts a high-resolution timer on the browser keydown event (capture phase)
|
||||
* and stops it when xterm.js finishes parsing the echoed write. This measures
|
||||
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
|
||||
*/
|
||||
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||
await page.evaluate(() => {
|
||||
const term = (window as any).__paseoTerminal;
|
||||
if (!term) {
|
||||
throw new Error("__paseoTerminal not available");
|
||||
}
|
||||
|
||||
const state = ((window as any).__perfKeystroke = {
|
||||
promise: null as Promise<number> | null,
|
||||
});
|
||||
|
||||
state.promise = new Promise<number>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
reject(new Error("keystroke echo timeout (5s)"));
|
||||
}, 5000);
|
||||
|
||||
function onKeyDown() {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
const start = performance.now();
|
||||
const disposable = term.onWriteParsed(() => {
|
||||
clearTimeout(timeout);
|
||||
disposable.dispose();
|
||||
resolve(performance.now() - start);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
});
|
||||
});
|
||||
|
||||
await page.keyboard.press(char);
|
||||
|
||||
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
||||
}
|
||||
|
||||
export function computePercentile(samples: number[], p: number): number {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, index)];
|
||||
}
|
||||
|
||||
export function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
160
packages/app/e2e/terminal-performance.spec.ts
Normal file
160
packages/app/e2e/terminal-performance.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
navigateToTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
measureKeystrokeLatency,
|
||||
computePercentile,
|
||||
round2,
|
||||
type TerminalPerfDaemonClient,
|
||||
type LatencySample,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
const LINE_COUNT = 50_000;
|
||||
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||
|
||||
test.describe("Terminal wire performance", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("perf-");
|
||||
client = await connectTerminalClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) {
|
||||
await client.close();
|
||||
}
|
||||
if (tempRepo) {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "throughput");
|
||||
if (!result.terminal) {
|
||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
}
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const startMs = Date.now();
|
||||
|
||||
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
|
||||
// seq 1 N outputs each number on its own line
|
||||
const estimatedBytes = Array.from(
|
||||
{ length: LINE_COUNT },
|
||||
(_, i) => String(i + 1).length + 1,
|
||||
).reduce((a, b) => a + b, 0);
|
||||
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
|
||||
|
||||
const report = {
|
||||
lineCount: LINE_COUNT,
|
||||
estimatedBytes,
|
||||
elapsedMs,
|
||||
throughputMBps: round2(throughputMBps),
|
||||
};
|
||||
|
||||
await testInfo.attach("throughput-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||
);
|
||||
|
||||
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||
THROUGHPUT_BUDGET_MS,
|
||||
);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "latency");
|
||||
if (!result.terminal) {
|
||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
}
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Ensure clean prompt state
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await terminal.press("Control+c");
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const samples: LatencySample[] = [];
|
||||
const chars = "abcdefghijklmnopqrst";
|
||||
|
||||
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
|
||||
const char = chars[i % chars.length];
|
||||
const latencyMs = await measureKeystrokeLatency(page, char);
|
||||
samples.push({ char, latencyMs });
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
// Clean up typed characters
|
||||
await terminal.press("Control+c");
|
||||
|
||||
const latencies = samples.map((s) => s.latencyMs);
|
||||
const p50 = computePercentile(latencies, 50);
|
||||
const p95 = computePercentile(latencies, 95);
|
||||
const max = Math.max(...latencies);
|
||||
const min = Math.min(...latencies);
|
||||
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
||||
|
||||
const report = {
|
||||
sampleCount: KEYSTROKE_SAMPLE_COUNT,
|
||||
p50Ms: round2(p50),
|
||||
p95Ms: round2(p95),
|
||||
maxMs: round2(max),
|
||||
minMs: round2(min),
|
||||
avgMs: round2(avg),
|
||||
samples: samples.map((s) => ({
|
||||
char: s.char,
|
||||
latencyMs: round2(s.latencyMs),
|
||||
})),
|
||||
};
|
||||
|
||||
await testInfo.attach("latency-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
|
||||
);
|
||||
|
||||
expect(
|
||||
p95,
|
||||
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.34",
|
||||
"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.34",
|
||||
"@getpaseo/highlight": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@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",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
export function shouldWaitOnStartupRace(input: {
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
pathname: string;
|
||||
}): boolean {
|
||||
if (input.onlineServerId) {
|
||||
return false;
|
||||
}
|
||||
if (input.pathname === WELCOME_ROUTE) {
|
||||
return false;
|
||||
}
|
||||
if (input.hasTimedOut) {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
|
||||
export function shouldRedirectToWelcome(input: {
|
||||
onlineServerId: string | null;
|
||||
hasTimedOut: boolean;
|
||||
pathname: string;
|
||||
isDesktopStartupRace: boolean;
|
||||
daemonCount: number;
|
||||
}): boolean {
|
||||
if (input.onlineServerId || !input.hasTimedOut) {
|
||||
return false;
|
||||
}
|
||||
if (input.pathname !== "/" && input.pathname !== "") {
|
||||
return false;
|
||||
}
|
||||
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -25,10 +25,14 @@ import {
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
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,
|
||||
@@ -65,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,
|
||||
@@ -76,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();
|
||||
@@ -204,41 +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();
|
||||
|
||||
void store
|
||||
.loadFromStorage()
|
||||
.then(() => {
|
||||
const init = async () => {
|
||||
const settings = await loadSettingsFromStorage();
|
||||
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
||||
await store.loadFromStorage();
|
||||
if (isDesktopManaged) {
|
||||
setPhase("starting-daemon");
|
||||
setError(null);
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
if (!cancelled) {
|
||||
setPhase("error");
|
||||
setError(bootstrapResult.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
void store.bootstrap();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", error);
|
||||
|
||||
setPhase("connecting");
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
listenAddress: bootstrapResult.listenAddress,
|
||||
serverId: bootstrapResult.serverId,
|
||||
hostname: bootstrapResult.hostname,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||
if (!cancelled) {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -399,24 +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) {
|
||||
return <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>
|
||||
@@ -519,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();
|
||||
|
||||
@@ -539,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 }}>
|
||||
@@ -606,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>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
shouldRedirectToWelcome,
|
||||
shouldWaitOnStartupRace,
|
||||
WELCOME_ROUTE,
|
||||
} from "@/app-support/index-startup";
|
||||
useHostRuntimeBootstrapState,
|
||||
useStoreReady,
|
||||
} from "@/app/_layout";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
@@ -33,81 +35,31 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
() => {
|
||||
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 daemons = useHosts();
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setHasTimedOut(true);
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const storeReady = useStoreReady();
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
|
||||
|
||||
useEffect(() => {
|
||||
if (!onlineServerId) {
|
||||
if (!storeReady) {
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(onlineServerId) as any);
|
||||
}, [onlineServerId, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldRedirectToWelcome({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
pathname,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
router.replace(WELCOME_ROUTE as any);
|
||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
||||
const targetRoute = anyOnlineServerId
|
||||
? buildHostRootRoute(anyOnlineServerId)
|
||||
: WELCOME_ROUTE;
|
||||
router.replace(targetRoute as any);
|
||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
getIsDesktopMac,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -16,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";
|
||||
@@ -31,8 +31,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
|
||||
const collapsedSidebarInset =
|
||||
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||
? trafficLightPadding
|
||||
: { left: 0, right: 0 };
|
||||
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
@@ -42,7 +44,11 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||
{
|
||||
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
|
||||
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
|
||||
},
|
||||
borderless && styles.borderless,
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
@@ -83,4 +89,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
borderless: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
@@ -681,7 +682,7 @@ function DesktopSidebar({
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
{trafficLightPadding.top > 0 ? (
|
||||
{trafficLightPadding.side === 'left' ? (
|
||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||
) : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -346,9 +346,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
[onChangeText, onSubmit, images, isAgentRunning],
|
||||
);
|
||||
|
||||
const handleDictationError = useCallback((error: Error) => {
|
||||
console.error("[MessageInput] Dictation error:", error);
|
||||
}, []);
|
||||
const handleDictationError = useCallback(
|
||||
(error: Error) => {
|
||||
console.error("[MessageInput] Dictation error:", error);
|
||||
toast.error(error.message);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
||||
serverInfo,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -886,7 +886,10 @@ function SplitPaneView({
|
||||
style={[
|
||||
styles.paneTabs,
|
||||
isFocusModeEnabled &&
|
||||
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
|
||||
trafficLightPadding.side && {
|
||||
paddingLeft: trafficLightPadding.left,
|
||||
paddingRight: trafficLightPadding.right,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<WorkspaceDesktopTabsRow
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostMutations,
|
||||
useHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
|
||||
actionTextPrimary: {
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
hostList: {
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
marginTop: theme.spacing[6],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hostRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
hostLabel: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
hostStatus: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
hostStatusError: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
versionLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function useAnyHostOnline(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;
|
||||
},
|
||||
() => {
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const status = snapshot?.connectionStatus ?? "connecting";
|
||||
const lastError = snapshot?.lastError ?? null;
|
||||
|
||||
let dotColor: string;
|
||||
let statusText: string;
|
||||
let isError = false;
|
||||
|
||||
switch (status) {
|
||||
case "online":
|
||||
dotColor = theme.colors.success;
|
||||
statusText = "Online";
|
||||
break;
|
||||
case "connecting":
|
||||
case "idle":
|
||||
dotColor = theme.colors.foregroundMuted;
|
||||
statusText = "Connecting…";
|
||||
break;
|
||||
case "offline":
|
||||
dotColor = theme.colors.foregroundMuted;
|
||||
statusText = "Offline";
|
||||
break;
|
||||
case "error":
|
||||
dotColor = theme.colors.destructive;
|
||||
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
|
||||
isError = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.hostRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
|
||||
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
|
||||
{statusText}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyOnlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pendingNameHost) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
||||
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect to your host to start</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{showHostList && (
|
||||
<View style={styles.hostList}>
|
||||
{hosts.map((host) => (
|
||||
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export const MAX_CONTENT_WIDTH = 820;
|
||||
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
||||
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||
|
||||
// Windows/Linux window controls (minimize/maximize/close) — top-right
|
||||
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||
|
||||
// Check if running in desktop app (any OS)
|
||||
function isDesktopEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionAgents) {
|
||||
previousAgentStatusRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
for (const nextAgent of sessionAgents.values()) {
|
||||
nextStatuses.set(nextAgent.id, nextAgent.status);
|
||||
}
|
||||
previousAgentStatusRef.current = nextStatuses;
|
||||
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
|
||||
previousAgentStatusRef.current,
|
||||
sessionAgents,
|
||||
);
|
||||
}, [sessionAgents]);
|
||||
|
||||
const hydrateWorkspaces = useCallback(
|
||||
|
||||
72
packages/app/src/contexts/session-status-tracking.test.ts
Normal file
72
packages/app/src/contexts/session-status-tracking.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
|
||||
|
||||
function createAgent(status: Agent["status"]): Agent {
|
||||
return {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date(0),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Agent",
|
||||
cwd: "/tmp",
|
||||
model: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reconcilePreviousAgentStatuses", () => {
|
||||
it("preserves previously seen status for existing agents", () => {
|
||||
const previous = new Map([["agent-1", "running" as const]]);
|
||||
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||
|
||||
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||
});
|
||||
|
||||
it("seeds newly seen agents from the current snapshot", () => {
|
||||
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
|
||||
|
||||
expect(result).toEqual(new Map([["agent-1", "idle"]]));
|
||||
});
|
||||
|
||||
it("removes agents that are no longer present", () => {
|
||||
const previous = new Map([
|
||||
["agent-1", "running" as const],
|
||||
["agent-2", "idle" as const],
|
||||
]);
|
||||
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
|
||||
|
||||
expect(result).toEqual(new Map([["agent-1", "running"]]));
|
||||
});
|
||||
|
||||
it("clears all tracked statuses when the session is unavailable", () => {
|
||||
const previous = new Map([["agent-1", "running" as const]]);
|
||||
|
||||
const result = reconcilePreviousAgentStatuses(previous, undefined);
|
||||
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
29
packages/app/src/contexts/session-status-tracking.ts
Normal file
29
packages/app/src/contexts/session-status-tracking.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
export function reconcilePreviousAgentStatuses(
|
||||
previousStatuses: Map<string, AgentLifecycleStatus>,
|
||||
sessionAgents: Map<string, Agent> | undefined,
|
||||
): Map<string, AgentLifecycleStatus> {
|
||||
if (!sessionAgents) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const nextStatuses = new Map(previousStatuses);
|
||||
const seenAgentIds = new Set<string>();
|
||||
|
||||
for (const agent of sessionAgents.values()) {
|
||||
seenAgentIds.add(agent.id);
|
||||
if (!nextStatuses.has(agent.id)) {
|
||||
nextStatuses.set(agent.id, agent.status);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of nextStatuses.keys()) {
|
||||
if (!seenAgentIds.has(agentId)) {
|
||||
nextStatuses.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextStatuses;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) ?? "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
57
packages/app/src/hooks/use-form-preferences.test.ts
Normal file
57
packages/app/src/hooks/use-form-preferences.test.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface AppSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "dark",
|
||||
theme: "auto",
|
||||
manageBuiltInDaemon: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -1137,7 +1145,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<void> {
|
||||
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
||||
if (this.bootstrapAttempted) {
|
||||
return;
|
||||
}
|
||||
@@ -1153,30 +1161,48 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapDesktop();
|
||||
if (options?.manageBuiltInDaemon ?? true) {
|
||||
await this.bootstrapDesktop();
|
||||
}
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapDesktop(): Promise<void> {
|
||||
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
|
||||
try {
|
||||
const daemon = await startDesktopDaemon();
|
||||
const connection = connectionFromListen(daemon.listen);
|
||||
if (!connection) {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
await this.upsertHostConnection({
|
||||
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,
|
||||
label: hostname ?? daemon.hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
hostname: daemon.hostname,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||
return {
|
||||
ok: false,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1282,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,
|
||||
@@ -1443,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) {
|
||||
|
||||
@@ -1,26 +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 { getIsDesktopMac } from "@/constants/layout";
|
||||
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 needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,22 +27,24 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||
<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>
|
||||
);
|
||||
@@ -58,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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||
import {
|
||||
getIsDesktopMac,
|
||||
getIsDesktop,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
@@ -120,11 +123,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
}, [isActive]);
|
||||
}
|
||||
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||
if (Platform.OS !== "web" || !getIsDesktop()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
@@ -175,12 +178,23 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsDesktopMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 };
|
||||
if (!getIsDesktop() || isFullscreen) {
|
||||
return { left: 0, right: 0, top: 0, side: null };
|
||||
}
|
||||
|
||||
if (getIsDesktopMac()) {
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
right: 0,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
side: 'left',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
left: 0,
|
||||
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
side: 'right',
|
||||
};
|
||||
}
|
||||
|
||||
44
packages/app/src/utils/sidebar-animation-state.test.ts
Normal file
44
packages/app/src/utils/sidebar-animation-state.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
41
packages/app/src/utils/sidebar-animation-state.ts
Normal file
41
packages/app/src/utils/sidebar-animation-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.34",
|
||||
"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.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@getpaseo/relay": "0.1.38",
|
||||
"@getpaseo/server": "0.1.38",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addDeleteOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
|
||||
if (options.all) {
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
|
||||
export function addLsOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -193,11 +194,7 @@ export async function runLsCommand(
|
||||
|
||||
// Optional cwd filter.
|
||||
if (options.cwd) {
|
||||
const targetCwd = options.cwd.replace(/\/$/, "");
|
||||
agents = agents.filter((a) => {
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
});
|
||||
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||
}
|
||||
|
||||
// Apply label filtering only when explicitly requested.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -75,12 +76,9 @@ export async function runStopCommand(
|
||||
agents = agents.filter((a) => !a.archivedAt);
|
||||
} else if (options.cwd) {
|
||||
// Stop agents in directory
|
||||
const filterCwd = options.cwd;
|
||||
agents = agents.filter((a) => {
|
||||
if (a.archivedAt) return false;
|
||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
// Stop specific agent
|
||||
|
||||
31
packages/cli/src/commands/chat/create.ts
Normal file
31
packages/cli/src/commands/chat/create.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
24
packages/cli/src/commands/chat/delete.ts
Normal file
24
packages/cli/src/commands/chat/delete.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
69
packages/cli/src/commands/chat/index.ts
Normal file
69
packages/cli/src/commands/chat/index.ts
Normal 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;
|
||||
}
|
||||
24
packages/cli/src/commands/chat/inspect.ts
Normal file
24
packages/cli/src/commands/chat/inspect.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
23
packages/cli/src/commands/chat/ls.ts
Normal file
23
packages/cli/src/commands/chat/ls.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
41
packages/cli/src/commands/chat/post.ts
Normal file
41
packages/cli/src/commands/chat/post.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
60
packages/cli/src/commands/chat/read.ts
Normal file
60
packages/cli/src/commands/chat/read.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
126
packages/cli/src/commands/chat/schema.ts
Normal file
126
packages/cli/src/commands/chat/schema.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
127
packages/cli/src/commands/chat/shared.ts
Normal file
127
packages/cli/src/commands/chat/shared.ts
Normal 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";
|
||||
}
|
||||
47
packages/cli/src/commands/chat/wait.ts
Normal file
47
packages/cli/src/commands/chat/wait.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -294,7 +299,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\")
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
export interface NodePathFromPidResult {
|
||||
nodePath: string | null;
|
||||
@@ -12,17 +13,14 @@ function normalizeError(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
nodePath: null,
|
||||
error: `ps failed: ${normalizeError(result.error)}`,
|
||||
};
|
||||
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
@@ -34,12 +32,82 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim();
|
||||
if (!resolved) {
|
||||
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
|
||||
}
|
||||
|
||||
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 { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
nodePath: null,
|
||||
error: "ps returned an empty command path",
|
||||
resolved: null,
|
||||
error: details ? `${command} failed: ${details}` : `${command} exited with code ${result.status ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { nodePath: resolved };
|
||||
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 {
|
||||
return platform() === "win32"
|
||||
? resolveNodePathFromPidWindows(pid)
|
||||
: resolveNodePathFromPidUnix(pid);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
packages/cli/src/commands/loop/index.ts
Normal file
34
packages/cli/src/commands/loop/index.ts
Normal 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;
|
||||
}
|
||||
118
packages/cli/src/commands/loop/inspect.ts
Normal file
118
packages/cli/src/commands/loop/inspect.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
82
packages/cli/src/commands/loop/logs.ts
Normal file
82
packages/cli/src/commands/loop/logs.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
88
packages/cli/src/commands/loop/ls.ts
Normal file
88
packages/cli/src/commands/loop/ls.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
161
packages/cli/src/commands/loop/run.ts
Normal file
161
packages/cli/src/commands/loop/run.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
80
packages/cli/src/commands/loop/stop.ts
Normal file
80
packages/cli/src/commands/loop/stop.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
147
packages/cli/src/commands/loop/types.ts
Normal file
147
packages/cli/src/commands/loop/types.ts
Normal 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>;
|
||||
}
|
||||
52
packages/cli/src/commands/schedule/create.ts
Normal file
52
packages/cli/src/commands/schedule/create.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
43
packages/cli/src/commands/schedule/delete.ts
Normal file
43
packages/cli/src/commands/schedule/delete.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
68
packages/cli/src/commands/schedule/index.ts
Normal file
68
packages/cli/src/commands/schedule/index.ts
Normal 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;
|
||||
}
|
||||
32
packages/cli/src/commands/schedule/inspect.ts
Normal file
32
packages/cli/src/commands/schedule/inspect.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
27
packages/cli/src/commands/schedule/logs.ts
Normal file
27
packages/cli/src/commands/schedule/logs.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
32
packages/cli/src/commands/schedule/ls.ts
Normal file
32
packages/cli/src/commands/schedule/ls.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
33
packages/cli/src/commands/schedule/pause.ts
Normal file
33
packages/cli/src/commands/schedule/pause.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
33
packages/cli/src/commands/schedule/resume.ts
Normal file
33
packages/cli/src/commands/schedule/resume.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
88
packages/cli/src/commands/schedule/schema.ts
Normal file
88
packages/cli/src/commands/schedule/schema.ts
Normal 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}` },
|
||||
];
|
||||
}
|
||||
195
packages/cli/src/commands/schedule/shared.ts
Normal file
195
packages/cli/src/commands/schedule/shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
141
packages/cli/src/commands/schedule/types.ts
Normal file
141
packages/cli/src/commands/schedule/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "path";
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
|
||||
|
||||
// Find the worktree by name or branch
|
||||
const worktree = listResponse.worktrees.find((wt) => {
|
||||
const name = wt.worktreePath.split("/").pop();
|
||||
const name = path.basename(wt.worktreePath);
|
||||
return name === nameArg || wt.branchName === nameArg;
|
||||
});
|
||||
|
||||
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
|
||||
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
|
||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||
return `unix://${trimmed}`;
|
||||
}
|
||||
|
||||
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
|
||||
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return `127.0.0.1:${trimmed}`;
|
||||
}
|
||||
|
||||
27
packages/cli/src/utils/paths.ts
Normal file
27
packages/cli/src/utils/paths.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Path utilities for cwd filtering in agent commands.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
|
||||
*
|
||||
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
|
||||
* This is important because agent cwd paths come from the agent's OS (could be Windows)
|
||||
* while the CLI filter path comes from the user (could also be Windows or Unix).
|
||||
*/
|
||||
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
|
||||
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
|
||||
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
|
||||
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
|
||||
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
|
||||
normalizedBase = normalizedBase.toLowerCase();
|
||||
normalizedCandidate = normalizedCandidate.toLowerCase();
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedCandidate === normalizedBase ||
|
||||
normalizedCandidate.startsWith(normalizedBase + "/")
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user