mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
70
README.md
70
README.md
@@ -4,40 +4,88 @@
|
||||
|
||||
<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.
|
||||
### Desktop app
|
||||
|
||||
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
|
||||
|
||||
### Headless / server mode
|
||||
|
||||
To run the daemon on a remote or headless machine:
|
||||
Run the daemon on any machine:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then connect from the desktop app, mobile app, or CLI.
|
||||
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
|
||||
|
||||
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 +105,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.
|
||||
|
||||
@@ -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-jBl1E7cwEf5XKXFQip/ZlEa/zNQoz0Hq5t0hqjp8Qe4=";
|
||||
|
||||
# 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 (daemon-runner, supervisor) 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;
|
||||
};
|
||||
}
|
||||
99
package-lock.json
generated
99
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"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",
|
||||
@@ -34842,16 +34851,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"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.37",
|
||||
"@getpaseo/highlight": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -34937,6 +34946,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 +34967,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 +34976,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -34987,6 +35000,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 +35012,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 +35021,12 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@getpaseo/cli": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -35040,7 +35059,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35241,7 +35260,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35267,7 +35286,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35283,13 +35302,13 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"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.37",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
@@ -35334,6 +35353,8 @@
|
||||
},
|
||||
"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 +35376,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 +35392,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 +35440,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 +35453,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 +35469,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 +35481,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 +35501,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 +35513,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 +35522,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 +35539,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 +35548,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 +35557,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 +35569,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 +35581,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 +35621,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 +35643,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 +35658,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 +35673,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 +35687,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,7 +35696,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
@@ -35663,6 +35722,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.37",
|
||||
"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.37",
|
||||
"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.37",
|
||||
"@getpaseo/highlight": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"@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;
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
let cancelled = false;
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
void store
|
||||
.loadFromStorage()
|
||||
const init = async () => {
|
||||
const settings = await loadSettingsFromStorage();
|
||||
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||
await store.loadFromStorage();
|
||||
if (isDesktopManaged) {
|
||||
await store.bootstrap({ manageBuiltInDaemon: true });
|
||||
} else {
|
||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||
}
|
||||
};
|
||||
|
||||
void init()
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
setReady(true);
|
||||
void store.bootstrap();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", error);
|
||||
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}, [isLoading, settings.theme]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingView />;
|
||||
const isDesktopManaged =
|
||||
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
|
||||
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,113 +1,18 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
import { useEffect } 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";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onlineServerId) {
|
||||
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]);
|
||||
|
||||
if (
|
||||
shouldWaitOnStartupRace({
|
||||
onlineServerId,
|
||||
hasTimedOut,
|
||||
isDesktopStartupRace,
|
||||
daemonCount: daemons.length,
|
||||
pathname,
|
||||
})
|
||||
) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
if (!onlineServerId) {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -31,8 +30,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 +43,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
||||
<View
|
||||
style={[
|
||||
styles.row,
|
||||
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
|
||||
{
|
||||
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
|
||||
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
|
||||
},
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
|
||||
@@ -681,7 +681,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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1137,7 +1137,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<void> {
|
||||
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
||||
if (this.bootstrapAttempted) {
|
||||
return;
|
||||
}
|
||||
@@ -1153,7 +1153,9 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapDesktop();
|
||||
if (options?.manageBuiltInDaemon ?? true) {
|
||||
await this.bootstrapDesktop();
|
||||
}
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
@@ -1163,20 +1165,16 @@ export class HostRuntimeStore {
|
||||
try {
|
||||
const daemon = await startDesktopDaemon();
|
||||
const connection = connectionFromListen(daemon.listen);
|
||||
if (!connection) {
|
||||
if (!connection || !daemon.serverId) {
|
||||
return;
|
||||
}
|
||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: hostname ?? daemon.hostname ?? undefined,
|
||||
serverId: daemon.serverId,
|
||||
label: daemon.hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
|
||||
console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { SidebarMenuToggle } 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";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
@@ -19,8 +18,10 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
|
||||
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
|
||||
const collapsedSidebarInset =
|
||||
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
|
||||
? trafficLightPadding
|
||||
: { left: 0, right: 0 };
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +32,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
|
||||
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: collapsedSidebarInset.left, paddingRight: collapsedSidebarInset.right }]}>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"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.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -294,7 +294,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,36 @@ 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 resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync(
|
||||
"wmic",
|
||||
["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
nodePath: null,
|
||||
error: "ps returned an empty command path",
|
||||
error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { nodePath: resolved };
|
||||
// wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n"
|
||||
const match = result.stdout.match(/ExecutablePath=(.+)/);
|
||||
const resolved = match?.[1]?.trim();
|
||||
return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" };
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
return platform() === "win32"
|
||||
? resolveNodePathFromPidWindows(pid)
|
||||
: resolveNodePathFromPidUnix(pid);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 + "/")
|
||||
);
|
||||
}
|
||||
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
|
||||
console.log("✓ rejects empty and non-host listen values\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
|
||||
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
|
||||
console.log("✓ rejects Windows absolute paths\n");
|
||||
}
|
||||
|
||||
console.log("=== All local daemon utility tests passed ===");
|
||||
|
||||
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
|
||||
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
|
||||
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||
|
||||
213
packages/cli/tests/30-cwd-filter-paths.test.ts
Normal file
213
packages/cli/tests/30-cwd-filter-paths.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* CWD Filter Path Tests
|
||||
*
|
||||
* Tests that cwd filtering in agent commands works correctly
|
||||
* with both Unix and Windows-style paths.
|
||||
*
|
||||
* Bug: The original code used hardcoded "/" separators for:
|
||||
* 1. Stripping trailing separators (only stripped "/", not "\")
|
||||
* 2. Descendant matching (appended "/" instead of platform separator)
|
||||
*
|
||||
* This causes Windows paths like "C:\Users\dev\project" to fail matching
|
||||
* against "C:\Users\dev\project\sub" because the code appends "/" instead
|
||||
* of "\" for the startsWith check.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
|
||||
|
||||
console.log("=== CWD Filter Path Tests ===\n");
|
||||
|
||||
// Test 1: Unix exact match
|
||||
{
|
||||
console.log("Test 1: Unix exact match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
|
||||
true,
|
||||
"exact Unix paths should match",
|
||||
);
|
||||
console.log("✓ Unix exact match\n");
|
||||
}
|
||||
|
||||
// Test 2: Unix descendant match
|
||||
{
|
||||
console.log("Test 2: Unix descendant match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
|
||||
true,
|
||||
"Unix descendant should match",
|
||||
);
|
||||
console.log("✓ Unix descendant match\n");
|
||||
}
|
||||
|
||||
// Test 3: Unix non-match (sibling)
|
||||
{
|
||||
console.log("Test 3: Unix non-match (sibling)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
|
||||
false,
|
||||
"sibling directories should not match",
|
||||
);
|
||||
console.log("✓ Unix non-match (sibling)\n");
|
||||
}
|
||||
|
||||
// Test 4: Unix prefix overlap (project vs project2)
|
||||
{
|
||||
console.log("Test 4: Unix prefix overlap (project vs project2)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
|
||||
false,
|
||||
"prefix overlap without separator should not match",
|
||||
);
|
||||
console.log("✓ Unix prefix overlap\n");
|
||||
}
|
||||
|
||||
// Test 5: Unix trailing slash on base
|
||||
{
|
||||
console.log("Test 5: Unix trailing slash on base");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
|
||||
true,
|
||||
"trailing slash on base should still match descendants",
|
||||
);
|
||||
console.log("✓ Unix trailing slash on base\n");
|
||||
}
|
||||
|
||||
// Test 6: Unix trailing slash on candidate
|
||||
{
|
||||
console.log("Test 6: Unix trailing slash on candidate");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
|
||||
true,
|
||||
"trailing slash on candidate should match as same dir",
|
||||
);
|
||||
console.log("✓ Unix trailing slash on candidate\n");
|
||||
}
|
||||
|
||||
// Test 7: Windows exact match
|
||||
{
|
||||
console.log("Test 7: Windows exact match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
|
||||
true,
|
||||
"exact Windows paths should match",
|
||||
);
|
||||
console.log("✓ Windows exact match\n");
|
||||
}
|
||||
|
||||
// Test 8: Windows descendant match
|
||||
{
|
||||
console.log("Test 8: Windows descendant match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
|
||||
true,
|
||||
"Windows descendant should match",
|
||||
);
|
||||
console.log("✓ Windows descendant match\n");
|
||||
}
|
||||
|
||||
// Test 9: Windows non-match (sibling)
|
||||
{
|
||||
console.log("Test 9: Windows non-match (sibling)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
|
||||
false,
|
||||
"Windows sibling directories should not match",
|
||||
);
|
||||
console.log("✓ Windows non-match (sibling)\n");
|
||||
}
|
||||
|
||||
// Test 10: Windows prefix overlap (project vs project2)
|
||||
{
|
||||
console.log("Test 10: Windows prefix overlap (project vs project2)");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
|
||||
false,
|
||||
"Windows prefix overlap without separator should not match",
|
||||
);
|
||||
console.log("✓ Windows prefix overlap\n");
|
||||
}
|
||||
|
||||
// Test 11: Windows trailing backslash on base
|
||||
{
|
||||
console.log("Test 11: Windows trailing backslash on base");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
|
||||
true,
|
||||
"trailing backslash on base should still match descendants",
|
||||
);
|
||||
console.log("✓ Windows trailing backslash on base\n");
|
||||
}
|
||||
|
||||
// Test 12: Windows trailing backslash on candidate
|
||||
{
|
||||
console.log("Test 12: Windows trailing backslash on candidate");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
|
||||
true,
|
||||
"trailing backslash on candidate should match as same dir",
|
||||
);
|
||||
console.log("✓ Windows trailing backslash on candidate\n");
|
||||
}
|
||||
|
||||
// Test 13: Mixed separators (agent might use \ while CLI sends /)
|
||||
{
|
||||
console.log("Test 13: Mixed separators");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
|
||||
true,
|
||||
"mixed separators should still match",
|
||||
);
|
||||
console.log("✓ Mixed separators\n");
|
||||
}
|
||||
|
||||
// Test 14: Deep Windows descendant
|
||||
{
|
||||
console.log("Test 14: Deep Windows descendant");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath(
|
||||
"C:\\Users\\dev\\project",
|
||||
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
|
||||
),
|
||||
true,
|
||||
"deep Windows descendant should match",
|
||||
);
|
||||
console.log("✓ Deep Windows descendant\n");
|
||||
}
|
||||
|
||||
// Test 15: Case-insensitive Windows descendant match
|
||||
{
|
||||
console.log("Test 15: Case-insensitive Windows descendant match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
|
||||
true,
|
||||
"Windows paths with different casing should match (case-insensitive)",
|
||||
);
|
||||
console.log("✓ Case-insensitive Windows descendant match\n");
|
||||
}
|
||||
|
||||
// Test 16: Case-insensitive Windows exact match
|
||||
{
|
||||
console.log("Test 16: Case-insensitive Windows exact match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
|
||||
true,
|
||||
"Windows paths with different casing should match exactly (case-insensitive)",
|
||||
);
|
||||
console.log("✓ Case-insensitive Windows exact match\n");
|
||||
}
|
||||
|
||||
// Test 17: Parent should not match
|
||||
{
|
||||
console.log("Test 15: Parent should not match");
|
||||
assert.strictEqual(
|
||||
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
|
||||
false,
|
||||
"parent directory should not match (only same-or-descendant)",
|
||||
);
|
||||
console.log("✓ Parent should not match\n");
|
||||
}
|
||||
|
||||
console.log("=== All CWD filter path tests passed ===");
|
||||
@@ -9,5 +9,6 @@ if not exist "%APP_EXECUTABLE%" (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "PASEO_DESKTOP_CLI=1"
|
||||
"%APP_EXECUTABLE%" %*
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
@@ -14,6 +14,7 @@ publish:
|
||||
owner: getpaseo
|
||||
repo: paseo
|
||||
mac:
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
category: public.app-category.developer-tools
|
||||
icon: assets/icon.icns
|
||||
hardenedRuntime: true
|
||||
@@ -30,11 +31,15 @@ linux:
|
||||
category: Development
|
||||
icon: assets
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
maintainer: "Mohamed Boudra <hello@moboudra.com>"
|
||||
vendor: "Paseo"
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
- tar.gz
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,9 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@getpaseo/cli": "0.1.37",
|
||||
"@getpaseo/server": "0.1.37",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -25,5 +26,15 @@
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"wait-on": "8.0.5"
|
||||
}
|
||||
},
|
||||
"homepage": "https://paseo.sh",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later"
|
||||
}
|
||||
|
||||
@@ -189,7 +189,8 @@ 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;
|
||||
}
|
||||
@@ -544,12 +545,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
return downloadAndInstallUpdate(currentVersion);
|
||||
},
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
webview_log: (args) => {
|
||||
const level = typeof args?.level === "number" ? args.level : 1;
|
||||
const message = typeof args?.message === "string" ? args.message : "";
|
||||
const method = level === 0 ? "debug" : level === 2 ? "warn" : level >= 3 ? "error" : "info";
|
||||
console[method]("[webview]", message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import log from "electron-log/main";
|
||||
log.initialize();
|
||||
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -89,8 +92,17 @@ async function createMainWindow(): Promise<void> {
|
||||
height: 800,
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: isMac ? "hidden" : "default",
|
||||
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined,
|
||||
titleBarStyle: "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 48,
|
||||
},
|
||||
autoHideMenuBar: true,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "electron-log/preload";
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -63,8 +63,8 @@
|
||||
"@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.37",
|
||||
"@getpaseo/relay": "0.1.37",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -231,6 +231,16 @@ export type ManagedAgent =
|
||||
| ManagedAgentError
|
||||
| ManagedAgentClosed;
|
||||
|
||||
export interface AgentMetricsSnapshot {
|
||||
total: number;
|
||||
byLifecycle: Record<string, number>;
|
||||
withActiveForegroundTurn: number;
|
||||
timelineStats: {
|
||||
totalItems: number;
|
||||
maxItemsPerAgent: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveManagedAgent =
|
||||
| ManagedAgentInitializing
|
||||
| ManagedAgentIdle
|
||||
@@ -345,6 +355,37 @@ export class AgentManager {
|
||||
this.onAgentAttention = callback;
|
||||
}
|
||||
|
||||
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
||||
const byLifecycle: Record<string, number> = {};
|
||||
let withActiveForegroundTurn = 0;
|
||||
let totalItems = 0;
|
||||
let maxItemsPerAgent = 0;
|
||||
|
||||
for (const agent of this.agents.values()) {
|
||||
byLifecycle[agent.lifecycle] = (byLifecycle[agent.lifecycle] ?? 0) + 1;
|
||||
|
||||
if (agent.activeForegroundTurnId !== null) {
|
||||
withActiveForegroundTurn++;
|
||||
}
|
||||
|
||||
const len = agent.timeline.length;
|
||||
totalItems += len;
|
||||
if (len > maxItemsPerAgent) {
|
||||
maxItemsPerAgent = len;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: this.agents.size,
|
||||
byLifecycle,
|
||||
withActiveForegroundTurn,
|
||||
timelineStats: {
|
||||
totalItems,
|
||||
maxItemsPerAgent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private touchUpdatedAt(agent: ManagedAgent): Date {
|
||||
const nowMs = Date.now();
|
||||
const previousMs = agent.updatedAt.getTime();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
@@ -352,6 +352,24 @@ describe("AgentStorage", () => {
|
||||
expect(records[0]?.internal).toBe(true);
|
||||
});
|
||||
|
||||
test("Windows drive-letter paths produce valid directory names", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "win-agent",
|
||||
cwd: "D:\\Users\\dev\\MyProject",
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("win-agent");
|
||||
expect(record).not.toBeNull();
|
||||
|
||||
// The persisted directory must not contain a colon (invalid on Windows)
|
||||
const dirs = readdirSync(storagePath);
|
||||
expect(dirs).toHaveLength(1);
|
||||
expect(dirs[0]).not.toContain(":");
|
||||
expect(dirs[0]).toBe("D-Users-dev-MyProject");
|
||||
});
|
||||
|
||||
test("remove deletes all duplicate record files across project directories", async () => {
|
||||
const agentId = "agent-duplicate";
|
||||
|
||||
|
||||
@@ -335,11 +335,16 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
function projectDirNameFromCwd(cwd: string): string {
|
||||
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
||||
if (!trimmed) {
|
||||
return "root";
|
||||
// path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
|
||||
const { root } = path.win32.parse(cwd);
|
||||
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
|
||||
// Sanitize root: strip colons and separators, keep letters (e.g. "C:\" → "C", "\\server\share\" → "server-share")
|
||||
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const prefix = sanitizedRoot ? sanitizedRoot + "-" : "";
|
||||
if (!withoutRoot) {
|
||||
return sanitizedRoot || "root";
|
||||
}
|
||||
return trimmed.replace(/[\\/]+/g, "-");
|
||||
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
|
||||
}
|
||||
|
||||
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import type { AgentModelDefinition } from "../agent-sdk-types.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function isBinaryInstalled(binary: string): boolean {
|
||||
@@ -15,6 +16,16 @@ function isBinaryInstalled(binary: string): boolean {
|
||||
const hasCodex = isBinaryInstalled("codex");
|
||||
const hasOpenCode = isBinaryInstalled("opencode");
|
||||
|
||||
function modelMatchesFamily(
|
||||
model: AgentModelDefinition,
|
||||
family: "sonnet" | "haiku",
|
||||
): boolean {
|
||||
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
|
||||
value.toLowerCase(),
|
||||
);
|
||||
return haystacks.some((text) => text.includes(family));
|
||||
}
|
||||
|
||||
describe("provider model catalogs (e2e)", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
@@ -32,11 +43,8 @@ describe("provider model catalogs (e2e)", () => {
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
|
||||
const descriptions = result.models.map((model) =>
|
||||
`${model.label} ${model.description ?? ""}`.toLowerCase(),
|
||||
);
|
||||
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
|
||||
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
|
||||
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
|
||||
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
|
||||
}, 180_000);
|
||||
|
||||
test.runIf(hasCodex)(
|
||||
|
||||
@@ -67,7 +67,11 @@ import type {
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||
|
||||
const fsPromises = promises;
|
||||
@@ -201,11 +205,14 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
...options,
|
||||
spawnClaudeCodeProcess: (spawnOptions) => {
|
||||
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
|
||||
// The SDK defaults to spawning "node" via PATH lookup, which fails when
|
||||
// running from the managed runtime bundle where node isn't in PATH.
|
||||
// Always use process.execPath — the actual node binary running the daemon.
|
||||
const command =
|
||||
resolved.command === spawnOptions.command ? process.execPath : resolved.command;
|
||||
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
|
||||
// process.execPath — the actual node binary running the daemon. This avoids
|
||||
// PATH lookup failures in the managed runtime bundle.
|
||||
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
|
||||
// or the user overrides the command via runtime settings, use that directly.
|
||||
const isDefaultRuntime =
|
||||
resolved.command === "node" || resolved.command === "bun";
|
||||
const command = isDefaultRuntime ? process.execPath : resolved.command;
|
||||
const child = spawn(command, resolved.args, {
|
||||
cwd: spawnOptions.cwd,
|
||||
env: {
|
||||
@@ -1855,12 +1862,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
.join("\n\n");
|
||||
|
||||
const claudeBinary = findExecutable("claude");
|
||||
const base: ClaudeOptions = {
|
||||
cwd: this.config.cwd,
|
||||
includePartialMessages: true,
|
||||
permissionMode: this.currentMode,
|
||||
agents: this.defaults?.agents,
|
||||
canUseTool: this.handlePermissionRequest,
|
||||
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
|
||||
// Use Claude Code preset system prompt and load CLAUDE.md files
|
||||
// Append provider-agnostic system prompt and orchestrator instructions for agents.
|
||||
systemPrompt: {
|
||||
|
||||
@@ -579,4 +579,127 @@ describe("codex tool-call mapper", () => {
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
it("maps apply_patch with Delete File directive into edit detail with removed lines", () => {
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Delete File: /tmp/repo/src/dead-module.ts",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-delete-rollout",
|
||||
name: "apply_patch",
|
||||
input: patch,
|
||||
output:
|
||||
'{"output":"Success. Updated the following files:\\nD /tmp/repo/src/dead-module.ts\\n"}',
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/dead-module.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("/dev/null");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps multi-file apply_patch with update + delete into edit detail referencing the deleted file", () => {
|
||||
// Exact data shape from real Codex rollout: update one file, delete another
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /tmp/repo/src/app/index.tsx",
|
||||
"@@",
|
||||
' import { useEffect } from "react";',
|
||||
'-import { WELCOME_ROUTE } from "@/app-support/index-startup";',
|
||||
"+",
|
||||
'+const WELCOME_ROUTE = "/welcome";',
|
||||
"*** Delete File: /tmp/repo/src/app-support/index-startup.ts",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const item = expectMapped(
|
||||
mapCodexRolloutToolCall({
|
||||
callId: "codex-delete-multi",
|
||||
name: "apply_patch",
|
||||
input: patch,
|
||||
output: JSON.stringify({
|
||||
output:
|
||||
"Success. Updated the following files:\nM /tmp/repo/src/app/index.tsx\nD /tmp/repo/src/app-support/index-startup.ts\n",
|
||||
metadata: { exit_code: 0, duration_seconds: 0.0 },
|
||||
}),
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
// The unified diff should contain both file sections
|
||||
const diff = item.detail.unifiedDiff ?? "";
|
||||
// The update file section should have normal diff lines
|
||||
expect(diff).toContain("-import");
|
||||
expect(diff).toContain("+const WELCOME_ROUTE");
|
||||
// The delete file section should reference /dev/null
|
||||
expect(diff).toContain("/dev/null");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange delete with content as removed lines, not added lines", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-delete-with-content",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/dead-module.ts",
|
||||
kind: "delete",
|
||||
content: 'export const FOO = "bar";\nexport function hello() {}\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.detail.type).toBe("edit");
|
||||
if (item?.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/dead-module.ts");
|
||||
const diff = item.detail.unifiedDiff ?? "";
|
||||
// For a deletion, the content should appear as REMOVED lines (-)
|
||||
// not as ADDED lines (+). This is the core bug.
|
||||
expect(diff).toContain("/dev/null");
|
||||
expect(diff).toContain("-export const FOO");
|
||||
expect(diff).toContain("-export function hello");
|
||||
// The content must NOT appear as added lines
|
||||
expect(diff).not.toContain("+export const FOO");
|
||||
expect(diff).not.toContain("+export function hello");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange delete without content to edit detail with /dev/null marker", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-delete-no-content",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/dead-module.ts",
|
||||
kind: "delete",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" },
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
// A delete without content should still produce a meaningful detail
|
||||
if (item?.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/dead-module.ts");
|
||||
const diff = item.detail.unifiedDiff ?? "";
|
||||
expect(diff).toContain("/dev/null");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,7 +347,7 @@ function normalizeDiffHeaderPath(rawPath: string): string {
|
||||
function codexApplyPatchToUnifiedDiff(text: string): string {
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
let sawDiffBody = false;
|
||||
let sawDiffContent = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const directive = parseCodexApplyPatchDirective(line);
|
||||
@@ -362,6 +362,7 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
|
||||
output.push(`diff --git a/${path} b/${path}`);
|
||||
output.push(`--- ${left}`);
|
||||
output.push(`+++ ${right}`);
|
||||
sawDiffContent = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -378,22 +379,22 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
output.push(line);
|
||||
sawDiffBody = true;
|
||||
sawDiffContent = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) {
|
||||
output.push(line);
|
||||
sawDiffBody = true;
|
||||
sawDiffContent = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("\\ No newline at end of file")) {
|
||||
output.push(line);
|
||||
sawDiffBody = true;
|
||||
sawDiffContent = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawDiffBody) {
|
||||
if (!sawDiffContent) {
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -401,6 +402,22 @@ function codexApplyPatchToUnifiedDiff(text: string): string {
|
||||
return normalized.length > 0 ? normalized : text;
|
||||
}
|
||||
|
||||
function contentToDeletionDiff(filePath: string, content: string): string {
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
output.push(`diff --git a/${filePath} b/${filePath}`);
|
||||
output.push(`--- a/${filePath}`);
|
||||
output.push(`+++ /dev/null`);
|
||||
const nonEmpty = lines.filter((l) => l.length > 0 || lines.indexOf(l) < lines.length - 1);
|
||||
if (nonEmpty.length > 0) {
|
||||
output.push(`@@ -1,${nonEmpty.length} +0,0 @@`);
|
||||
for (const line of nonEmpty) {
|
||||
output.push(`-${line}`);
|
||||
}
|
||||
}
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
function classifyDiffLikeText(
|
||||
text: string,
|
||||
): { isDiff: true; text: string } | { isDiff: false; text: string } {
|
||||
@@ -716,6 +733,26 @@ function parseFileChangeEntries(
|
||||
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
|
||||
}
|
||||
|
||||
function resolveFileChangeTextFields(
|
||||
file: CodexFileChangeEntry | undefined,
|
||||
): { unifiedDiff?: string; newString?: string } {
|
||||
if (!file) {
|
||||
return {};
|
||||
}
|
||||
const isDelete = file.kind === "delete";
|
||||
if (isDelete && file.diff) {
|
||||
const classified = classifyDiffLikeText(file.diff);
|
||||
if (classified.isDiff) {
|
||||
return { unifiedDiff: truncateDiffText(classified.text) };
|
||||
}
|
||||
return { unifiedDiff: truncateDiffText(contentToDeletionDiff(file.path, file.diff)) };
|
||||
}
|
||||
if (isDelete && !file.diff) {
|
||||
return { unifiedDiff: contentToDeletionDiff(file.path, "") };
|
||||
}
|
||||
return asEditTextFields(file.diff);
|
||||
}
|
||||
|
||||
function mapFileChangeItem(
|
||||
item: z.infer<typeof CodexFileChangeItemSchema>,
|
||||
options?: CodexMapperOptions,
|
||||
@@ -739,7 +776,9 @@ function mapFileChangeItem(
|
||||
files: files.map((file) => ({
|
||||
path: file.path,
|
||||
...(file.kind !== undefined ? { kind: file.kind } : {}),
|
||||
...asEditFileOutputFields(file.diff),
|
||||
...(file.kind === "delete"
|
||||
? { patch: resolveFileChangeTextFields(file).unifiedDiff }
|
||||
: asEditFileOutputFields(file.diff)),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
@@ -749,7 +788,7 @@ function mapFileChangeItem(
|
||||
const error = item.error ?? null;
|
||||
const status = resolveStatus(item.status, error, output);
|
||||
const firstFile = files[0];
|
||||
const firstTextFields = asEditTextFields(firstFile?.diff);
|
||||
const firstTextFields = resolveFileChangeTextFields(firstFile);
|
||||
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
|
||||
const input = toNullableObject({
|
||||
...inputBase,
|
||||
|
||||
@@ -597,6 +597,42 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readOpenCodeRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeQuestionOptions(
|
||||
value: unknown,
|
||||
): Array<{ label: string; description?: string }> | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options: Array<{ label: string; description?: string }> = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" && item.trim().length > 0) {
|
||||
options.push({ label: item.trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = readOpenCodeRecord(item);
|
||||
const label = readNonEmptyString(record?.label);
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
const description = readNonEmptyString(record?.description);
|
||||
options.push(description ? { label, description } : { label });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function translateOpenCodeEvent(
|
||||
event: unknown,
|
||||
state: OpenCodeEventTranslationState,
|
||||
@@ -785,6 +821,60 @@ export function translateOpenCodeEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "question.asked": {
|
||||
const sessionId = props.sessionID as string | undefined;
|
||||
if (sessionId !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestId = props.id as string | undefined;
|
||||
const rawQuestions = Array.isArray(props.questions) ? props.questions : [];
|
||||
if (!requestId || rawQuestions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const questions = rawQuestions.flatMap((item) => {
|
||||
const questionRecord = readOpenCodeRecord(item);
|
||||
const question = readNonEmptyString(questionRecord?.question);
|
||||
const header = readNonEmptyString(questionRecord?.header);
|
||||
if (!question || !header) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = normalizeQuestionOptions(questionRecord?.options) ?? [];
|
||||
return [
|
||||
{
|
||||
question,
|
||||
header,
|
||||
options,
|
||||
...(questionRecord?.multiple === true ? { multiSelect: true } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (questions.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: requestId,
|
||||
provider: "opencode",
|
||||
name: "question",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
input: { questions },
|
||||
metadata: {
|
||||
source: "opencode_question",
|
||||
...(readOpenCodeRecord(props.tool) ?? {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.idle": {
|
||||
const sessionId = props.sessionID as string | undefined;
|
||||
if (sessionId === state.sessionId) {
|
||||
@@ -1196,6 +1286,38 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
throw new Error(`No pending permission request with id '${requestId}'`);
|
||||
}
|
||||
|
||||
if (pending.kind === "question") {
|
||||
if (response.behavior === "deny") {
|
||||
await this.client.question.reject({
|
||||
requestID: requestId,
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
} else {
|
||||
const answersRecord = readOpenCodeRecord(response.updatedInput?.answers);
|
||||
const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : [];
|
||||
const answers = questions.map((item) => {
|
||||
const header = readNonEmptyString(readOpenCodeRecord(item)?.header);
|
||||
const rawAnswer = header ? readNonEmptyString(answersRecord?.[header]) : null;
|
||||
if (!rawAnswer) {
|
||||
return [];
|
||||
}
|
||||
return rawAnswer
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
});
|
||||
|
||||
await this.client.question.reply({
|
||||
requestID: requestId,
|
||||
directory: this.config.cwd,
|
||||
answers,
|
||||
});
|
||||
}
|
||||
|
||||
this.pendingPermissions.delete(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const reply = response.behavior === "allow" ? "once" : "reject";
|
||||
await this.client.permission.reply({
|
||||
requestID: requestId,
|
||||
|
||||
@@ -122,6 +122,23 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("parses whitespace-padded numeric port strings", () => {
|
||||
expect(parseListenString(" 6767 ")).toEqual({
|
||||
type: "tcp",
|
||||
host: "127.0.0.1",
|
||||
port: 6767,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Windows absolute paths that are not named pipes", () => {
|
||||
// A Windows drive path like C:\daemon must NOT be silently parsed as TCP
|
||||
// (split(":") would yield host="C" and port="\\daemon" which is nonsensical).
|
||||
expect(() => parseListenString(String.raw`C:\daemon`)).toThrow();
|
||||
expect(() => parseListenString(String.raw`D:\Users\foo\.paseo\daemon.sock`)).toThrow();
|
||||
// Single-letter "host" with no valid port is not a valid listen string
|
||||
expect(() => parseListenString(String.raw`C:\some\path`)).toThrow();
|
||||
});
|
||||
|
||||
test("parses Windows named pipes as managed IPC listen targets", () => {
|
||||
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
|
||||
type: "pipe",
|
||||
|
||||
@@ -35,34 +35,43 @@ function resolveBoundListenTarget(
|
||||
};
|
||||
}
|
||||
|
||||
// Matches a Windows drive-letter path like C:\ or D:\
|
||||
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
|
||||
|
||||
export function parseListenString(listen: string): ListenTarget {
|
||||
// 1. Windows named pipes: \\.\pipe\... or pipe://...
|
||||
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
||||
return {
|
||||
type: "pipe",
|
||||
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
|
||||
};
|
||||
}
|
||||
// Unix socket: starts with / or ~ or contains .sock
|
||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
||||
return { type: "socket", path: listen };
|
||||
}
|
||||
// Explicit unix:// prefix
|
||||
// 2. Explicit unix:// prefix
|
||||
if (listen.startsWith("unix://")) {
|
||||
return { type: "socket", path: listen.slice(7) };
|
||||
}
|
||||
// TCP: host:port or just port
|
||||
// 3. Reject Windows absolute drive paths — they are not Unix sockets
|
||||
if (WINDOWS_DRIVE_RE.test(listen)) {
|
||||
throw new Error(`Invalid listen string (Windows path is not a valid listen target): ${listen}`);
|
||||
}
|
||||
// 4. POSIX absolute path (/ or ~) — Unix socket
|
||||
if (listen.startsWith("/") || listen.startsWith("~")) {
|
||||
return { type: "socket", path: listen };
|
||||
}
|
||||
// 5. Pure numeric — TCP port on 127.0.0.1
|
||||
const trimmed = listen.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const port = parseInt(trimmed, 10);
|
||||
return { type: "tcp", host: "127.0.0.1", port };
|
||||
}
|
||||
// 6. host:port — TCP
|
||||
if (listen.includes(":")) {
|
||||
const [host, portStr] = listen.split(":");
|
||||
const port = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(port)) {
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(parsedPort)) {
|
||||
throw new Error(`Invalid port in listen string: ${listen}`);
|
||||
}
|
||||
return { type: "tcp", host: host || "127.0.0.1", port };
|
||||
}
|
||||
// Just a port number
|
||||
const port = parseInt(listen, 10);
|
||||
if (Number.isFinite(port)) {
|
||||
return { type: "tcp", host: "127.0.0.1", port };
|
||||
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
|
||||
}
|
||||
throw new Error(`Invalid listen string: ${listen}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-"));
|
||||
}
|
||||
|
||||
function pickOpenCodeModel(
|
||||
models: Array<{ id: string }>,
|
||||
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
|
||||
): string {
|
||||
const preferred = models.find((model) => preferences.some((fragment) => model.id.includes(fragment)));
|
||||
return preferred?.id ?? models[0]!.id;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<{
|
||||
client: DaemonClient;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
}> {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: new OpenCodeAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-real" } });
|
||||
return { client, daemon };
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => {
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"surfaces clarifying questions as pending permissions",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
|
||||
try {
|
||||
const modelList = await client.listProviderModels("opencode");
|
||||
expect(modelList.models.length).toBeGreaterThan(0);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode question regression",
|
||||
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the question tool/feature to ask me exactly one clarifying question.",
|
||||
"Ask this exact question: What kind of project should the plan cover?",
|
||||
"Wait for my answer before doing anything else.",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const snapshotWithQuestion = await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question",
|
||||
30_000,
|
||||
);
|
||||
|
||||
expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0);
|
||||
|
||||
const permission = snapshotWithQuestion.pendingPermissions?.[0];
|
||||
expect(permission).toBeTruthy();
|
||||
expect(permission?.kind).toBe("question");
|
||||
expect(Array.isArray(permission?.input?.questions)).toBe(true);
|
||||
|
||||
const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined;
|
||||
expect(firstQuestion?.header).toBeTruthy();
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"plan mode stays read-only through the daemon path",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const filePath = path.join(cwd, "plan-mode-output.txt");
|
||||
const { client, daemon } = await createHarness();
|
||||
|
||||
try {
|
||||
const modelList = await client.listProviderModels("opencode");
|
||||
expect(modelList.models.length).toBeGreaterThan(0);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode plan mode regression",
|
||||
model: pickOpenCodeModel(modelList.models),
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
|
||||
);
|
||||
|
||||
const state = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(state.status).toBe("idle");
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => entry.item.text)
|
||||
.join(" ");
|
||||
|
||||
expect(assistantText.toLowerCase()).toContain("plan");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000,
|
||||
);
|
||||
});
|
||||
@@ -291,6 +291,8 @@ export type SessionRuntimeMetrics = {
|
||||
checkoutDiffFallbackRefreshTargetCount: number;
|
||||
terminalDirectorySubscriptionCount: number;
|
||||
terminalSubscriptionCount: number;
|
||||
inflightRequests: number;
|
||||
peakInflightRequests: number;
|
||||
};
|
||||
|
||||
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
|
||||
@@ -610,6 +612,8 @@ export class Session {
|
||||
private readonly activeTerminalStreams = new Map<number, ActiveTerminalStream>();
|
||||
private readonly terminalIdToSlot = new Map<string, number>();
|
||||
private nextTerminalSlot = 0;
|
||||
private inflightRequests = 0;
|
||||
private peakInflightRequests = 0;
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
|
||||
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
|
||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
@@ -754,6 +758,8 @@ export class Session {
|
||||
checkoutDiffFallbackRefreshTargetCount,
|
||||
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
|
||||
terminalSubscriptionCount: this.activeTerminalStreams.size,
|
||||
inflightRequests: this.inflightRequests,
|
||||
peakInflightRequests: this.peakInflightRequests,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1454,6 +1460,11 @@ export class Session {
|
||||
* Main entry point for processing session messages
|
||||
*/
|
||||
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
|
||||
this.inflightRequests++;
|
||||
if (this.inflightRequests > this.peakInflightRequests) {
|
||||
this.peakInflightRequests = this.inflightRequests;
|
||||
}
|
||||
try {
|
||||
this.sessionLogger.trace({ inbound: msg }, "inbound message");
|
||||
try {
|
||||
switch (msg.type) {
|
||||
@@ -1779,6 +1790,13 @@ export class Session {
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.inflightRequests--;
|
||||
}
|
||||
}
|
||||
|
||||
public resetPeakInflight(): void {
|
||||
this.peakInflightRequests = this.inflightRequests;
|
||||
}
|
||||
|
||||
public handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||
|
||||
@@ -70,6 +70,21 @@ export function resolveSherpaLoaderEnv(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the actual case-sensitive key in a plain object that matches the given
|
||||
* key case-insensitively. On Windows, `{...process.env}` produces a plain
|
||||
* (case-sensitive) object where PATH is typically stored as `Path`. Using a
|
||||
* hardcoded `"PATH"` would miss the existing key and create a duplicate,
|
||||
* breaking the child process's PATH.
|
||||
*/
|
||||
function findEnvKey(env: NodeJS.ProcessEnv, key: string): string {
|
||||
const lower = key.toLowerCase();
|
||||
for (const k of Object.keys(env)) {
|
||||
if (k.toLowerCase() === lower) return k;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function applySherpaLoaderEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -90,9 +105,10 @@ export function applySherpaLoaderEnv(
|
||||
};
|
||||
}
|
||||
|
||||
const next = prependEnvPath(env[resolved.key], resolved.libDir);
|
||||
const changed = next !== (env[resolved.key] ?? "");
|
||||
env[resolved.key] = next;
|
||||
const actualKey = findEnvKey(env, resolved.key);
|
||||
const next = prependEnvPath(env[actualKey], resolved.libDir);
|
||||
const changed = next !== (env[actualKey] ?? "");
|
||||
env[actualKey] = next;
|
||||
return {
|
||||
changed,
|
||||
key: resolved.key,
|
||||
|
||||
@@ -197,6 +197,7 @@ type WebSocketRuntimeCounters = {
|
||||
hostRejected: number;
|
||||
};
|
||||
|
||||
const SLOW_REQUEST_THRESHOLD_MS = 500;
|
||||
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
|
||||
const HELLO_TIMEOUT_MS = 15_000;
|
||||
const WS_CLOSE_HELLO_TIMEOUT = 4001;
|
||||
@@ -275,6 +276,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
};
|
||||
private readonly inboundMessageCounts = new Map<string, number>();
|
||||
private readonly inboundSessionRequestCounts = new Map<string, number>();
|
||||
private readonly requestLatencies = new Map<string, number[]>();
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -1056,7 +1058,21 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
if (message.type === "session") {
|
||||
this.recordInboundSessionRequestType(message.message.type);
|
||||
const startMs = performance.now();
|
||||
await activeConnection.session.handleMessage(message.message);
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.recordRequestLatency(message.message.type, durationMs);
|
||||
|
||||
if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
|
||||
activeConnection.connectionLogger.warn(
|
||||
{
|
||||
requestType: message.message.type,
|
||||
durationMs: Math.round(durationMs),
|
||||
inflightRequests: activeConnection.session.getRuntimeMetrics().inflightRequests,
|
||||
},
|
||||
"ws_slow_request",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
@@ -1147,10 +1163,49 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.incrementCount(this.inboundSessionRequestCounts, type);
|
||||
}
|
||||
|
||||
private recordRequestLatency(type: string, durationMs: number): void {
|
||||
let latencies = this.requestLatencies.get(type);
|
||||
if (!latencies) {
|
||||
latencies = [];
|
||||
this.requestLatencies.set(type, latencies);
|
||||
}
|
||||
latencies.push(durationMs);
|
||||
}
|
||||
|
||||
private getTopCounts(map: Map<string, number>, limit: number): Array<[string, number]> {
|
||||
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
||||
}
|
||||
|
||||
private computeLatencyStats(): Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
totalMs: number;
|
||||
}> {
|
||||
const stats: Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
totalMs: number;
|
||||
}> = [];
|
||||
for (const [type, latencies] of this.requestLatencies) {
|
||||
if (latencies.length === 0) continue;
|
||||
latencies.sort((a, b) => a - b);
|
||||
const count = latencies.length;
|
||||
const minMs = Math.round(latencies[0]!);
|
||||
const maxMs = Math.round(latencies[count - 1]!);
|
||||
const p50Ms = Math.round(latencies[Math.floor(count / 2)]!);
|
||||
const totalMs = Math.round(latencies.reduce((sum, v) => sum + v, 0));
|
||||
stats.push({ type, count, minMs, maxMs, p50Ms, totalMs });
|
||||
}
|
||||
stats.sort((a, b) => b.totalMs - a.totalMs);
|
||||
return stats.slice(0, 15);
|
||||
}
|
||||
|
||||
private collectSessionRuntimeMetrics(): SessionRuntimeMetrics {
|
||||
const uniqueConnections = new Set<SessionConnection>(this.externalSessionsByKey.values());
|
||||
let checkoutDiffTargetCount = 0;
|
||||
@@ -1159,6 +1214,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
let checkoutDiffFallbackRefreshTargetCount = 0;
|
||||
let terminalDirectorySubscriptionCount = 0;
|
||||
let terminalSubscriptionCount = 0;
|
||||
let inflightRequests = 0;
|
||||
let peakInflightRequests = 0;
|
||||
|
||||
for (const connection of uniqueConnections) {
|
||||
const sessionMetrics = connection.session.getRuntimeMetrics();
|
||||
@@ -1169,6 +1226,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
sessionMetrics.checkoutDiffFallbackRefreshTargetCount;
|
||||
terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount;
|
||||
terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount;
|
||||
inflightRequests += sessionMetrics.inflightRequests;
|
||||
peakInflightRequests = Math.max(peakInflightRequests, sessionMetrics.peakInflightRequests);
|
||||
connection.session.resetPeakInflight();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1178,6 +1238,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
checkoutDiffFallbackRefreshTargetCount,
|
||||
terminalDirectorySubscriptionCount,
|
||||
terminalSubscriptionCount,
|
||||
inflightRequests,
|
||||
peakInflightRequests,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1192,6 +1254,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null,
|
||||
).length;
|
||||
const sessionMetrics = this.collectSessionRuntimeMetrics();
|
||||
const latencyStats = this.computeLatencyStats();
|
||||
const agentSnapshot = this.agentManager.getMetricsSnapshot();
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -1210,6 +1274,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12),
|
||||
inboundSessionRequestTypesTop: this.getTopCounts(this.inboundSessionRequestCounts, 20),
|
||||
runtime: sessionMetrics,
|
||||
latency: latencyStats,
|
||||
agents: agentSnapshot,
|
||||
},
|
||||
"ws_runtime_metrics",
|
||||
);
|
||||
@@ -1221,6 +1287,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
this.inboundMessageCounts.clear();
|
||||
this.inboundSessionRequestCounts.clear();
|
||||
this.requestLatencies.clear();
|
||||
this.runtimeWindowStartedAt = now;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@ describe("TerminalManager", () => {
|
||||
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
|
||||
});
|
||||
|
||||
it("accepts Windows absolute paths", async () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow();
|
||||
await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("creates separate terminals for different cwds", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
||||
@@ -121,6 +127,18 @@ describe("TerminalManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reject Windows absolute paths as relative", async () => {
|
||||
manager = createTerminalManager();
|
||||
// Should pass path validation (not throw "cwd must be absolute path").
|
||||
// The terminal may or may not spawn successfully on non-Windows hosts,
|
||||
// so we only assert the validation error is absent.
|
||||
try {
|
||||
await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" });
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toBe("cwd must be absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
it("inherits registered env for the worktree root cwd", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
import { resolve, sep } from "node:path";
|
||||
import { resolve, sep, win32, posix } from "node:path";
|
||||
|
||||
export interface TerminalListItem {
|
||||
id: string;
|
||||
@@ -37,7 +37,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
|
||||
throw new Error("cwd must be absolute path");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
NotGitRepoError,
|
||||
pushCurrentBranch,
|
||||
resolveRepositoryDefaultBranch,
|
||||
parseWorktreeList,
|
||||
isPaseoWorktreePath,
|
||||
isDescendantPath,
|
||||
} from "./checkout-git.js";
|
||||
import { createWorktree } from "./worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
@@ -812,4 +815,73 @@ const x = 1;
|
||||
).rejects.toThrow(/base/i);
|
||||
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
|
||||
});
|
||||
|
||||
describe("parseWorktreeList", () => {
|
||||
it("parses porcelain worktree output", () => {
|
||||
const output = [
|
||||
"worktree /home/user/repo",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /home/user/.paseo/worktrees/feature",
|
||||
"branch refs/heads/feature",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const entries = parseWorktreeList(output);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" });
|
||||
expect(entries[1]).toEqual({
|
||||
path: "/home/user/.paseo/worktrees/feature",
|
||||
branchRef: "refs/heads/feature",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bare repos", () => {
|
||||
const output = ["worktree /home/user/repo.git", "bare", ""].join("\n");
|
||||
const entries = parseWorktreeList(output);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.isBare).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPaseoWorktreePath", () => {
|
||||
it("matches Unix .paseo/worktrees/ paths", () => {
|
||||
expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Windows .paseo\\worktrees\\ paths", () => {
|
||||
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects paths without .paseo/worktrees segment", () => {
|
||||
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
|
||||
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDescendantPath", () => {
|
||||
it("detects children with Unix separators", () => {
|
||||
expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects children with Windows separators", () => {
|
||||
expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects the parent itself", () => {
|
||||
expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects siblings that share a prefix", () => {
|
||||
expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles mixed separators", () => {
|
||||
expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case insensitive on Windows paths", () => {
|
||||
expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||
});
|
||||
const worktrees = parseWorktreeList(worktreeOut);
|
||||
const nonBareNonPaseo = worktrees.filter(
|
||||
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"),
|
||||
(wt) => !wt.isBare && !isPaseoWorktreePath(wt.path),
|
||||
);
|
||||
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/"));
|
||||
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
|
||||
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
|
||||
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
export type GitWorktreeEntry = {
|
||||
path: string;
|
||||
branchRef?: string;
|
||||
isBare?: boolean;
|
||||
};
|
||||
|
||||
function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
|
||||
export function isPaseoWorktreePath(p: string): boolean {
|
||||
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
|
||||
}
|
||||
|
||||
/** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
|
||||
export function isDescendantPath(child: string, parent: string): boolean {
|
||||
let c = child.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
let p = parent.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
// Case-insensitive on Windows (drive letter like C: or D:)
|
||||
if (/^[A-Za-z]:/.test(c) || /^[A-Za-z]:/.test(p)) {
|
||||
c = c.toLowerCase();
|
||||
p = p.toLowerCase();
|
||||
}
|
||||
if (!c.startsWith(p)) return false;
|
||||
if (c.length === p.length) return false;
|
||||
return c[p.length] === "/";
|
||||
}
|
||||
|
||||
export function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
let current: GitWorktreeEntry | null = null;
|
||||
for (const line of output.split("\n")) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.37",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
BIN
packages/website/public/hero-mockup.png
Normal file
BIN
packages/website/public/hero-mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 862 KiB |
BIN
packages/website/public/mobile-mockup.png
Normal file
BIN
packages/website/public/mobile-mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 592 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
122
packages/website/src/components/command-dialog.tsx
Normal file
122
packages/website/src/components/command-dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface CommandDialogProps {
|
||||
trigger: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
footnote?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CommandDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
command,
|
||||
footnote,
|
||||
}: CommandDialogProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button onClick={() => setOpen(!open)}>{trigger}</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-xl border border-white/20 bg-background p-6 space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-base font-medium text-white">{title}</p>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<CodeBlock>{command}</CodeBlock>
|
||||
{footnote && (
|
||||
<p className="text-xs text-white/30">{footnote}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children }: { children: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black/30 backdrop-blur-sm rounded-lg p-3 md:p-4 font-mono text-sm flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground select-none">$ </span>
|
||||
<span className="text-foreground">{children}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M216,28H88A20,20,0,0,0,68,48V76H40A20,20,0,0,0,20,96V216a20,20,0,0,0,20,20H168a20,20,0,0,0,20-20V188h28a20,20,0,0,0,20-20V48A20,20,0,0,0,216,28ZM164,212H44V100H164Zm48-48H188V96a20,20,0,0,0-20-20H92V52H212Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { CursorFieldProvider } from "~/components/butterfly";
|
||||
import websitePackage from "../../package.json";
|
||||
import { CommandDialog } from "~/components/command-dialog";
|
||||
import {
|
||||
appStoreUrl,
|
||||
playStoreUrl,
|
||||
webAppUrl,
|
||||
downloadOptions,
|
||||
useDetectedPlatform,
|
||||
AppleIcon,
|
||||
AndroidIcon,
|
||||
TerminalIcon,
|
||||
GlobeIcon,
|
||||
} from "~/downloads";
|
||||
import "~/styles.css";
|
||||
|
||||
const desktopVersion = websitePackage.version;
|
||||
const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`;
|
||||
|
||||
|
||||
type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux";
|
||||
|
||||
interface DownloadOption {
|
||||
platform: Platform;
|
||||
label: string;
|
||||
href: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
|
||||
}
|
||||
|
||||
const downloadOptions: DownloadOption[] = [
|
||||
{
|
||||
platform: "mac-silicon",
|
||||
label: "Mac",
|
||||
href: `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "mac-intel",
|
||||
label: "Mac Intel",
|
||||
href: `${releaseBase}/Paseo-${desktopVersion}.dmg`,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
label: "Windows",
|
||||
href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`,
|
||||
icon: WindowsIcon,
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
label: "Linux",
|
||||
href: `${releaseBase}/Paseo-${desktopVersion}.AppImage`,
|
||||
icon: LinuxIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function useDetectedPlatform(): Platform {
|
||||
const [platform, setPlatform] = React.useState<Platform>("mac-silicon");
|
||||
|
||||
React.useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes("win")) {
|
||||
setPlatform("windows");
|
||||
} else if (ua.includes("linux")) {
|
||||
setPlatform("linux");
|
||||
} else if (ua.includes("mac")) {
|
||||
// Check for Apple Silicon vs Intel
|
||||
// navigator.platform is deprecated but still the most reliable check
|
||||
const isARM =
|
||||
/arm|aarch64/i.test(navigator.platform) ||
|
||||
// Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData
|
||||
(navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData
|
||||
?.architecture === "arm";
|
||||
setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs
|
||||
}
|
||||
}, []);
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
interface LandingPageProps {
|
||||
title: React.ReactNode;
|
||||
subtitle: string;
|
||||
@@ -96,7 +43,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
>
|
||||
<div className="max-w-6xl lg:max-w-7xl xl:max-w-[90rem] mx-auto">
|
||||
<img
|
||||
src="/paseo-mockup.png"
|
||||
src="/hero-mockup.png"
|
||||
alt="Paseo app showing agent management interface"
|
||||
className="w-full rounded-lg shadow-2xl"
|
||||
/>
|
||||
@@ -109,6 +56,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<main className="p-6 md:p-20 md:pt-8 max-w-5xl mx-auto">
|
||||
<div className="space-y-24">
|
||||
<Features />
|
||||
<MobileSection />
|
||||
<CLISection />
|
||||
<FAQ />
|
||||
<SponsorCTA />
|
||||
@@ -193,7 +141,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<p className="text-white/60 font-medium">Download</p>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
|
||||
href={appStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-white/40 hover:text-white/60 transition-colors"
|
||||
@@ -201,7 +149,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
App Store
|
||||
</a>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=sh.paseo"
|
||||
href={playStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-white/40 hover:text-white/60 transition-colors"
|
||||
@@ -217,7 +165,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
Desktop
|
||||
</a>
|
||||
<a
|
||||
href="https://app.paseo.sh"
|
||||
href={webAppUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-white/40 hover:text-white/60 transition-colors"
|
||||
@@ -255,6 +203,12 @@ function Nav() {
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
<a
|
||||
href="/download"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/jz8T2uahpH"
|
||||
target="_blank"
|
||||
@@ -453,7 +407,7 @@ function Features() {
|
||||
<span className="text-white/40">{feature.icon}</span>
|
||||
<p className="font-medium text-lg">{feature.title}</p>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 leading-relaxed">{feature.description}</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@@ -487,7 +441,7 @@ function GetStarted() {
|
||||
<div className="flex flex-row flex-wrap gap-3">
|
||||
<DownloadButton />
|
||||
<a
|
||||
href="https://app.paseo.sh"
|
||||
href={webAppUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
|
||||
@@ -496,7 +450,7 @@ function GetStarted() {
|
||||
Web App
|
||||
</a>
|
||||
<a
|
||||
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
|
||||
href={appStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
|
||||
@@ -505,7 +459,7 @@ function GetStarted() {
|
||||
<AppleIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=sh.paseo"
|
||||
href={playStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
|
||||
@@ -515,7 +469,15 @@ function GetStarted() {
|
||||
</a>
|
||||
<ServerInstallButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-8">
|
||||
<div className="pt-3">
|
||||
<a
|
||||
href="/download"
|
||||
className="text-xs text-white/40 hover:text-white/70 transition-colors"
|
||||
>
|
||||
All download options
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<span className="text-xs text-white/40">Supports</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
|
||||
@@ -529,133 +491,40 @@ function GetStarted() {
|
||||
|
||||
function DownloadButton() {
|
||||
const detectedPlatform = useDetectedPlatform();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const primary = downloadOptions.find((o) => o.platform === detectedPlatform)!;
|
||||
const secondaryOptions = downloadOptions.filter((o) => o.platform !== detectedPlatform);
|
||||
const PrimaryIcon = primary.icon;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div className="flex items-stretch">
|
||||
<a
|
||||
href={primary.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-l-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
|
||||
>
|
||||
<PrimaryIcon className="h-4 w-4" />
|
||||
Download for {primary.label}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center justify-center rounded-r-lg border-l border-background/20 bg-foreground px-2 py-2 text-background hover:bg-foreground/90 transition-colors"
|
||||
aria-label="More download options"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] rounded-lg border border-white/20 bg-black/90 backdrop-blur-sm py-1"
|
||||
>
|
||||
{secondaryOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<a
|
||||
key={option.platform}
|
||||
href={option.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-white/80 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<a
|
||||
href={primary.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
|
||||
>
|
||||
<PrimaryIcon className="h-4 w-4" />
|
||||
Download for {primary.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerInstallButton() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Install on a server"
|
||||
>
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-xl border border-white/20 bg-background p-6 space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-base font-medium text-white">Run agents on a remote machine</p>
|
||||
<p className="text-sm text-white/60">
|
||||
For headless machines you want to connect to from the Paseo apps. The desktop app
|
||||
already includes a built-in daemon.
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
|
||||
<p className="text-xs text-white/30">
|
||||
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to
|
||||
start the daemon.
|
||||
</p>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<CommandDialog
|
||||
trigger={
|
||||
<span className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors">
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
</span>
|
||||
}
|
||||
title="Run agents on a remote machine"
|
||||
description="For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon."
|
||||
command="npm install -g @getpaseo/cli && paseo"
|
||||
footnote={
|
||||
<>
|
||||
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to
|
||||
start the daemon.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -708,34 +577,6 @@ function OpenCodeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AndroidIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M380.91,199l42.47-73.57a8.63,8.63,0,0,0-3.12-11.76,8.52,8.52,0,0,0-11.71,3.12l-43,74.52c-32.83-15-69.78-23.35-109.52-23.35s-76.69,8.36-109.52,23.35l-43-74.52a8.6,8.6,0,1,0-14.88,8.64L131,199C57.8,238.64,8.19,312.77,0,399.55H512C503.81,312.77,454.2,238.64,380.91,199ZM138.45,327.65a21.46,21.46,0,1,1,21.46-21.46A21.47,21.47,0,0,1,138.45,327.65Zm235,0A21.46,21.46,0,1,1,395,306.19,21.47,21.47,0,0,1,373.49,327.65Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppStoreIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -762,57 +603,6 @@ function AppStoreIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LinuxIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8.996 4.497c.104-.076.1-.168.186-.158s.022.102-.098.207c-.12.104-.308.243-.46.323-.291.152-.631.336-.993.336s-.647-.167-.853-.33c-.102-.082-.186-.162-.248-.221-.11-.086-.096-.207-.052-.204.075.01.087.109.134.153.064.06.144.137.241.214.195.154.454.304.778.304s.702-.19.932-.32c.13-.073.297-.204.433-.304M7.34 3.781c.055-.02.123-.031.174-.003.011.006.024.021.02.034-.012.038-.074.032-.11.05-.032.017-.057.052-.093.054-.034 0-.086-.012-.09-.046-.007-.044.058-.072.1-.089m.581-.003c.05-.028.119-.018.173.003.041.017.106.045.1.09-.004.033-.057.046-.09.045-.036-.002-.062-.037-.093-.053-.036-.019-.098-.013-.11-.051-.004-.013.008-.028.02-.034" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.446.019c2.521.003 2.38 2.66 2.364 4.093-.01.939.509 1.574 1.04 2.244.474.56 1.095 1.38 1.45 2.32.29.765.402 1.613.115 2.465a.8.8 0 0 1 .254.152l.001.002c.207.175.271.447.329.698.058.252.112.488.224.615.344.382.494.667.48.922-.015.254-.203.43-.435.57-.465.28-1.164.491-1.586 1.002-.443.527-.99.83-1.505.871a1.25 1.25 0 0 1-1.256-.716v-.001a1 1 0 0 1-.078-.21c-.67.038-1.252-.165-1.718-.128-.687.038-1.116.204-1.506.206-.151.331-.445.547-.808.63-.5.114-1.126 0-1.743-.324-.577-.306-1.31-.278-1.85-.39-.27-.057-.51-.157-.626-.384-.116-.226-.095-.538.07-.988.051-.16.012-.398-.026-.648a2.5 2.5 0 0 1-.037-.369c0-.133.022-.265.087-.386v-.002c.14-.266.368-.377.577-.451s.397-.125.53-.258c.143-.15.27-.374.443-.56q.036-.037.073-.07c-.081-.538.007-1.105.192-1.662.393-1.18 1.223-2.314 1.811-3.014.502-.713.65-1.287.701-2.016.042-.997-.705-3.974 2.112-4.2q.168-.015.321-.013m2.596 10.866-.03.016c-.223.121-.348.337-.427.656-.08.32-.107.733-.13 1.206v.001c-.023.37-.192.824-.31 1.267s-.176.862-.036 1.128v.002c.226.452.608.636 1.051.601s.947-.304 1.36-.795c.474-.576 1.218-.796 1.638-1.05.21-.126.324-.242.333-.4.009-.157-.097-.403-.425-.767-.17-.192-.217-.462-.274-.71-.056-.247-.122-.468-.26-.585l-.001-.001c-.18-.157-.356-.17-.565-.164q-.069.001-.14.005c-.239.275-.805.612-1.197.508-.359-.09-.562-.508-.587-.918m-7.204.03H3.83c-.189.002-.314.09-.44.225-.149.158-.276.382-.445.56v.002h-.002c-.183.184-.414.239-.61.31-.195.069-.353.143-.46.35v.002c-.085.155-.066.378-.029.624.038.245.096.507.018.746v.002l-.001.002c-.157.427-.155.678-.082.822.074.143.235.22.48.272.493.103 1.26.069 1.906.41.583.305 1.168.404 1.598.305.431-.098.712-.369.75-.867v-.002c.029-.292-.195-.673-.485-1.052-.29-.38-.633-.752-.795-1.09v-.002l-.61-1.11c-.21-.286-.43-.462-.68-.5a1 1 0 0 0-.106-.008M9.584 4.85c-.14.2-.386.37-.695.467-.147.048-.302.17-.495.28a1.3 1.3 0 0 1-.74.19.97.97 0 0 1-.582-.227c-.14-.113-.25-.237-.394-.322a3 3 0 0 1-.192-.126c-.063 1.179-.85 2.658-1.226 3.511a5.4 5.4 0 0 0-.43 1.917c-.68-.906-.184-2.066.081-2.568.297-.55.343-.701.27-.649-.266.436-.685 1.13-.848 1.844-.085.372-.1.749.01 1.097.11.349.345.67.766.931.573.351.963.703 1.193 1.015s.302.584.23.777a.4.4 0 0 1-.212.22.7.7 0 0 1-.307.056l.184.235c.094.124.186.249.266.375 1.179.805 2.567.496 3.568-.218.1-.342.197-.664.212-.903.024-.474.05-.896.136-1.245s.244-.634.53-.791a1 1 0 0 1 .138-.061q.005-.045.013-.087c.082-.546.569-.572 1.18-.303.588.266.81.499.71.814h.13c.122-.398-.133-.69-.822-1.025l-.137-.06a2.35 2.35 0 0 0-.012-1.113c-.188-.79-.704-1.49-1.098-1.838-.072-.003-.065.06.081.203.363.333 1.156 1.532.727 2.644a1.2 1.2 0 0 0-.342-.043c-.164-.907-.543-1.66-.735-2.014-.359-.668-.918-2.036-1.158-2.983M7.72 3.503a1 1 0 0 0-.312.053c-.268.093-.447.286-.559.391-.022.021-.05.04-.119.091s-.172.126-.321.238q-.198.151-.13.38c.046.15.192.325.459.476.166.098.28.23.41.334a1 1 0 0 0 .215.133.9.9 0 0 0 .298.066c.282.017.49-.068.673-.173s.34-.233.518-.29c.365-.115.627-.345.709-.564a.37.37 0 0 0-.01-.309c-.048-.096-.148-.187-.318-.257h-.001c-.354-.151-.507-.162-.705-.29-.321-.207-.587-.28-.807-.279m-.89-1.122h-.025a.4.4 0 0 0-.278.135.76.76 0 0 0-.191.334 1.2 1.2 0 0 0-.051.445v.001c.01.162.041.299.102.436.05.116.109.204.183.274l.089-.065.117-.09-.023-.018a.4.4 0 0 1-.11-.161.7.7 0 0 1-.054-.22v-.01a.7.7 0 0 1 .014-.234.4.4 0 0 1 .08-.179q.056-.069.126-.073h.013a.18.18 0 0 1 .123.05c.045.04.08.09.11.162a.7.7 0 0 1 .054.22v.01a.7.7 0 0 1-.002.17 1.1 1.1 0 0 1 .317-.143 1.3 1.3 0 0 0 .002-.194V3.23a1.2 1.2 0 0 0-.102-.437.8.8 0 0 0-.227-.31.4.4 0 0 0-.268-.102m1.95-.155a.63.63 0 0 0-.394.14.9.9 0 0 0-.287.376 1.2 1.2 0 0 0-.1.51v.015q0 .079.01.152c.114.027.278.074.406.138a1 1 0 0 1-.011-.172.8.8 0 0 1 .058-.278.5.5 0 0 1 .139-.2.26.26 0 0 1 .182-.069.26.26 0 0 1 .178.081c.055.054.094.12.124.21.029.086.042.17.04.27l-.002.012a.8.8 0 0 1-.057.277c-.024.059-.089.106-.122.145.046.016.09.03.146.052a5 5 0 0 1 .248.102 1.2 1.2 0 0 0 .244-.763 1.2 1.2 0 0 0-.11-.495.9.9 0 0 0-.294-.37.64.64 0 0 0-.39-.133z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" x2="20" y1="19" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -831,27 +621,6 @@ function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3a15 15 0 0 1 0 18" />
|
||||
<path d="M12 3a15 15 0 0 0 0 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ number, children }: { number: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
@@ -863,52 +632,6 @@ function Step({ number, children }: { number: number; children: React.ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children }: { children: React.ReactNode }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const text = typeof children === "string" ? children : "";
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-black/30 backdrop-blur-sm rounded-lg p-3 md:p-4 font-mono text-sm flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground select-none">$ </span>
|
||||
<span className="text-foreground">{children}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M216,28H88A20,20,0,0,0,68,48V76H40A20,20,0,0,0,20,96V216a20,20,0,0,0,20,20H168a20,20,0,0,0,20-20V188h28a20,20,0,0,0,20-20V48A20,20,0,0,0,216,28ZM164,212H44V100H164Zm48-48H188V96a20,20,0,0,0-20-20H92V52H212Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bashKeywords = new Set([
|
||||
"while",
|
||||
@@ -1195,6 +918,28 @@ done`,
|
||||
},
|
||||
];
|
||||
|
||||
function MobileSection() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-medium">Mobile-first</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-lg">
|
||||
The mobile app has full feature parity with desktop. Launch agents, review diffs, talk through problems with voice, all from your phone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="-mx-[calc(50vw-50%)] px-6 md:px-8 overflow-x-auto">
|
||||
<div className="max-w-5xl lg:max-w-6xl mx-auto">
|
||||
<img
|
||||
src="/mobile-mockup.png"
|
||||
alt="Paseo mobile app screens"
|
||||
className="min-w-[900px] w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CLISection() {
|
||||
const [activeIndex, setActiveIndex] = React.useState(0);
|
||||
const active = cliExamples[activeIndex];
|
||||
@@ -1209,7 +954,7 @@ function CLISection() {
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-medium">CLI</h2>
|
||||
<p className="text-sm text-white/60 max-w-lg">
|
||||
<p className="text-sm text-muted-foreground max-w-lg">
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1231,7 +976,7 @@ function CLISection() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-white/50">{active.description}</p>
|
||||
<p className="text-sm text-muted-foreground">{active.description}</p>
|
||||
<CLICodeBlock>{active.code}</CLICodeBlock>
|
||||
</div>
|
||||
|
||||
@@ -1288,7 +1033,7 @@ function FAQ() {
|
||||
</FAQItem>
|
||||
<FAQItem question="Do I need the desktop app?">
|
||||
No. You can run the daemon headless with{" "}
|
||||
<code className="font-mono text-white/50">npm install -g @getpaseo/cli && paseo</code> and
|
||||
<code className="font-mono text-muted-foreground">npm install -g @getpaseo/cli && paseo</code> and
|
||||
use the CLI, web app, or mobile app to connect. The desktop app just bundles the daemon
|
||||
with a UI.
|
||||
</FAQItem>
|
||||
@@ -1345,13 +1090,26 @@ function SponsorCTA() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-center space-y-4"
|
||||
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-left space-y-4 max-w-xl mx-auto"
|
||||
>
|
||||
<p className="text-lg font-medium">Paseo is an independent project</p>
|
||||
<p className="text-sm text-white/50 max-w-md mx-auto leading-relaxed">
|
||||
I built Paseo because the existing tools weren't good enough for me. There's no VC or big
|
||||
team behind this. If it saves you time, sponsoring keeps development going.
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
|
||||
<p>
|
||||
I believe that open source and freedom of choice always win for developer tools.
|
||||
</p>
|
||||
<p>
|
||||
Paseo has no VC funding and no big team behind it. I built it because the existing tools
|
||||
weren't good enough for me. No tracking, no telemetry, no forced accounts, no vendor lock-in.
|
||||
</p>
|
||||
<p>
|
||||
The monetization story is still taking shape. The obvious path is optional hosted
|
||||
infrastructure like cloud sandboxes for teams. But Paseo itself will always be FOSS.
|
||||
</p>
|
||||
<p>
|
||||
If you like Paseo, consider sponsoring development.
|
||||
</p>
|
||||
<p>- Mo</p>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<a
|
||||
href="https://github.com/sponsors/boudra"
|
||||
@@ -1384,7 +1142,7 @@ function FAQItem({ question, children }: { question: string; children: React.Rea
|
||||
<span className="font-mono text-white/40 hidden group-open:inline">-</span>
|
||||
{question}
|
||||
</summary>
|
||||
<div className="text-sm text-white/60 space-y-2 mt-2 ml-4">{children}</div>
|
||||
<div className="text-sm text-muted-foreground space-y-2 mt-2 ml-4">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
174
packages/website/src/downloads.tsx
Normal file
174
packages/website/src/downloads.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as React from "react";
|
||||
import websitePackage from "../package.json";
|
||||
|
||||
export const desktopVersion = websitePackage.version;
|
||||
export const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`;
|
||||
export const macAppleSiliconDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`;
|
||||
export const macIntelDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x64.dmg`;
|
||||
export const linuxAppImageDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.AppImage`;
|
||||
export const linuxDebDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-amd64.deb`;
|
||||
export const linuxRpmDownloadUrl = `${releaseBase}/Paseo-${desktopVersion}-x86_64.rpm`;
|
||||
|
||||
export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
|
||||
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
|
||||
export const webAppUrl = "https://app.paseo.sh";
|
||||
|
||||
type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux";
|
||||
|
||||
export interface DownloadOption {
|
||||
platform: Platform;
|
||||
label: string;
|
||||
href: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
|
||||
}
|
||||
|
||||
export const downloadOptions: DownloadOption[] = [
|
||||
{
|
||||
platform: "mac-silicon",
|
||||
label: "Mac",
|
||||
href: macAppleSiliconDownloadUrl,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "mac-intel",
|
||||
label: "Mac Intel",
|
||||
href: macIntelDownloadUrl,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
label: "Windows",
|
||||
href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`,
|
||||
icon: WindowsIcon,
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
label: "Linux",
|
||||
href: linuxAppImageDownloadUrl,
|
||||
icon: LinuxIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function useDetectedPlatform(): Platform {
|
||||
const [platform, setPlatform] = React.useState<Platform>("mac-silicon");
|
||||
|
||||
React.useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes("win")) {
|
||||
setPlatform("windows");
|
||||
} else if (ua.includes("linux")) {
|
||||
setPlatform("linux");
|
||||
} else if (ua.includes("mac")) {
|
||||
// Check for Apple Silicon vs Intel
|
||||
// navigator.platform is deprecated but still the most reliable check
|
||||
const isARM =
|
||||
/arm|aarch64/i.test(navigator.platform) ||
|
||||
// Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData
|
||||
(navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData
|
||||
?.architecture === "arm";
|
||||
setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs
|
||||
}
|
||||
}, []);
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AndroidIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M380.91,199l42.47-73.57a8.63,8.63,0,0,0-3.12-11.76,8.52,8.52,0,0,0-11.71,3.12l-43,74.52c-32.83-15-69.78-23.35-109.52-23.35s-76.69,8.36-109.52,23.35l-43-74.52a8.6,8.6,0,1,0-14.88,8.64L131,199C57.8,238.64,8.19,312.77,0,399.55H512C503.81,312.77,454.2,238.64,380.91,199ZM138.45,327.65a21.46,21.46,0,1,1,21.46-21.46A21.47,21.47,0,0,1,138.45,327.65Zm235,0A21.46,21.46,0,1,1,395,306.19,21.47,21.47,0,0,1,373.49,327.65Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinuxIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8.996 4.497c.104-.076.1-.168.186-.158s.022.102-.098.207c-.12.104-.308.243-.46.323-.291.152-.631.336-.993.336s-.647-.167-.853-.33c-.102-.082-.186-.162-.248-.221-.11-.086-.096-.207-.052-.204.075.01.087.109.134.153.064.06.144.137.241.214.195.154.454.304.778.304s.702-.19.932-.32c.13-.073.297-.204.433-.304M7.34 3.781c.055-.02.123-.031.174-.003.011.006.024.021.02.034-.012.038-.074.032-.11.05-.032.017-.057.052-.093.054-.034 0-.086-.012-.09-.046-.007-.044.058-.072.1-.089m.581-.003c.05-.028.119-.018.173.003.041.017.106.045.1.09-.004.033-.057.046-.09.045-.036-.002-.062-.037-.093-.053-.036-.019-.098-.013-.11-.051-.004-.013.008-.028.02-.034" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.446.019c2.521.003 2.38 2.66 2.364 4.093-.01.939.509 1.574 1.04 2.244.474.56 1.095 1.38 1.45 2.32.29.765.402 1.613.115 2.465a.8.8 0 0 1 .254.152l.001.002c.207.175.271.447.329.698.058.252.112.488.224.615.344.382.494.667.48.922-.015.254-.203.43-.435.57-.465.28-1.164.491-1.586 1.002-.443.527-.99.83-1.505.871a1.25 1.25 0 0 1-1.256-.716v-.001a1 1 0 0 1-.078-.21c-.67.038-1.252-.165-1.718-.128-.687.038-1.116.204-1.506.206-.151.331-.445.547-.808.63-.5.114-1.126 0-1.743-.324-.577-.306-1.31-.278-1.85-.39-.27-.057-.51-.157-.626-.384-.116-.226-.095-.538.07-.988.051-.16.012-.398-.026-.648a2.5 2.5 0 0 1-.037-.369c0-.133.022-.265.087-.386v-.002c.14-.266.368-.377.577-.451s.397-.125.53-.258c.143-.15.27-.374.443-.56q.036-.037.073-.07c-.081-.538.007-1.105.192-1.662.393-1.18 1.223-2.314 1.811-3.014.502-.713.65-1.287.701-2.016.042-.997-.705-3.974 2.112-4.2q.168-.015.321-.013m2.596 10.866-.03.016c-.223.121-.348.337-.427.656-.08.32-.107.733-.13 1.206v.001c-.023.37-.192.824-.31 1.267s-.176.862-.036 1.128v.002c.226.452.608.636 1.051.601s.947-.304 1.36-.795c.474-.576 1.218-.796 1.638-1.05.21-.126.324-.242.333-.4.009-.157-.097-.403-.425-.767-.17-.192-.217-.462-.274-.71-.056-.247-.122-.468-.26-.585l-.001-.001c-.18-.157-.356-.17-.565-.164q-.069.001-.14.005c-.239.275-.805.612-1.197.508-.359-.09-.562-.508-.587-.918m-7.204.03H3.83c-.189.002-.314.09-.44.225-.149.158-.276.382-.445.56v.002h-.002c-.183.184-.414.239-.61.31-.195.069-.353.143-.46.35v.002c-.085.155-.066.378-.029.624.038.245.096.507.018.746v.002l-.001.002c-.157.427-.155.678-.082.822.074.143.235.22.48.272.493.103 1.26.069 1.906.41.583.305 1.168.404 1.598.305.431-.098.712-.369.75-.867v-.002c.029-.292-.195-.673-.485-1.052-.29-.38-.633-.752-.795-1.09v-.002l-.61-1.11c-.21-.286-.43-.462-.68-.5a1 1 0 0 0-.106-.008M9.584 4.85c-.14.2-.386.37-.695.467-.147.048-.302.17-.495.28a1.3 1.3 0 0 1-.74.19.97.97 0 0 1-.582-.227c-.14-.113-.25-.237-.394-.322a3 3 0 0 1-.192-.126c-.063 1.179-.85 2.658-1.226 3.511a5.4 5.4 0 0 0-.43 1.917c-.68-.906-.184-2.066.081-2.568.297-.55.343-.701.27-.649-.266.436-.685 1.13-.848 1.844-.085.372-.1.749.01 1.097.11.349.345.67.766.931.573.351.963.703 1.193 1.015s.302.584.23.777a.4.4 0 0 1-.212.22.7.7 0 0 1-.307.056l.184.235c.094.124.186.249.266.375 1.179.805 2.567.496 3.568-.218.1-.342.197-.664.212-.903.024-.474.05-.896.136-1.245s.244-.634.53-.791a1 1 0 0 1 .138-.061q.005-.045.013-.087c.082-.546.569-.572 1.18-.303.588.266.81.499.71.814h.13c.122-.398-.133-.69-.822-1.025l-.137-.06a2.35 2.35 0 0 0-.012-1.113c-.188-.79-.704-1.49-1.098-1.838-.072-.003-.065.06.081.203.363.333 1.156 1.532.727 2.644a1.2 1.2 0 0 0-.342-.043c-.164-.907-.543-1.66-.735-2.014-.359-.668-.918-2.036-1.158-2.983M7.72 3.503a1 1 0 0 0-.312.053c-.268.093-.447.286-.559.391-.022.021-.05.04-.119.091s-.172.126-.321.238q-.198.151-.13.38c.046.15.192.325.459.476.166.098.28.23.41.334a1 1 0 0 0 .215.133.9.9 0 0 0 .298.066c.282.017.49-.068.673-.173s.34-.233.518-.29c.365-.115.627-.345.709-.564a.37.37 0 0 0-.01-.309c-.048-.096-.148-.187-.318-.257h-.001c-.354-.151-.507-.162-.705-.29-.321-.207-.587-.28-.807-.279m-.89-1.122h-.025a.4.4 0 0 0-.278.135.76.76 0 0 0-.191.334 1.2 1.2 0 0 0-.051.445v.001c.01.162.041.299.102.436.05.116.109.204.183.274l.089-.065.117-.09-.023-.018a.4.4 0 0 1-.11-.161.7.7 0 0 1-.054-.22v-.01a.7.7 0 0 1 .014-.234.4.4 0 0 1 .08-.179q.056-.069.126-.073h.013a.18.18 0 0 1 .123.05c.045.04.08.09.11.162a.7.7 0 0 1 .054.22v.01a.7.7 0 0 1-.002.17 1.1 1.1 0 0 1 .317-.143 1.3 1.3 0 0 0 .002-.194V3.23a1.2 1.2 0 0 0-.102-.437.8.8 0 0 0-.227-.31.4.4 0 0 0-.268-.102m1.95-.155a.63.63 0 0 0-.394.14.9.9 0 0 0-.287.376 1.2 1.2 0 0 0-.1.51v.015q0 .079.01.152c.114.027.278.074.406.138a1 1 0 0 1-.011-.172.8.8 0 0 1 .058-.278.5.5 0 0 1 .139-.2.26.26 0 0 1 .182-.069.26.26 0 0 1 .178.081c.055.054.094.12.124.21.029.086.042.17.04.27l-.002.012a.8.8 0 0 1-.057.277c-.024.059-.089.106-.122.145.046.016.09.03.146.052a5 5 0 0 1 .248.102 1.2 1.2 0 0 0 .244-.763 1.2 1.2 0 0 0-.11-.495.9.9 0 0 0-.294-.37.64.64 0 0 0-.39-.133z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" x2="20" y1="19" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3a15 15 0 0 1 0 18" />
|
||||
<path d="M12 3a15 15 0 0 0 0 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -8,332 +8,352 @@
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from "./routes/__root";
|
||||
import { Route as PrivacyRouteImport } from "./routes/privacy";
|
||||
import { Route as OpencodeRouteImport } from "./routes/opencode";
|
||||
import { Route as DocsRouteImport } from "./routes/docs";
|
||||
import { Route as CodexRouteImport } from "./routes/codex";
|
||||
import { Route as ClaudeCodeRouteImport } from "./routes/claude-code";
|
||||
import { Route as ChangelogRouteImport } from "./routes/changelog";
|
||||
import { Route as IndexRouteImport } from "./routes/index";
|
||||
import { Route as DocsIndexRouteImport } from "./routes/docs/index";
|
||||
import { Route as DocsWorktreesRouteImport } from "./routes/docs/worktrees";
|
||||
import { Route as DocsVoiceRouteImport } from "./routes/docs/voice";
|
||||
import { Route as DocsUpdatesRouteImport } from "./routes/docs/updates";
|
||||
import { Route as DocsSecurityRouteImport } from "./routes/docs/security";
|
||||
import { Route as DocsConfigurationRouteImport } from "./routes/docs/configuration";
|
||||
import { Route as DocsCliRouteImport } from "./routes/docs/cli";
|
||||
import { Route as DocsBestPracticesRouteImport } from "./routes/docs/best-practices";
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as PrivacyRouteImport } from './routes/privacy'
|
||||
import { Route as OpencodeRouteImport } from './routes/opencode'
|
||||
import { Route as DownloadRouteImport } from './routes/download'
|
||||
import { Route as DocsRouteImport } from './routes/docs'
|
||||
import { Route as CodexRouteImport } from './routes/codex'
|
||||
import { Route as ClaudeCodeRouteImport } from './routes/claude-code'
|
||||
import { Route as ChangelogRouteImport } from './routes/changelog'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DocsIndexRouteImport } from './routes/docs/index'
|
||||
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
|
||||
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
|
||||
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
|
||||
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
||||
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
|
||||
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
||||
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||
|
||||
const PrivacyRoute = PrivacyRouteImport.update({
|
||||
id: "/privacy",
|
||||
path: "/privacy",
|
||||
id: '/privacy',
|
||||
path: '/privacy',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const OpencodeRoute = OpencodeRouteImport.update({
|
||||
id: "/opencode",
|
||||
path: "/opencode",
|
||||
id: '/opencode',
|
||||
path: '/opencode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const DownloadRoute = DownloadRouteImport.update({
|
||||
id: '/download',
|
||||
path: '/download',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DocsRoute = DocsRouteImport.update({
|
||||
id: "/docs",
|
||||
path: "/docs",
|
||||
id: '/docs',
|
||||
path: '/docs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const CodexRoute = CodexRouteImport.update({
|
||||
id: "/codex",
|
||||
path: "/codex",
|
||||
id: '/codex',
|
||||
path: '/codex',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const ClaudeCodeRoute = ClaudeCodeRouteImport.update({
|
||||
id: "/claude-code",
|
||||
path: "/claude-code",
|
||||
id: '/claude-code',
|
||||
path: '/claude-code',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const ChangelogRoute = ChangelogRouteImport.update({
|
||||
id: "/changelog",
|
||||
path: "/changelog",
|
||||
id: '/changelog',
|
||||
path: '/changelog',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsIndexRoute = DocsIndexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsWorktreesRoute = DocsWorktreesRouteImport.update({
|
||||
id: "/worktrees",
|
||||
path: "/worktrees",
|
||||
id: '/worktrees',
|
||||
path: '/worktrees',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsVoiceRoute = DocsVoiceRouteImport.update({
|
||||
id: "/voice",
|
||||
path: "/voice",
|
||||
id: '/voice',
|
||||
path: '/voice',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
|
||||
id: "/updates",
|
||||
path: "/updates",
|
||||
id: '/updates',
|
||||
path: '/updates',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
||||
id: "/security",
|
||||
path: "/security",
|
||||
id: '/security',
|
||||
path: '/security',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
|
||||
id: "/configuration",
|
||||
path: "/configuration",
|
||||
id: '/configuration',
|
||||
path: '/configuration',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsCliRoute = DocsCliRouteImport.update({
|
||||
id: "/cli",
|
||||
path: "/cli",
|
||||
id: '/cli',
|
||||
path: '/cli',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
|
||||
id: "/best-practices",
|
||||
path: "/best-practices",
|
||||
id: '/best-practices',
|
||||
path: '/best-practices',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any);
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof IndexRoute;
|
||||
"/changelog": typeof ChangelogRoute;
|
||||
"/claude-code": typeof ClaudeCodeRoute;
|
||||
"/codex": typeof CodexRoute;
|
||||
"/docs": typeof DocsRouteWithChildren;
|
||||
"/opencode": typeof OpencodeRoute;
|
||||
"/privacy": typeof PrivacyRoute;
|
||||
"/docs/best-practices": typeof DocsBestPracticesRoute;
|
||||
"/docs/cli": typeof DocsCliRoute;
|
||||
"/docs/configuration": typeof DocsConfigurationRoute;
|
||||
"/docs/security": typeof DocsSecurityRoute;
|
||||
"/docs/updates": typeof DocsUpdatesRoute;
|
||||
"/docs/voice": typeof DocsVoiceRoute;
|
||||
"/docs/worktrees": typeof DocsWorktreesRoute;
|
||||
"/docs/": typeof DocsIndexRoute;
|
||||
'/': typeof IndexRoute
|
||||
'/changelog': typeof ChangelogRoute
|
||||
'/claude-code': typeof ClaudeCodeRoute
|
||||
'/codex': typeof CodexRoute
|
||||
'/docs': typeof DocsRouteWithChildren
|
||||
'/download': typeof DownloadRoute
|
||||
'/opencode': typeof OpencodeRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
'/docs/security': typeof DocsSecurityRoute
|
||||
'/docs/updates': typeof DocsUpdatesRoute
|
||||
'/docs/voice': typeof DocsVoiceRoute
|
||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||
'/docs/': typeof DocsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof IndexRoute;
|
||||
"/changelog": typeof ChangelogRoute;
|
||||
"/claude-code": typeof ClaudeCodeRoute;
|
||||
"/codex": typeof CodexRoute;
|
||||
"/opencode": typeof OpencodeRoute;
|
||||
"/privacy": typeof PrivacyRoute;
|
||||
"/docs/best-practices": typeof DocsBestPracticesRoute;
|
||||
"/docs/cli": typeof DocsCliRoute;
|
||||
"/docs/configuration": typeof DocsConfigurationRoute;
|
||||
"/docs/security": typeof DocsSecurityRoute;
|
||||
"/docs/updates": typeof DocsUpdatesRoute;
|
||||
"/docs/voice": typeof DocsVoiceRoute;
|
||||
"/docs/worktrees": typeof DocsWorktreesRoute;
|
||||
"/docs": typeof DocsIndexRoute;
|
||||
'/': typeof IndexRoute
|
||||
'/changelog': typeof ChangelogRoute
|
||||
'/claude-code': typeof ClaudeCodeRoute
|
||||
'/codex': typeof CodexRoute
|
||||
'/download': typeof DownloadRoute
|
||||
'/opencode': typeof OpencodeRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
'/docs/security': typeof DocsSecurityRoute
|
||||
'/docs/updates': typeof DocsUpdatesRoute
|
||||
'/docs/voice': typeof DocsVoiceRoute
|
||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||
'/docs': typeof DocsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
"/": typeof IndexRoute;
|
||||
"/changelog": typeof ChangelogRoute;
|
||||
"/claude-code": typeof ClaudeCodeRoute;
|
||||
"/codex": typeof CodexRoute;
|
||||
"/docs": typeof DocsRouteWithChildren;
|
||||
"/opencode": typeof OpencodeRoute;
|
||||
"/privacy": typeof PrivacyRoute;
|
||||
"/docs/best-practices": typeof DocsBestPracticesRoute;
|
||||
"/docs/cli": typeof DocsCliRoute;
|
||||
"/docs/configuration": typeof DocsConfigurationRoute;
|
||||
"/docs/security": typeof DocsSecurityRoute;
|
||||
"/docs/updates": typeof DocsUpdatesRoute;
|
||||
"/docs/voice": typeof DocsVoiceRoute;
|
||||
"/docs/worktrees": typeof DocsWorktreesRoute;
|
||||
"/docs/": typeof DocsIndexRoute;
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/changelog': typeof ChangelogRoute
|
||||
'/claude-code': typeof ClaudeCodeRoute
|
||||
'/codex': typeof CodexRoute
|
||||
'/docs': typeof DocsRouteWithChildren
|
||||
'/download': typeof DownloadRoute
|
||||
'/opencode': typeof OpencodeRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/docs/best-practices': typeof DocsBestPracticesRoute
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
'/docs/security': typeof DocsSecurityRoute
|
||||
'/docs/updates': typeof DocsUpdatesRoute
|
||||
'/docs/voice': typeof DocsVoiceRoute
|
||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||
'/docs/': typeof DocsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| "/"
|
||||
| "/changelog"
|
||||
| "/claude-code"
|
||||
| "/codex"
|
||||
| "/docs"
|
||||
| "/opencode"
|
||||
| "/privacy"
|
||||
| "/docs/best-practices"
|
||||
| "/docs/cli"
|
||||
| "/docs/configuration"
|
||||
| "/docs/security"
|
||||
| "/docs/updates"
|
||||
| "/docs/voice"
|
||||
| "/docs/worktrees"
|
||||
| "/docs/";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
| '/'
|
||||
| '/changelog'
|
||||
| '/claude-code'
|
||||
| '/codex'
|
||||
| '/docs'
|
||||
| '/download'
|
||||
| '/opencode'
|
||||
| '/privacy'
|
||||
| '/docs/best-practices'
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
| '/docs/security'
|
||||
| '/docs/updates'
|
||||
| '/docs/voice'
|
||||
| '/docs/worktrees'
|
||||
| '/docs/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| "/"
|
||||
| "/changelog"
|
||||
| "/claude-code"
|
||||
| "/codex"
|
||||
| "/opencode"
|
||||
| "/privacy"
|
||||
| "/docs/best-practices"
|
||||
| "/docs/cli"
|
||||
| "/docs/configuration"
|
||||
| "/docs/security"
|
||||
| "/docs/updates"
|
||||
| "/docs/voice"
|
||||
| "/docs/worktrees"
|
||||
| "/docs";
|
||||
| '/'
|
||||
| '/changelog'
|
||||
| '/claude-code'
|
||||
| '/codex'
|
||||
| '/download'
|
||||
| '/opencode'
|
||||
| '/privacy'
|
||||
| '/docs/best-practices'
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
| '/docs/security'
|
||||
| '/docs/updates'
|
||||
| '/docs/voice'
|
||||
| '/docs/worktrees'
|
||||
| '/docs'
|
||||
id:
|
||||
| "__root__"
|
||||
| "/"
|
||||
| "/changelog"
|
||||
| "/claude-code"
|
||||
| "/codex"
|
||||
| "/docs"
|
||||
| "/opencode"
|
||||
| "/privacy"
|
||||
| "/docs/best-practices"
|
||||
| "/docs/cli"
|
||||
| "/docs/configuration"
|
||||
| "/docs/security"
|
||||
| "/docs/updates"
|
||||
| "/docs/voice"
|
||||
| "/docs/worktrees"
|
||||
| "/docs/";
|
||||
fileRoutesById: FileRoutesById;
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/changelog'
|
||||
| '/claude-code'
|
||||
| '/codex'
|
||||
| '/docs'
|
||||
| '/download'
|
||||
| '/opencode'
|
||||
| '/privacy'
|
||||
| '/docs/best-practices'
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
| '/docs/security'
|
||||
| '/docs/updates'
|
||||
| '/docs/voice'
|
||||
| '/docs/worktrees'
|
||||
| '/docs/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute;
|
||||
ChangelogRoute: typeof ChangelogRoute;
|
||||
ClaudeCodeRoute: typeof ClaudeCodeRoute;
|
||||
CodexRoute: typeof CodexRoute;
|
||||
DocsRoute: typeof DocsRouteWithChildren;
|
||||
OpencodeRoute: typeof OpencodeRoute;
|
||||
PrivacyRoute: typeof PrivacyRoute;
|
||||
IndexRoute: typeof IndexRoute
|
||||
ChangelogRoute: typeof ChangelogRoute
|
||||
ClaudeCodeRoute: typeof ClaudeCodeRoute
|
||||
CodexRoute: typeof CodexRoute
|
||||
DocsRoute: typeof DocsRouteWithChildren
|
||||
DownloadRoute: typeof DownloadRoute
|
||||
OpencodeRoute: typeof OpencodeRoute
|
||||
PrivacyRoute: typeof PrivacyRoute
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
"/privacy": {
|
||||
id: "/privacy";
|
||||
path: "/privacy";
|
||||
fullPath: "/privacy";
|
||||
preLoaderRoute: typeof PrivacyRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/opencode": {
|
||||
id: "/opencode";
|
||||
path: "/opencode";
|
||||
fullPath: "/opencode";
|
||||
preLoaderRoute: typeof OpencodeRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/docs": {
|
||||
id: "/docs";
|
||||
path: "/docs";
|
||||
fullPath: "/docs";
|
||||
preLoaderRoute: typeof DocsRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/codex": {
|
||||
id: "/codex";
|
||||
path: "/codex";
|
||||
fullPath: "/codex";
|
||||
preLoaderRoute: typeof CodexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/claude-code": {
|
||||
id: "/claude-code";
|
||||
path: "/claude-code";
|
||||
fullPath: "/claude-code";
|
||||
preLoaderRoute: typeof ClaudeCodeRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/changelog": {
|
||||
id: "/changelog";
|
||||
path: "/changelog";
|
||||
fullPath: "/changelog";
|
||||
preLoaderRoute: typeof ChangelogRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/": {
|
||||
id: "/";
|
||||
path: "/";
|
||||
fullPath: "/";
|
||||
preLoaderRoute: typeof IndexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/docs/": {
|
||||
id: "/docs/";
|
||||
path: "/";
|
||||
fullPath: "/docs/";
|
||||
preLoaderRoute: typeof DocsIndexRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/worktrees": {
|
||||
id: "/docs/worktrees";
|
||||
path: "/worktrees";
|
||||
fullPath: "/docs/worktrees";
|
||||
preLoaderRoute: typeof DocsWorktreesRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/voice": {
|
||||
id: "/docs/voice";
|
||||
path: "/voice";
|
||||
fullPath: "/docs/voice";
|
||||
preLoaderRoute: typeof DocsVoiceRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/updates": {
|
||||
id: "/docs/updates";
|
||||
path: "/updates";
|
||||
fullPath: "/docs/updates";
|
||||
preLoaderRoute: typeof DocsUpdatesRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/security": {
|
||||
id: "/docs/security";
|
||||
path: "/security";
|
||||
fullPath: "/docs/security";
|
||||
preLoaderRoute: typeof DocsSecurityRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/configuration": {
|
||||
id: "/docs/configuration";
|
||||
path: "/configuration";
|
||||
fullPath: "/docs/configuration";
|
||||
preLoaderRoute: typeof DocsConfigurationRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/cli": {
|
||||
id: "/docs/cli";
|
||||
path: "/cli";
|
||||
fullPath: "/docs/cli";
|
||||
preLoaderRoute: typeof DocsCliRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
"/docs/best-practices": {
|
||||
id: "/docs/best-practices";
|
||||
path: "/best-practices";
|
||||
fullPath: "/docs/best-practices";
|
||||
preLoaderRoute: typeof DocsBestPracticesRouteImport;
|
||||
parentRoute: typeof DocsRoute;
|
||||
};
|
||||
'/privacy': {
|
||||
id: '/privacy'
|
||||
path: '/privacy'
|
||||
fullPath: '/privacy'
|
||||
preLoaderRoute: typeof PrivacyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/opencode': {
|
||||
id: '/opencode'
|
||||
path: '/opencode'
|
||||
fullPath: '/opencode'
|
||||
preLoaderRoute: typeof OpencodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/download': {
|
||||
id: '/download'
|
||||
path: '/download'
|
||||
fullPath: '/download'
|
||||
preLoaderRoute: typeof DownloadRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/docs': {
|
||||
id: '/docs'
|
||||
path: '/docs'
|
||||
fullPath: '/docs'
|
||||
preLoaderRoute: typeof DocsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/codex': {
|
||||
id: '/codex'
|
||||
path: '/codex'
|
||||
fullPath: '/codex'
|
||||
preLoaderRoute: typeof CodexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/claude-code': {
|
||||
id: '/claude-code'
|
||||
path: '/claude-code'
|
||||
fullPath: '/claude-code'
|
||||
preLoaderRoute: typeof ClaudeCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/changelog': {
|
||||
id: '/changelog'
|
||||
path: '/changelog'
|
||||
fullPath: '/changelog'
|
||||
preLoaderRoute: typeof ChangelogRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/docs/': {
|
||||
id: '/docs/'
|
||||
path: '/'
|
||||
fullPath: '/docs/'
|
||||
preLoaderRoute: typeof DocsIndexRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/worktrees': {
|
||||
id: '/docs/worktrees'
|
||||
path: '/worktrees'
|
||||
fullPath: '/docs/worktrees'
|
||||
preLoaderRoute: typeof DocsWorktreesRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/voice': {
|
||||
id: '/docs/voice'
|
||||
path: '/voice'
|
||||
fullPath: '/docs/voice'
|
||||
preLoaderRoute: typeof DocsVoiceRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/updates': {
|
||||
id: '/docs/updates'
|
||||
path: '/updates'
|
||||
fullPath: '/docs/updates'
|
||||
preLoaderRoute: typeof DocsUpdatesRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/security': {
|
||||
id: '/docs/security'
|
||||
path: '/security'
|
||||
fullPath: '/docs/security'
|
||||
preLoaderRoute: typeof DocsSecurityRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/configuration': {
|
||||
id: '/docs/configuration'
|
||||
path: '/configuration'
|
||||
fullPath: '/docs/configuration'
|
||||
preLoaderRoute: typeof DocsConfigurationRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/cli': {
|
||||
id: '/docs/cli'
|
||||
path: '/cli'
|
||||
fullPath: '/docs/cli'
|
||||
preLoaderRoute: typeof DocsCliRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/best-practices': {
|
||||
id: '/docs/best-practices'
|
||||
path: '/best-practices'
|
||||
fullPath: '/docs/best-practices'
|
||||
preLoaderRoute: typeof DocsBestPracticesRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DocsRouteChildren {
|
||||
DocsBestPracticesRoute: typeof DocsBestPracticesRoute;
|
||||
DocsCliRoute: typeof DocsCliRoute;
|
||||
DocsConfigurationRoute: typeof DocsConfigurationRoute;
|
||||
DocsSecurityRoute: typeof DocsSecurityRoute;
|
||||
DocsUpdatesRoute: typeof DocsUpdatesRoute;
|
||||
DocsVoiceRoute: typeof DocsVoiceRoute;
|
||||
DocsWorktreesRoute: typeof DocsWorktreesRoute;
|
||||
DocsIndexRoute: typeof DocsIndexRoute;
|
||||
DocsBestPracticesRoute: typeof DocsBestPracticesRoute
|
||||
DocsCliRoute: typeof DocsCliRoute
|
||||
DocsConfigurationRoute: typeof DocsConfigurationRoute
|
||||
DocsSecurityRoute: typeof DocsSecurityRoute
|
||||
DocsUpdatesRoute: typeof DocsUpdatesRoute
|
||||
DocsVoiceRoute: typeof DocsVoiceRoute
|
||||
DocsWorktreesRoute: typeof DocsWorktreesRoute
|
||||
DocsIndexRoute: typeof DocsIndexRoute
|
||||
}
|
||||
|
||||
const DocsRouteChildren: DocsRouteChildren = {
|
||||
@@ -345,9 +365,9 @@ const DocsRouteChildren: DocsRouteChildren = {
|
||||
DocsVoiceRoute: DocsVoiceRoute,
|
||||
DocsWorktreesRoute: DocsWorktreesRoute,
|
||||
DocsIndexRoute: DocsIndexRoute,
|
||||
};
|
||||
}
|
||||
|
||||
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren);
|
||||
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
@@ -355,18 +375,19 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ClaudeCodeRoute: ClaudeCodeRoute,
|
||||
CodexRoute: CodexRoute,
|
||||
DocsRoute: DocsRouteWithChildren,
|
||||
DownloadRoute: DownloadRoute,
|
||||
OpencodeRoute: OpencodeRoute,
|
||||
PrivacyRoute: PrivacyRoute,
|
||||
};
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>();
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from "./router.tsx";
|
||||
import type { createStart } from "@tanstack/react-start";
|
||||
declare module "@tanstack/react-start" {
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true;
|
||||
router: Awaited<ReturnType<typeof getRouter>>;
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
|
||||
331
packages/website/src/routes/download.tsx
Normal file
331
packages/website/src/routes/download.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { CommandDialog } from "~/components/command-dialog";
|
||||
import { pageMeta } from "~/meta";
|
||||
import {
|
||||
desktopVersion,
|
||||
linuxAppImageDownloadUrl,
|
||||
linuxDebDownloadUrl,
|
||||
linuxRpmDownloadUrl,
|
||||
releaseBase,
|
||||
macAppleSiliconDownloadUrl,
|
||||
macIntelDownloadUrl,
|
||||
appStoreUrl,
|
||||
playStoreUrl,
|
||||
webAppUrl,
|
||||
AppleIcon,
|
||||
AndroidIcon,
|
||||
WindowsIcon,
|
||||
LinuxIcon,
|
||||
TerminalIcon,
|
||||
GlobeIcon,
|
||||
} from "~/downloads";
|
||||
import "~/styles.css";
|
||||
|
||||
export const Route = createFileRoute("/download")({
|
||||
head: () => ({
|
||||
meta: pageMeta(
|
||||
"Download - Paseo",
|
||||
"Download Paseo for macOS, Windows, Linux, iOS, and Android. Your dev environment, in your pocket.",
|
||||
),
|
||||
}),
|
||||
component: Download,
|
||||
});
|
||||
|
||||
function Download() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="max-w-3xl mx-auto p-6 md:p-12">
|
||||
<header className="flex items-center justify-between gap-4 mb-12">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
|
||||
<span className="text-lg font-medium">Paseo</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/docs"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
to="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<a
|
||||
href="https://discord.gg/jz8T2uahpH"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Discord"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
|
||||
<p className="text-muted-foreground mb-10">
|
||||
v{desktopVersion}
|
||||
</p>
|
||||
|
||||
{/* Desktop */}
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-semibold">Desktop</h2>
|
||||
<MonitorIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* macOS */}
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppleIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">macOS</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DownloadPill href={macAppleSiliconDownloadUrl} label="Apple Silicon" />
|
||||
<DownloadPill href={macIntelDownloadUrl} label="Intel" />
|
||||
<CommandDialog
|
||||
trigger={
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors">
|
||||
Homebrew
|
||||
</span>
|
||||
}
|
||||
title="Install via Homebrew"
|
||||
command="brew install --cask paseo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Windows */}
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<WindowsIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Windows</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill
|
||||
href={`${releaseBase}/Paseo-Setup-${desktopVersion}.exe`}
|
||||
label="Download"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linux */}
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<LinuxIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Linux</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill
|
||||
href={linuxAppImageDownloadUrl}
|
||||
label="AppImage"
|
||||
/>
|
||||
<DownloadPill href={linuxDebDownloadUrl} label="DEB" />
|
||||
<DownloadPill href={linuxRpmDownloadUrl} label="RPM" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mobile */}
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-semibold">Mobile</h2>
|
||||
<PhoneIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* Android */}
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<AndroidIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Android</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill
|
||||
href={playStoreUrl}
|
||||
label="Play Store"
|
||||
external
|
||||
/>
|
||||
<DownloadPill
|
||||
href={`${releaseBase}/paseo-v${desktopVersion}-android.apk`}
|
||||
label="APK"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* iOS */}
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppleIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">iOS</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill
|
||||
href={appStoreUrl}
|
||||
label="App Store"
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Web & CLI */}
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-semibold">Web & CLI</h2>
|
||||
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<GlobeIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Web App</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill
|
||||
href={webAppUrl}
|
||||
label="Open"
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<TerminalIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">CLI</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<code className="text-sm text-muted-foreground font-mono bg-muted px-3 py-1.5 rounded-lg">
|
||||
npm install -g @getpaseo/cli
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground mt-8">
|
||||
All releases are available on{" "}
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadPill({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
external?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{external && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="ml-1.5 h-3 w-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M7 17L17 7" />
|
||||
<path d="M7 7h10v10" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a1a1aa;
|
||||
--color-muted-foreground: #b6b6b6;
|
||||
--color-card: #171717;
|
||||
--color-border: #27272a;
|
||||
--color-primary: #3b82f6;
|
||||
|
||||
76
scripts/fix-lockfile.mjs
Normal file
76
scripts/fix-lockfile.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
// Workaround for https://github.com/npm/cli/issues/4460
|
||||
//
|
||||
// npm silently omits `resolved` and `integrity` fields from some
|
||||
// package-lock.json entries in workspace monorepos (especially for
|
||||
// workspace-hoisted packages). npm acknowledged this as a bug in 2022
|
||||
// but has never shipped a fix.
|
||||
//
|
||||
// This is harmless for regular `npm ci`, but breaks offline installers
|
||||
// like Nix that need every entry to have a resolved URL + integrity hash
|
||||
// so they can pre-fetch all tarballs in a sandbox with no network access.
|
||||
//
|
||||
// This script finds incomplete entries and fills them in using `npm view`.
|
||||
// It's idempotent — running it on an already-complete lockfile is a no-op.
|
||||
//
|
||||
// See also: https://github.com/npm/cli/issues/4263
|
||||
// https://github.com/npm/cli/issues/6301
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/fix-lockfile.mjs
|
||||
// node scripts/fix-lockfile.mjs path/to/package-lock.json
|
||||
|
||||
import fs from "fs";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const lockPath = process.argv[2] || "package-lock.json";
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||
|
||||
// Collect workspace package roots (local packages, not from npm)
|
||||
const workspaceRoots = new Set();
|
||||
for (const [key, val] of Object.entries(lock.packages || {})) {
|
||||
if (val.link) {
|
||||
workspaceRoots.add(val.resolved || key);
|
||||
}
|
||||
}
|
||||
|
||||
let fixed = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(lock.packages || {})) {
|
||||
if (
|
||||
!key || // root package
|
||||
key.startsWith("node_modules/") || // top-level (already has resolved)
|
||||
val.link || // workspace link entry
|
||||
(val.resolved && val.integrity) || // already complete
|
||||
!val.version || // no version to look up
|
||||
workspaceRoots.has(key) // workspace package root (local, not on npm)
|
||||
)
|
||||
continue;
|
||||
|
||||
const pkgName = key.replace(/.*node_modules\//, "");
|
||||
const version = val.version;
|
||||
|
||||
try {
|
||||
const info = JSON.parse(
|
||||
execSync(`npm view ${pkgName}@${version} --json dist`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})
|
||||
);
|
||||
if (info.tarball && info.integrity) {
|
||||
val.resolved = info.tarball;
|
||||
val.integrity = info.integrity;
|
||||
fixed++;
|
||||
}
|
||||
} catch {
|
||||
console.error(`Warning: could not fetch info for ${pkgName}@${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
||||
|
||||
if (fixed > 0) {
|
||||
console.log(`Fixed ${fixed} lockfile entries with missing resolved/integrity`);
|
||||
} else {
|
||||
console.log("Lockfile is already complete");
|
||||
}
|
||||
@@ -150,7 +150,8 @@ function main() {
|
||||
const targetEntry = entries.find((entry) => entry.tag === targetTag);
|
||||
|
||||
if (!targetEntry) {
|
||||
throw new Error(`No matching changelog section found for ${targetTag}.`);
|
||||
console.log(`No matching changelog section found for ${targetTag}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
|
||||
|
||||
@@ -9,6 +9,12 @@ const rootPackagePath = path.join(rootDir, "package.json");
|
||||
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8"));
|
||||
const rootVersion = rootPackage.version;
|
||||
const workspacePaths = Array.isArray(rootPackage.workspaces) ? rootPackage.workspaces : [];
|
||||
const sharedMetadata = {
|
||||
homepage: rootPackage.homepage,
|
||||
repository: rootPackage.repository,
|
||||
author: rootPackage.author,
|
||||
license: rootPackage.license,
|
||||
};
|
||||
|
||||
if (typeof rootVersion !== "string" || rootVersion.length === 0) {
|
||||
throw new Error('Root package.json must contain a valid "version"');
|
||||
@@ -37,6 +43,17 @@ for (const workspacePath of workspacePaths) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (pkg.name === "@getpaseo/desktop") {
|
||||
for (const [field, value] of Object.entries(sharedMetadata)) {
|
||||
const currentValue = JSON.stringify(pkg[field]);
|
||||
const nextValue = JSON.stringify(value);
|
||||
if (currentValue !== nextValue) {
|
||||
pkg[field] = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of dependencySections) {
|
||||
const deps = pkg[section];
|
||||
if (!deps || typeof deps !== "object") {
|
||||
|
||||
63
scripts/update-nix.sh
Executable file
63
scripts/update-nix.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fix workspace-local lockfile entries and update the Nix dependency hash.
|
||||
# Requires: node, npm, nix
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update-nix.sh # fix lockfile + update hash
|
||||
# ./scripts/update-nix.sh --check # verify everything is up to date (CI mode)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCK_FILE="$ROOT_DIR/package-lock.json"
|
||||
PACKAGE_NIX="$ROOT_DIR/nix/package.nix"
|
||||
|
||||
CHECK_MODE=false
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
CHECK_MODE=true
|
||||
fi
|
||||
|
||||
# 1. Fix lockfile (add resolved/integrity for workspace-local entries)
|
||||
# Workaround for https://github.com/npm/cli/issues/4460
|
||||
echo "Fixing lockfile..."
|
||||
node "$SCRIPT_DIR/fix-lockfile.mjs" "$LOCK_FILE"
|
||||
|
||||
# 2. Prefetch deps and compute hash
|
||||
echo "Prefetching npm dependencies..."
|
||||
|
||||
# Resolve prefetch-npm-deps from the same nixpkgs pinned in flake.lock
|
||||
NIXPKGS_URL="$(node -p "
|
||||
const l = JSON.parse(require('fs').readFileSync('$ROOT_DIR/flake.lock', 'utf8'));
|
||||
const n = l.nodes.nixpkgs.locked;
|
||||
'github:' + n.owner + '/' + n.repo + '/' + n.rev;
|
||||
")"
|
||||
|
||||
STDERR_LOG="$(mktemp)"
|
||||
trap "rm -f '$STDERR_LOG'" EXIT
|
||||
|
||||
if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then
|
||||
echo "ERROR: prefetch-npm-deps failed:" >&2
|
||||
tail -20 "$STDERR_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Computed hash: $NEW_HASH"
|
||||
|
||||
# 3. Read current hash
|
||||
CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')"
|
||||
|
||||
if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then
|
||||
echo "Hash is already up to date."
|
||||
else
|
||||
if $CHECK_MODE; then
|
||||
echo "ERROR: npmDepsHash is stale."
|
||||
echo " current: $CURRENT_HASH"
|
||||
echo " correct: $NEW_HASH"
|
||||
echo "Run ./scripts/update-nix.sh to fix."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating npmDepsHash in nix/package.nix..."
|
||||
sed -i.bak "s|npmDepsHash = \".*\"|npmDepsHash = \"$NEW_HASH\"|" "$PACKAGE_NIX"
|
||||
rm -f "$PACKAGE_NIX.bak"
|
||||
echo "Updated: $CURRENT_HASH -> $NEW_HASH"
|
||||
fi
|
||||
@@ -495,7 +495,10 @@ case "$command_name" in
|
||||
sanitize_room_path "$room"
|
||||
require_room "$room"
|
||||
|
||||
mapfile -t all_files < <(message_files_sorted "$room")
|
||||
all_files=()
|
||||
while IFS= read -r _f; do
|
||||
all_files+=("$_f")
|
||||
done < <(message_files_sorted "$room")
|
||||
|
||||
since_file=""
|
||||
if [[ -n "$since" ]]; then
|
||||
@@ -587,7 +590,10 @@ case "$command_name" in
|
||||
trap 'exit 130' INT TERM
|
||||
|
||||
while true; do
|
||||
mapfile -t all_files < <(message_files_sorted "$room")
|
||||
all_files=()
|
||||
while IFS= read -r _f; do
|
||||
all_files+=("$_f")
|
||||
done < <(message_files_sorted "$room")
|
||||
new_files=()
|
||||
after_baseline=false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user