Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a73d93ec36 |
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: store-screenshots
|
||||
description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Store screenshots
|
||||
|
||||
Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports.
|
||||
|
||||
Working rules:
|
||||
|
||||
- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store.
|
||||
- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type.
|
||||
- Use SF Pro. Never substitute another font.
|
||||
- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview.
|
||||
- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks.
|
||||
- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons.
|
||||
- When you learn something the next capture needs, fix the README rather than this skill.
|
||||
@@ -41,7 +41,6 @@ server:
|
||||
desktop:
|
||||
- "packages/desktop/**"
|
||||
- "packages/app/src/desktop/**"
|
||||
- "packages/app/src/**/*.electron.{ts,tsx}"
|
||||
- "packages/server/src/server/browser-tools/**"
|
||||
- "packages/app/e2e/support/**"
|
||||
- "packages/app/*config.{cjs,js,ts}"
|
||||
|
||||
@@ -3,8 +3,8 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -133,12 +133,11 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -396,7 +395,7 @@ jobs:
|
||||
name: app-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -422,7 +421,7 @@ jobs:
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
@@ -478,7 +477,7 @@ jobs:
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
@@ -488,7 +487,7 @@ jobs:
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
@@ -498,7 +497,7 @@ jobs:
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
@@ -508,7 +507,7 @@ jobs:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
@@ -518,7 +517,7 @@ jobs:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
@@ -528,7 +527,7 @@ jobs:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
@@ -538,7 +537,7 @@ jobs:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
|
||||
# or how it is smoked still proves the real artifacts before merge.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
|
||||
@@ -169,34 +169,6 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PASEO_REAL_XCRUN="$(command -v xcrun)"
|
||||
stapler_bin="$(mktemp -d)"
|
||||
trap 'rm -rf "$stapler_bin"' EXIT
|
||||
cat > "$stapler_bin/xcrun" <<'STAPLER'
|
||||
#!/bin/bash
|
||||
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
|
||||
exec "$PASEO_REAL_XCRUN" "$@"
|
||||
fi
|
||||
output="$(mktemp)"
|
||||
trap 'rm -f "$output"' EXIT
|
||||
for attempt in 1 2 3 4; do
|
||||
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
|
||||
cat "$output"
|
||||
exit 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
cat "$output"
|
||||
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
|
||||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
|
||||
exit "$status"
|
||||
fi
|
||||
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
|
||||
sleep 30
|
||||
done
|
||||
STAPLER
|
||||
chmod +x "$stapler_bin/xcrun"
|
||||
export PATH="$stapler_bin:$PATH"
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
|
||||
@@ -85,176 +85,6 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
|
||||
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
|
||||
{
|
||||
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
|
||||
"rules": {
|
||||
"paseo/no-sync-io": [
|
||||
"error",
|
||||
{
|
||||
"allow": {
|
||||
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
|
||||
"executable-resolution/executable-resolution.ts": ["existsSync"],
|
||||
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
|
||||
"executable-resolution/windows.ts": ["readdirSync"],
|
||||
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
|
||||
"server/agent/providers/claude/agent.ts": ["existsSync"],
|
||||
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
|
||||
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
|
||||
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
|
||||
"server/agent/providers/claude/subagents/workflow-output.ts": [
|
||||
"closeSync",
|
||||
"openSync",
|
||||
"readSync"
|
||||
],
|
||||
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
|
||||
"server/agent/providers/omp/agent.ts": ["existsSync"],
|
||||
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
|
||||
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
|
||||
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
|
||||
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
|
||||
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
|
||||
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
|
||||
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
|
||||
"server/agent/providers/pi/agent.ts": [
|
||||
"existsSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
|
||||
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
|
||||
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
|
||||
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
|
||||
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
|
||||
"server/config.ts": ["existsSync"],
|
||||
// Startup identity: load or create the daemon keypair before accepting connections.
|
||||
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
|
||||
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
|
||||
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
|
||||
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
|
||||
"server/host-name.ts": ["execFileSync"],
|
||||
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
|
||||
"server/hub/relationship-controller.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
|
||||
"server/local-credential.ts": ["readFileSync"],
|
||||
// Startup logger construction: create the log directory before opening the logging destination.
|
||||
"server/logger.ts": ["mkdirSync"],
|
||||
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
|
||||
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
|
||||
// Module initialization: the package version is resolved once and cached at import.
|
||||
"server/package-version.ts": ["existsSync", "readFileSync"],
|
||||
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
|
||||
"server/persisted-config.ts": ["existsSync", "readFileSync"],
|
||||
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
|
||||
"server/pid-lock.ts": ["readFileSync"],
|
||||
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
|
||||
"server/plugins/builtin/index.ts": ["existsSync"],
|
||||
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
|
||||
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
|
||||
// Build child process: select the esbuild binary for the isolated plugin compiler.
|
||||
"server/plugins/compiler-esbuild.ts": ["existsSync"],
|
||||
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
|
||||
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
|
||||
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
|
||||
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
|
||||
"server/private-files.ts": [
|
||||
"chmodSync",
|
||||
"mkdirSync",
|
||||
"realpathSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
|
||||
"server/push/token-store.ts": ["existsSync", "readFileSync"],
|
||||
// Startup identity: load or create the server id before accepting connections.
|
||||
"server/server-id.ts": ["existsSync", "readFileSync"],
|
||||
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
|
||||
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
|
||||
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
|
||||
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
|
||||
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
|
||||
// Speech child process: native addon discovery and patchelf run outside the daemon.
|
||||
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
|
||||
"existsSync",
|
||||
"spawnSync"
|
||||
],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
|
||||
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
|
||||
"server/web-ui.ts": ["readFileSync", "statSync"],
|
||||
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
|
||||
"server/workspace-registry-bootstrap.ts": ["statSync"],
|
||||
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
|
||||
"terminal/agent-hooks/agent-hook-installer.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
|
||||
"terminal/terminal-worker-process.ts": [
|
||||
"chmodSync",
|
||||
"closeSync",
|
||||
"existsSync",
|
||||
"openSync",
|
||||
"rmSync",
|
||||
"writeSync"
|
||||
],
|
||||
// Worker startup only: choose and secure the local listening socket before accepting a controller.
|
||||
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
|
||||
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
|
||||
"terminal/terminal-worker-runtime.ts": [
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"statSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
|
||||
"terminal/paseo-cli-path.ts": ["existsSync"],
|
||||
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
|
||||
"terminal/terminal-ts-loader.mjs": ["existsSync"],
|
||||
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
|
||||
"terminal/terminal.ts": [
|
||||
"accessSync",
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"statSync"
|
||||
],
|
||||
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
|
||||
"utils/path.ts": ["realpathSync"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
||||
"packages/server/src/**/test-utils/**",
|
||||
"packages/server/src/server/daemon-e2e/**",
|
||||
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
|
||||
],
|
||||
"rules": { "paseo/no-sync-io": "off" }
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"plugins/*-provider/server/**/*.ts",
|
||||
|
||||
@@ -1,175 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||
|
||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||
|
||||
### Added
|
||||
|
||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
|
||||
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
|
||||
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
|
||||
|
||||
### Improved
|
||||
|
||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
|
||||
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
|
||||
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
|
||||
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
|
||||
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
|
||||
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
|
||||
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
|
||||
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
|
||||
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
|
||||
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
|
||||
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
|
||||
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### Notes
|
||||
|
||||
- Voice mode now defaults to off; enable it in Voice settings
|
||||
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
|
||||
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
```sh
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
paseo host add 'relay://...' --label laptop
|
||||
```
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
|
||||
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
|
||||
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
|
||||
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
|
||||
- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
|
||||
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
|
||||
- Paused status ring animations inside hidden panels on iOS and Android ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
|
||||
- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
|
||||
- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
|
||||
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
|
||||
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
|
||||
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
|
||||
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
|
||||
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
|
||||
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
|
||||
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
- Fixed `paseo agent archive` reporting `AGENT_NOT_FOUND` for agents beyond the first 200 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
|
||||
- Fixed archived agents reappearing in MCP `list_agents` after their history was read ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed detached agents created with `notifyOnFinish` never notifying their creator ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed OpenCode agents being told `notifyOnFinish` defaults to off ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
|
||||
- Fixed Pi subagent rows staying running when a resume is refused ([#7369](https://github.com/getpaseo/paseo/pull/7369))
|
||||
- Fixed Android source builds downloading a second NDK ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Fixed Nix packages reporting the AGPL license instead of Apache-2.0 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
|
||||
|
||||
## 0.12.0 - 2026-10-19
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
@@ -36,20 +36,16 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
@@ -66,7 +62,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
@@ -111,8 +106,6 @@ npm run format:check # Check formatting without writing
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Release branches
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
|
||||
|
||||
### 桌面 app(推荐)
|
||||
|
||||
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
|
||||
如果要从手机连接,在 Settings 中扫描显示的二维码。
|
||||
|
||||
@@ -97,8 +97,8 @@ paseo
|
||||
|
||||
完整安装和配置见:
|
||||
|
||||
- [文档](https://paseo.sh/zh-CN/docs)
|
||||
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
|
||||
- [文档](https://paseo.sh/docs)
|
||||
- [配置参考](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -164,7 +164,7 @@ npm run typecheck
|
||||
|
||||
## 支持 Paseo
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes.
|
||||
4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
|
||||
|
||||
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
|
||||
|
||||
@@ -34,15 +34,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
|
||||
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
|
||||
- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
|
||||
- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode
|
||||
- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated.
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
|
||||
### Trust model
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
output/preview.png
|
||||
@@ -1,98 +0,0 @@
|
||||
# Store screenshots
|
||||
|
||||
Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change.
|
||||
|
||||
| # | Headline | Capture | Phone callout | Tablet callout |
|
||||
| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |
|
||||
| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) |
|
||||
| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) |
|
||||
| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover |
|
||||
| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) |
|
||||
| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree |
|
||||
|
||||
On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`.
|
||||
|
||||
## Creative direction
|
||||
|
||||
The panels follow paseo.sh:
|
||||
|
||||
- **Canvas:** the site's `#101615` background.
|
||||
- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines.
|
||||
- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height.
|
||||
- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site.
|
||||
- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred.
|
||||
- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain.
|
||||
|
||||
Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups.
|
||||
|
||||
Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it.
|
||||
|
||||
## Files
|
||||
|
||||
- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts.
|
||||
- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout.
|
||||
- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there.
|
||||
- `demo/`: the staged demo host the captures come from.
|
||||
- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot.
|
||||
- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures.
|
||||
- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot.
|
||||
- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures.
|
||||
- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store.
|
||||
|
||||
All exports are opaque RGB PNGs.
|
||||
|
||||
## Render
|
||||
|
||||
```sh
|
||||
SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs
|
||||
```
|
||||
|
||||
`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font.
|
||||
|
||||
Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased.
|
||||
|
||||
Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing.
|
||||
|
||||
Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it.
|
||||
|
||||
## Recapture
|
||||
|
||||
1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree:
|
||||
|
||||
```sh
|
||||
npx tsx assets/store-screenshots/demo/stage.mts
|
||||
```
|
||||
|
||||
Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output.
|
||||
|
||||
2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs.
|
||||
|
||||
```sh
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port <port>
|
||||
ssh -o ExitOnForwardFailure=yes -R <port>:127.0.0.1:<port> -R 7791:127.0.0.1:7791 <mac-host>
|
||||
```
|
||||
|
||||
3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:<port>`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set.
|
||||
- Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar <device> override`).
|
||||
- On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays.
|
||||
- Name the host "MacBook Pro" and set the sidebar badge to Hidden.
|
||||
- Group the sidebar by status.
|
||||
- If you reset the simulator, reinstall the dev client.
|
||||
|
||||
4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture.
|
||||
|
||||
5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store:
|
||||
|
||||
```sh
|
||||
agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868
|
||||
agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752
|
||||
```
|
||||
|
||||
- **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat.
|
||||
- **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks.
|
||||
- **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 <terminal-id> "<text>" Enter` instead of the simulator keyboard, then dismiss the software keyboard.
|
||||
- **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`.
|
||||
|
||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||
|
||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||
@@ -1,84 +0,0 @@
|
||||
// Applies the demo change to a checkout of the Paseo repository.
|
||||
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = process.argv[2];
|
||||
const client = path.join(repo, "packages/client/src");
|
||||
|
||||
copyFileSync(
|
||||
path.join(here, "reconnect-delay.ts"),
|
||||
path.join(client, "connection/reconnect-delay.ts"),
|
||||
);
|
||||
copyFileSync(
|
||||
path.join(here, "reconnect-delay.test.ts"),
|
||||
path.join(client, "connection/reconnect-delay.test.ts"),
|
||||
);
|
||||
|
||||
function edit(file, pairs) {
|
||||
let text = readFileSync(file, "utf8");
|
||||
for (const [from, to] of pairs) {
|
||||
if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`);
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
writeFileSync(file, text);
|
||||
}
|
||||
|
||||
edit(path.join(client, "daemon-client.ts"), [
|
||||
[
|
||||
`import { CreationClient } from "./creation/index.js";\n`,
|
||||
`import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`,
|
||||
],
|
||||
[
|
||||
` private reconnectAttempt = 0;\n`,
|
||||
` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`,
|
||||
],
|
||||
[
|
||||
` nextRetryAt: new Date(
|
||||
Date.now() +
|
||||
Math.min(
|
||||
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
|
||||
2 ** this.reconnectAttempt,
|
||||
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
),
|
||||
).toISOString(),
|
||||
`,
|
||||
` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(),
|
||||
`,
|
||||
],
|
||||
[
|
||||
` private armReconnectTimer(): void {
|
||||
const attempt = this.reconnectAttempt;
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
||||
`,
|
||||
` // The countdown and the timer must agree, so each attempt draws its jitter once.
|
||||
private getNextReconnectDelayMs(): number {
|
||||
const attempt = this.reconnectAttempt;
|
||||
if (this.nextReconnectDelay?.attempt !== attempt) {
|
||||
this.nextReconnectDelay = {
|
||||
attempt,
|
||||
delayMs: getReconnectDelayMs(attempt, {
|
||||
baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS,
|
||||
maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return this.nextReconnectDelay.delayMs;
|
||||
}
|
||||
|
||||
private armReconnectTimer(): void {
|
||||
const attempt = this.reconnectAttempt;
|
||||
const delay = this.getNextReconnectDelayMs();
|
||||
`,
|
||||
],
|
||||
]);
|
||||
|
||||
edit(path.join(repo, "CHANGELOG.md"), [
|
||||
[
|
||||
`# Changelog\n\n`,
|
||||
`# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`,
|
||||
],
|
||||
]);
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getReconnectDelayMs } from "./reconnect-delay";
|
||||
|
||||
const options = { baseDelayMs: 1500, maxDelayMs: 30_000 };
|
||||
|
||||
describe("getReconnectDelayMs", () => {
|
||||
it("doubles the ceiling on each attempt", () => {
|
||||
const top = () => 1;
|
||||
expect(getReconnectDelayMs(0, options, top)).toBe(1500);
|
||||
expect(getReconnectDelayMs(1, options, top)).toBe(3000);
|
||||
expect(getReconnectDelayMs(2, options, top)).toBe(6000);
|
||||
});
|
||||
|
||||
it("never waits longer than the max delay", () => {
|
||||
expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000);
|
||||
});
|
||||
|
||||
it("spreads retries between half and the full ceiling", () => {
|
||||
expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000);
|
||||
expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000);
|
||||
expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000);
|
||||
});
|
||||
|
||||
it("keeps the first retry under two seconds", () => {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface ReconnectDelayOptions {
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff with equal jitter. Half of each delay is fixed and half
|
||||
* is random, so a relay restart spreads reconnects out instead of every client
|
||||
* retrying on the same tick.
|
||||
*/
|
||||
export function getReconnectDelayMs(
|
||||
attempt: number,
|
||||
options: ReconnectDelayOptions,
|
||||
random: () => number = Math.random,
|
||||
): number {
|
||||
const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs);
|
||||
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on
|
||||
// every composer that opens the latest preview deploy for each branch. The
|
||||
// deploys are fixed sample data; the UI is a real plugin surface.
|
||||
import type {
|
||||
PluginButtonContentProps,
|
||||
PluginButtonRegistration,
|
||||
PluginClientContext,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
type DeployStatus = "ready" | "building" | "failed";
|
||||
|
||||
interface Deploy {
|
||||
branch: string;
|
||||
status: DeployStatus;
|
||||
detail: string;
|
||||
age: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
const DEPLOYS: Deploy[] = [
|
||||
{ branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" },
|
||||
{
|
||||
branch: "perf/provider-snapshot-cache",
|
||||
status: "building",
|
||||
detail: "Bundling app",
|
||||
age: "now",
|
||||
progress: 0.64,
|
||||
},
|
||||
{ branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" },
|
||||
{
|
||||
branch: "i18n/ko-settings",
|
||||
status: "failed",
|
||||
detail: "Missing key settings.notifications",
|
||||
age: "26m",
|
||||
},
|
||||
];
|
||||
|
||||
function DeploysSheet({ theme, layout }: PluginButtonContentProps) {
|
||||
const mono = layout.platform === "ios" ? "Menlo" : "monospace";
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
// Popovers on tablets and desktop are narrow by default; give the
|
||||
// branch names room. Phones show the sheet at full width.
|
||||
body: { gap: 14, width: layout.compact ? undefined : 400 },
|
||||
production: {
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
||||
label: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||
version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const },
|
||||
live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const },
|
||||
heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 },
|
||||
list: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "center" as const,
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
divider: { height: 1, backgroundColor: theme.colors.border },
|
||||
text: { flex: 1, gap: 4 },
|
||||
branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono },
|
||||
detail: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||
failed: { color: theme.colors.statusDanger, fontSize: 13 },
|
||||
age: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||
track: {
|
||||
height: 4,
|
||||
marginTop: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
}),
|
||||
[theme, mono, layout.compact],
|
||||
);
|
||||
const dotColor: Record<DeployStatus, string> = {
|
||||
ready: theme.colors.statusSuccess,
|
||||
building: theme.colors.statusWarning,
|
||||
failed: theme.colors.statusDanger,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<View style={styles.production}>
|
||||
<View style={styles.productionTop}>
|
||||
<Dot color={theme.colors.statusSuccess} />
|
||||
<Text style={styles.live}>Live</Text>
|
||||
<Text style={styles.label}>Production · main</Text>
|
||||
</View>
|
||||
<Text style={styles.version}>v0.14.0-beta.1</Text>
|
||||
<Text style={styles.label}>Deployed 3h ago</Text>
|
||||
</View>
|
||||
<Text style={styles.heading}>Previews</Text>
|
||||
<View style={styles.list}>
|
||||
{DEPLOYS.map((deploy, index) => (
|
||||
<View key={deploy.branch}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<View style={styles.row}>
|
||||
<Dot color={dotColor[deploy.status]} />
|
||||
<View style={styles.text}>
|
||||
<Text style={styles.branch} numberOfLines={1}>
|
||||
{deploy.branch}
|
||||
</Text>
|
||||
<Text style={deploy.status === "failed" ? styles.failed : styles.detail}>
|
||||
{deploy.detail}
|
||||
</Text>
|
||||
{deploy.progress !== undefined ? (
|
||||
<Progress
|
||||
value={deploy.progress}
|
||||
track={styles.track}
|
||||
color={theme.colors.statusWarning}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.age}>{deploy.age}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ value, track, color }: { value: number; track: object; color: string }) {
|
||||
const fill = useMemo(
|
||||
() => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }),
|
||||
[value, color],
|
||||
);
|
||||
return (
|
||||
<View style={track}>
|
||||
<View style={fill} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
const style = useMemo(
|
||||
() => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }),
|
||||
[color],
|
||||
);
|
||||
return <View style={style} />;
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
const pills = new Map<string, PluginButtonRegistration>();
|
||||
const lifetime = new AbortController();
|
||||
const register = (agent: { id: string; workspaceId?: string | null }) => {
|
||||
if (lifetime.signal.aborted || !agent.workspaceId) return;
|
||||
pills.get(agent.id)?.remove();
|
||||
pills.set(
|
||||
agent.id,
|
||||
client.addComposerPill({
|
||||
id: "deploys",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
button: {
|
||||
title: "Preview deploys",
|
||||
icon: "Rocket",
|
||||
label: "Deploys",
|
||||
behavior: { kind: "popover", Content: DeploysSheet },
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
void client.paseo.agents
|
||||
.list({ subscribe: {}, signal: lifetime.signal })
|
||||
.then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
snapshot: ({ entries }) => {
|
||||
for (const pill of pills.values()) pill.remove();
|
||||
pills.clear();
|
||||
for (const { agent } of entries) register(agent);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
const update = message.payload;
|
||||
if (update.kind === "remove") {
|
||||
pills.get(update.agentId)?.remove();
|
||||
pills.delete(update.agentId);
|
||||
} else register(update.agent);
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
|
||||
});
|
||||
return () => {
|
||||
lifetime.abort();
|
||||
for (const pill of pills.values()) pill.remove();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"id": "preview-deploys",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Scripted Claude Code and Codex clients for staging store screenshots.
|
||||
// Each agent replays a fixed conversation through the real daemon, so the app
|
||||
// renders real timeline rows, permission prompts, and running states.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ToolCallDetail,
|
||||
} from "../../../packages/server/src/server/agent/agent-sdk-types.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export type ScriptStep =
|
||||
| { kind: "say"; text: string }
|
||||
| { kind: "think"; text: string }
|
||||
| { kind: "tool"; name: string; detail: ToolCallDetail }
|
||||
| { kind: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| { kind: "wait"; ms: number }
|
||||
| { kind: "running-tool"; name: string; detail: ToolCallDetail }
|
||||
| { kind: "ask"; name: string; title: string; detail: ToolCallDetail };
|
||||
|
||||
export interface AgentScript {
|
||||
/** Matched against the start of the prompt that opens the turn. */
|
||||
prompt: string;
|
||||
steps: ScriptStep[];
|
||||
/** Leave the turn open after the last step. */
|
||||
keepRunning?: boolean;
|
||||
/** Pause between steps, so "Worked for" reflects a believable duration. */
|
||||
stepDelayMs?: number;
|
||||
}
|
||||
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const MODELS: Record<string, AgentModelDefinition[]> = {
|
||||
claude: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-5-5",
|
||||
label: "Opus 5.5",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium", isDefault: true },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
{ provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" },
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.6-sol",
|
||||
label: "GPT-5.6-Sol",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium", isDefault: true },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function promptText(prompt: AgentPromptInput): string {
|
||||
if (typeof prompt === "string") return prompt;
|
||||
return prompt
|
||||
.map((block) => ("text" in block && typeof block.text === "string" ? block.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
class ScriptedSession implements AgentSession {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly history: AgentStreamEvent[] = [];
|
||||
private pending: AgentPermissionRequest[] = [];
|
||||
private activeTurnId: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private readonly config: AgentSessionConfig,
|
||||
private readonly scripts: AgentScript[],
|
||||
) {}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event;
|
||||
this.history.push(tagged);
|
||||
for (const listener of this.listeners) listener(tagged);
|
||||
}
|
||||
|
||||
private timeline(item: AgentTimelineItem): void {
|
||||
this.emit({ type: "timeline", provider: this.provider, item });
|
||||
}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
const text = promptText(prompt);
|
||||
const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt));
|
||||
setTimeout(() => void this.play(text, script), 50);
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
private async play(text: string, script: AgentScript | undefined): Promise<void> {
|
||||
this.emit({ type: "turn_started", provider: this.provider });
|
||||
this.timeline({ type: "user_message", text, messageId: randomUUID() });
|
||||
const delay = script?.stepDelayMs ?? 400;
|
||||
for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay));
|
||||
this.playStep(step);
|
||||
if (step.kind === "ask") return;
|
||||
}
|
||||
if (script?.keepRunning) return;
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: this.provider,
|
||||
usage: { inputTokens: 48_210, outputTokens: 3_904 },
|
||||
});
|
||||
this.activeTurnId = null;
|
||||
}
|
||||
|
||||
private playStep(step: ScriptStep): void {
|
||||
switch (step.kind) {
|
||||
case "say":
|
||||
this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() });
|
||||
return;
|
||||
case "think":
|
||||
this.timeline({ type: "reasoning", text: step.text });
|
||||
return;
|
||||
case "todo":
|
||||
this.timeline({ type: "todo", items: step.items });
|
||||
return;
|
||||
case "wait":
|
||||
return;
|
||||
case "tool":
|
||||
case "running-tool": {
|
||||
const callId = randomUUID();
|
||||
this.timeline({
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: step.name,
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: step.detail,
|
||||
});
|
||||
if (step.kind === "tool") {
|
||||
this.timeline({
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: step.name,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: step.detail,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "ask": {
|
||||
const request: AgentPermissionRequest = {
|
||||
id: randomUUID(),
|
||||
provider: this.provider,
|
||||
name: step.name,
|
||||
kind: "tool",
|
||||
title: step.title,
|
||||
detail: step.detail,
|
||||
};
|
||||
this.pending = [request];
|
||||
this.emit({ type: "permission_requested", provider: this.provider, request });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield* this.history;
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null,
|
||||
thinkingOptionId: this.config.thinkingOptionId ?? "medium",
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return getAgentProviderDefinition(this.provider).modes;
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.config.modeId ?? null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async respondToPermission(requestId: string, resolution: AgentPermissionResponse) {
|
||||
this.pending = [];
|
||||
this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution });
|
||||
}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
if (!this.activeTurnId) return;
|
||||
this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" });
|
||||
this.activeTurnId = null;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class ScriptedAgentClient implements AgentClient {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private readonly scripts: AgentScript[],
|
||||
) {}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new ScriptedSession(this.provider, config, this.scripts);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new ScriptedSession(
|
||||
this.provider,
|
||||
{ provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides },
|
||||
this.scripts,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchCatalog() {
|
||||
return {
|
||||
models: MODELS[this.provider] ?? [],
|
||||
modes: getAgentProviderDefinition(this.provider).modes,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
// Stages the demo host used for store screenshots.
|
||||
//
|
||||
// npx tsx assets/store-screenshots/demo/stage.mts
|
||||
//
|
||||
// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude
|
||||
// Code and Codex clients, adds a local clone of this repository as the `paseo`
|
||||
// project, cuts real worktrees, applies a real change to the hero worktree, and
|
||||
// opens a terminal there. Leave it running while you capture.
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js";
|
||||
import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../..");
|
||||
const demoRoot = path.join(repoRoot, ".dev/store-capture/demo");
|
||||
const codeRoot = path.join(demoRoot, "code");
|
||||
const pluginDir = path.join(here, "preview-deploys");
|
||||
const port = Number(process.env.DEMO_PORT ?? 7791);
|
||||
const minute = 60_000;
|
||||
|
||||
const claudeScripts: AgentScript[] = [];
|
||||
const codexScripts: AgentScript[] = [];
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function run(cwd: string, command: string): string {
|
||||
return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } });
|
||||
}
|
||||
|
||||
async function prepareRepos(): Promise<void> {
|
||||
// A fresh clone each run: worktrees from an earlier run stay registered in it.
|
||||
// Worktree setup (npm ci, builds) is dropped so new worktrees open instantly.
|
||||
const paseo = path.join(codeRoot, "paseo");
|
||||
await rm(paseo, { recursive: true, force: true });
|
||||
await mkdir(codeRoot, { recursive: true });
|
||||
execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]);
|
||||
git(paseo, "checkout", "-q", "-B", "main");
|
||||
git(paseo, "remote", "remove", "origin");
|
||||
const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8"));
|
||||
delete config.worktree;
|
||||
await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||
git(paseo, "commit", "-qam", "chore: skip worktree setup in demo");
|
||||
const dotfiles = path.join(codeRoot, "dotfiles");
|
||||
if (!existsSync(dotfiles)) {
|
||||
await mkdir(dotfiles, { recursive: true });
|
||||
await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n");
|
||||
await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n");
|
||||
git(dotfiles, "init", "-q", "-b", "main");
|
||||
git(dotfiles, "add", ".");
|
||||
git(dotfiles, "commit", "-qm", "Initial dotfiles");
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await prepareRepos();
|
||||
const home = path.join(demoRoot, "home");
|
||||
await rm(home, { recursive: true, force: true });
|
||||
await mkdir(home, { recursive: true });
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: {
|
||||
claude: new ScriptedAgentClient("claude", claudeScripts),
|
||||
codex: new ScriptedAgentClient("codex", codexScripts),
|
||||
},
|
||||
listen: "127.0.0.1",
|
||||
listenPort: port,
|
||||
paseoHomeRoot: home,
|
||||
mcpEnabled: false,
|
||||
cleanup: false,
|
||||
pluginsEnabled: true,
|
||||
plugins: {
|
||||
"preview-deploys": { source: "directory", path: pluginDir, enabled: true },
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.14.0",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const paseo = await client.addProject(path.join(codeRoot, "paseo"));
|
||||
const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles"));
|
||||
const paseoProjectId = paseo.project!.projectId;
|
||||
|
||||
async function worktree(title: string, branchName: string) {
|
||||
const result = await client.createWorkspace({
|
||||
title,
|
||||
source: {
|
||||
kind: "worktree",
|
||||
projectId: paseoProjectId,
|
||||
action: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName,
|
||||
},
|
||||
});
|
||||
if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`);
|
||||
return result.workspace;
|
||||
}
|
||||
|
||||
async function agent(input: {
|
||||
provider: "claude" | "codex";
|
||||
workspace: { id: string; workspaceDirectory?: string | null };
|
||||
cwd: string;
|
||||
title: string;
|
||||
script: AgentScript;
|
||||
}) {
|
||||
(input.provider === "claude" ? claudeScripts : codexScripts).push(input.script);
|
||||
return client.createAgent({
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspace.id,
|
||||
title: input.title,
|
||||
initialPrompt: input.script.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Done: finished earlier and already read.
|
||||
const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1");
|
||||
const releaseAgent = await agent({
|
||||
provider: "codex",
|
||||
workspace: release,
|
||||
cwd: release.workspaceDirectory!,
|
||||
title: "Release v0.14.0-beta.1",
|
||||
script: {
|
||||
prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm run release:beta",
|
||||
output: "Tagged v0.14.0-beta.1\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const dotfilesWorkspace = (
|
||||
await client.createWorkspace({
|
||||
title: "Add a tmux status line",
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: path.join(codeRoot, "dotfiles"),
|
||||
projectId: dotfiles.project!.projectId,
|
||||
},
|
||||
})
|
||||
).workspace!;
|
||||
const tmuxAgent = await agent({
|
||||
provider: "claude",
|
||||
workspace: dotfilesWorkspace,
|
||||
cwd: path.join(codeRoot, "dotfiles"),
|
||||
title: "Add a tmux status line",
|
||||
script: {
|
||||
prompt: "Add a minimal tmux status line with the session name and battery.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "tmux.conf",
|
||||
oldString: "",
|
||||
newString: "set -g status-right '#S · #{battery_percentage}'",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Added a status line with the session name on the left and battery on the right.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await pause(3000);
|
||||
await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]);
|
||||
|
||||
// Working: turns that stay open while you capture.
|
||||
const ipad = await worktree(
|
||||
"Keep the composer above the iPad keyboard",
|
||||
"fix/ipad-keyboard-inset",
|
||||
);
|
||||
await agent({
|
||||
provider: "codex",
|
||||
workspace: ipad,
|
||||
cwd: ipad.workspaceDirectory!,
|
||||
title: "Keep the composer above the iPad keyboard",
|
||||
script: {
|
||||
prompt:
|
||||
"On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.",
|
||||
keepRunning: true,
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: 'rg -n "keyboardHeight" packages/app/src',
|
||||
output:
|
||||
"packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "read",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "packages/app/src/keyboard/shift/internal/provider.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.",
|
||||
},
|
||||
{
|
||||
kind: "running-tool",
|
||||
name: "shell",
|
||||
detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const snapshots = await worktree(
|
||||
"Cache provider snapshots on disk",
|
||||
"perf/provider-snapshot-cache",
|
||||
);
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: snapshots,
|
||||
cwd: snapshots.workspaceDirectory!,
|
||||
title: "Cache provider snapshots on disk",
|
||||
script: {
|
||||
prompt:
|
||||
"Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.",
|
||||
keepRunning: true,
|
||||
steps: [
|
||||
{
|
||||
kind: "todo",
|
||||
items: [
|
||||
{ text: "Find where snapshots are built", completed: true },
|
||||
{ text: "Persist the last snapshot per host", completed: false },
|
||||
{ text: "Refresh in the background after boot", completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "running-tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Needs input: waiting on a permission.
|
||||
const korean = await worktree("Korean translations for Settings", "i18n/ko-settings");
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: korean,
|
||||
cwd: korean.workspaceDirectory!,
|
||||
title: "Korean translations for Settings",
|
||||
script: {
|
||||
prompt: "Translate the new Settings strings into Korean and open a PR.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" },
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" },
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm run test --workspace=@getpaseo/app -- i18n",
|
||||
output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{ kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." },
|
||||
{
|
||||
kind: "ask",
|
||||
name: "Bash",
|
||||
title: "Bash",
|
||||
detail: { type: "shell", command: "git push -u origin i18n/ko-settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Ready to review: a second finished agent.
|
||||
const terminals = await worktree(
|
||||
"Remember the last terminal per workspace",
|
||||
"feat/remember-terminal",
|
||||
);
|
||||
await agent({
|
||||
provider: "codex",
|
||||
workspace: terminals,
|
||||
cwd: terminals.workspaceDirectory!,
|
||||
title: "Remember the last terminal per workspace",
|
||||
script: {
|
||||
prompt:
|
||||
"When I switch back to a workspace, open the terminal I was using last instead of the first one.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: 'rg -n "activeTerminalId" packages/app/src',
|
||||
output: "",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Plugin: the agent "built" the preview-deploys plugin this daemon runs.
|
||||
const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys");
|
||||
const pluginSource = (file: string) => run(pluginDir, `cat ${file}`);
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: plugin,
|
||||
cwd: plugin.workspaceDirectory!,
|
||||
title: "Build a preview deploys plugin",
|
||||
script: {
|
||||
prompt: "Build me a plugin that shows our preview deploys right next to the composer.",
|
||||
stepDelayMs: 6_000,
|
||||
steps: [
|
||||
{ kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } },
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "plugins/preview-deploys/paseo-plugin.json",
|
||||
content: pluginSource("paseo-plugin.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "plugins/preview-deploys/index.client.tsx",
|
||||
content: pluginSource("index.client.tsx"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "paseo plugin install ./plugins/preview-deploys",
|
||||
output: "Installed preview-deploys\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Hero: Claude finished a real change in a real worktree.
|
||||
const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter");
|
||||
const cwd = jitter.workspaceDirectory!;
|
||||
await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules"));
|
||||
execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]);
|
||||
const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts");
|
||||
const vitest = run(
|
||||
cwd,
|
||||
"npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
|
||||
).replace(/^ RUN .*\n/m, "");
|
||||
const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts");
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: jitter,
|
||||
cwd,
|
||||
title: "Add jitter to reconnect backoff",
|
||||
script: {
|
||||
prompt:
|
||||
'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.',
|
||||
stepDelayMs: 7_000,
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Grep",
|
||||
detail: {
|
||||
type: "search",
|
||||
toolName: "grep",
|
||||
query: "reconnectAttempt",
|
||||
filePaths: ["packages/client/src/daemon-client.ts"],
|
||||
numFiles: 1,
|
||||
numMatches: 9,
|
||||
mode: "files_with_matches",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "packages/client/src/daemon-client.ts",
|
||||
offset: 7040,
|
||||
limit: 60,
|
||||
content: readExcerpt,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "packages/client/src/connection/reconnect-delay.ts",
|
||||
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "packages/client/src/daemon-client.ts",
|
||||
unifiedDiff: clientDiff,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "packages/client/src/connection/reconnect-delay.test.ts",
|
||||
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
|
||||
output: vitest,
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "CHANGELOG.md",
|
||||
unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: [
|
||||
"Reconnects now back off with jitter.",
|
||||
"",
|
||||
"- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.",
|
||||
'- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.',
|
||||
"- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.",
|
||||
"",
|
||||
"Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const terminal = await client.createTerminal(cwd, "zsh", undefined, {
|
||||
workspaceId: jitter.id,
|
||||
command: "bash",
|
||||
args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"],
|
||||
});
|
||||
const terminalId = terminal.terminal?.id;
|
||||
if (terminalId) {
|
||||
await pause(1500);
|
||||
for (const line of [
|
||||
"git status --short",
|
||||
"cd packages/client",
|
||||
"npx vitest run reconnect --reporter=tree",
|
||||
]) {
|
||||
client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` });
|
||||
await pause(4000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`);
|
||||
await writeFile(
|
||||
path.join(demoRoot, "state.json"),
|
||||
JSON.stringify(
|
||||
{ port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const stop = async () => {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", stop);
|
||||
process.on("SIGINT", stop);
|
||||
void minute;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a
|
||||
# phone-width terminal, and vitest output without the absolute checkout path
|
||||
# or the long timing breakdown. Vitest drops its colors when piped, so the
|
||||
# wrapper puts its usual green checks and grey timings back.
|
||||
export FORCE_COLOR=1
|
||||
__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; }
|
||||
__branch() { git branch --show-current 2>/dev/null; }
|
||||
__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; }
|
||||
PROMPT_COMMAND='printf "\e]0;zsh\a"'
|
||||
PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] '
|
||||
npx() {
|
||||
command npx "$@" 2>&1 | sed -E \
|
||||
-e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \
|
||||
-e $'s/✓/\e[32m✓\e[0m/' \
|
||||
-e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \
|
||||
-e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \
|
||||
-e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/'
|
||||
}
|
||||
clear
|
||||
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 736 KiB |
|
Before Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 537 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 1006 KiB |
|
Before Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 298 KiB |
@@ -1,396 +0,0 @@
|
||||
// Renders the App Store and Google Play screenshots from the native captures in
|
||||
// raw/. Every image is resized with Lanczos to the exact pixel size it occupies
|
||||
// on the canvas, and the page renders at device scale 1, so the browser never
|
||||
// resamples a capture.
|
||||
//
|
||||
// node assets/store-screenshots/render.mjs
|
||||
//
|
||||
// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts;
|
||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { chromium } from "playwright";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import sharp from "sharp";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(root, "../..");
|
||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||
const output = path.join(root, "output");
|
||||
|
||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||
const DEVICES = {
|
||||
iphone: {
|
||||
capture: { width: 1320, height: 2868 },
|
||||
radius: 186,
|
||||
bezel: 22,
|
||||
island: { width: 378, height: 112, top: 33 },
|
||||
},
|
||||
ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null },
|
||||
};
|
||||
|
||||
// Every title wraps to two lines and every subline fits on one; the renderer
|
||||
// fails otherwise, because the device is placed from that fixed text height.
|
||||
//
|
||||
// Each scene names its capture per device, and a device entry may override the
|
||||
// title or subline where the copy depends on the form factor.
|
||||
//
|
||||
// A callout lifts a region of a capture out of the device. `crop` is in native
|
||||
// capture pixels, `zoom` is its size relative to the device screen (capped to
|
||||
// the canvas width), and the callout is centred on the spot the crop comes
|
||||
// from, moved by `shift` native pixels down the screen.
|
||||
const SCENES = [
|
||||
{
|
||||
id: "sidebar",
|
||||
backdrop: "agents",
|
||||
title: "Run many coding agents at once",
|
||||
body: "See which agents are working and which need you.",
|
||||
iphone: { capture: "sidebar" },
|
||||
ipad: { capture: "sidebar" },
|
||||
},
|
||||
{
|
||||
id: "chat",
|
||||
title: "Your full dev environment on your phone",
|
||||
body: "Start agents and follow every edit and command.",
|
||||
iphone: { capture: "chat" },
|
||||
ipad: { capture: "chat", title: "Your full dev environment on your tablet" },
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
title: "Ask an agent to change the app itself",
|
||||
body: "This Deploys sheet is a plugin an agent wrote.",
|
||||
iphone: {
|
||||
capture: "plugin",
|
||||
callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 },
|
||||
},
|
||||
ipad: {
|
||||
capture: "plugin",
|
||||
body: "This Deploys panel is a plugin an agent wrote.",
|
||||
callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
title: "Review the diff before you merge",
|
||||
body: "Every file the agent changed, on its own branch.",
|
||||
iphone: {
|
||||
capture: "diff",
|
||||
callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 },
|
||||
},
|
||||
ipad: { capture: "diff" },
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
title: "A real terminal in every workspace",
|
||||
body: "Run the tests yourself, on your own machine.",
|
||||
iphone: {
|
||||
capture: "terminal",
|
||||
callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 },
|
||||
},
|
||||
ipad: {
|
||||
capture: "terminal",
|
||||
callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// `textTop` is where the text starts when the device is sized by the canvas
|
||||
// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device
|
||||
// by its width and centres the text and device instead.
|
||||
const STORES = {
|
||||
apple: {
|
||||
device: "iphone",
|
||||
width: 1320,
|
||||
height: 2868,
|
||||
textTop: 230,
|
||||
titleSize: 100,
|
||||
bodySize: 46,
|
||||
textWidth: 1120,
|
||||
},
|
||||
google: {
|
||||
device: "iphone",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
textTop: 150,
|
||||
titleSize: 96,
|
||||
bodySize: 44,
|
||||
textWidth: 1200,
|
||||
},
|
||||
"apple-ipad": {
|
||||
device: "ipad",
|
||||
width: 2064,
|
||||
height: 2752,
|
||||
textTop: 200,
|
||||
titleSize: 124,
|
||||
bodySize: 56,
|
||||
textWidth: 1400,
|
||||
},
|
||||
"google-tablet": {
|
||||
device: "ipad",
|
||||
width: 1800,
|
||||
height: 3200,
|
||||
textTop: 200,
|
||||
titleSize: 136,
|
||||
bodySize: 60,
|
||||
textWidth: 1560,
|
||||
},
|
||||
};
|
||||
|
||||
const TITLE_LINE = 1.06;
|
||||
const BODY_LINE = 1.3;
|
||||
const BODY_GAP = 30;
|
||||
// The device starts this far below the text and runs BLEED off the bottom.
|
||||
const GAP = 140;
|
||||
const BLEED = 40;
|
||||
const MARGIN = 56;
|
||||
const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" };
|
||||
|
||||
function findFont() {
|
||||
const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean);
|
||||
for (const dir of dirs) {
|
||||
const file = path.join(dir, "SFNS.ttf");
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS.");
|
||||
}
|
||||
|
||||
// The device fills the canvas from GAP below the text to just past the bottom
|
||||
// edge, so a taller canvas gets a larger device rather than a larger gap. When
|
||||
// that would be wider than the canvas, the device takes the full width and the
|
||||
// text and the whole device sit centred, still GAP apart.
|
||||
function deviceLayout(store, device) {
|
||||
const { capture, bezel } = device;
|
||||
const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE;
|
||||
const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel;
|
||||
const widest = store.width - 2 * MARGIN - 2 * bezel;
|
||||
const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest);
|
||||
const screenHeight = Math.round((screenWidth * capture.height) / capture.width);
|
||||
const scale = screenWidth / capture.width;
|
||||
const width = screenWidth + 2 * bezel;
|
||||
const height = screenHeight + 2 * bezel;
|
||||
const textTop =
|
||||
screenWidth < widest
|
||||
? store.height + BLEED - height - GAP - textHeight
|
||||
: (store.height - height - GAP - textHeight) / 2;
|
||||
const island = device.island && {
|
||||
width: Math.round(device.island.width * scale),
|
||||
height: Math.round(device.island.height * scale),
|
||||
top: Math.round(device.island.top * scale),
|
||||
};
|
||||
return {
|
||||
textTop: Math.round(textTop),
|
||||
left: Math.round((store.width - width) / 2),
|
||||
top: Math.round(textTop + textHeight + GAP),
|
||||
width,
|
||||
height,
|
||||
bezel,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
scale,
|
||||
radius: Math.round(device.radius * scale),
|
||||
island,
|
||||
};
|
||||
}
|
||||
|
||||
function calloutLayout(callout, frame, store) {
|
||||
const [cropX, cropY, cropWidth, cropHeight] = callout.crop;
|
||||
const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth);
|
||||
const width = Math.round(cropWidth * scale);
|
||||
const height = Math.round(cropHeight * scale);
|
||||
const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale;
|
||||
const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale;
|
||||
const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: clamp(centreX - width / 2, store.width - width),
|
||||
top: clamp(centreY - height / 2, store.height - height),
|
||||
};
|
||||
}
|
||||
|
||||
async function resize(source, crop, width, height, file) {
|
||||
let image = sharp(source);
|
||||
if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] });
|
||||
await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file);
|
||||
return pathToFileURL(file).href;
|
||||
}
|
||||
|
||||
// Agent marks scattered behind the first panel, from the homepage's featured
|
||||
// agents. Each entry is [mark, centre x, centre y, size, rotation], with the
|
||||
// centre as a fraction of the canvas and the size as a fraction of its width.
|
||||
const AGENT_MARKS = [
|
||||
["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14],
|
||||
["CodexIcon", 0.89, 0.1, 0.13, 12],
|
||||
["PiIcon", 0.07, 0.34, 0.1, 10],
|
||||
["OmpIcon", 0.93, 0.37, 0.11, -10],
|
||||
["OpenCodeIcon", 0.08, 0.6, 0.11, -8],
|
||||
["CursorIcon", 0.92, 0.63, 0.1, 16],
|
||||
["MuseCodeIcon", 0.07, 0.85, 0.11, 14],
|
||||
["AntigravityIcon", 0.92, 0.88, 0.12, -12],
|
||||
];
|
||||
|
||||
async function loadAgentMarks(work) {
|
||||
const bundle = path.join(work, "agent-icons.mjs");
|
||||
await build({
|
||||
entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")],
|
||||
outfile: bundle,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
jsx: "automatic",
|
||||
});
|
||||
const icons = await import(pathToFileURL(bundle).href);
|
||||
return Object.fromEntries(
|
||||
AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]),
|
||||
);
|
||||
}
|
||||
|
||||
function backdrop(store, marks) {
|
||||
return AGENT_MARKS.map(([name, x, y, size, rotation]) => {
|
||||
const side = Math.round(size * store.width);
|
||||
const left = Math.round(x * store.width - side / 2);
|
||||
const top = Math.round(y * store.height - side / 2);
|
||||
return `<div class="mark" style="left:${left}px;top:${top}px;width:${side}px;height:${side}px;transform:rotate(${rotation}deg)">${marks[name]}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) {
|
||||
const behind = shot.backdrop === "agents" ? backdrop(store, marks) : "";
|
||||
const { island } = frame;
|
||||
const notch = island
|
||||
? `<div class="island" style="top:${island.top}px;width:${island.width}px;height:${island.height}px;margin-left:-${island.width / 2}px;border-radius:${island.height / 2}px"></div>`
|
||||
: "";
|
||||
const lifted = callout
|
||||
? `<img class="callout" src="${calloutImage}" style="left:${callout.left}px;top:${callout.top}px;width:${callout.width}px;height:${callout.height}px">`
|
||||
: "";
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@font-face { font-family: "SF Pro"; src: url("${pathToFileURL(font).href}"); font-weight: 1 1000; }
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
html, body { width: ${store.width}px; height: ${store.height}px; overflow: hidden; background: ${COLORS.canvas}; }
|
||||
body { position: relative; font-family: "SF Pro"; -webkit-font-smoothing: antialiased; }
|
||||
.text { position: absolute; top: ${frame.textTop}px; left: 0; right: 0; margin: 0 auto; width: ${store.textWidth}px; text-align: center; }
|
||||
.mark { position: absolute; color: #fff; opacity: 0.09; filter: grayscale(1) brightness(0) invert(1); }
|
||||
.mark svg { display: block; width: 100%; height: 100%; }
|
||||
h1 { color: ${COLORS.title}; font-size: ${store.titleSize}px; font-weight: 600; line-height: ${TITLE_LINE}; letter-spacing: -0.022em; text-wrap: balance; }
|
||||
p { margin-top: ${BODY_GAP}px; color: ${COLORS.body}; font-size: ${store.bodySize}px; font-weight: 400; line-height: ${BODY_LINE}; letter-spacing: -0.005em; text-wrap: balance; }
|
||||
.device { position: absolute; left: ${frame.left}px; top: ${frame.top}px; width: ${frame.width}px; height: ${frame.height}px; padding: ${frame.bezel}px; border-radius: ${frame.radius + frame.bezel}px; background: #040505; box-shadow: 0 0 0 3px #343b39, 0 0 0 5px #161b1a, 0 60px 140px rgba(0, 0, 0, 0.55); }
|
||||
.screen { position: relative; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; border-radius: ${frame.radius}px; overflow: hidden; }
|
||||
.screen img { display: block; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; }
|
||||
.island { position: absolute; left: 50%; background: #000; }
|
||||
.callout { position: absolute; display: block; border-radius: 36px; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14), 0 40px 100px rgba(0, 0, 0, 0.6); }
|
||||
</style>
|
||||
${behind}
|
||||
<div class="text"><h1>${shot.title}</h1><p>${shot.body}</p></div>
|
||||
<div class="device"><div class="screen"><img src="${screen}">${notch}</div></div>
|
||||
${lifted}`;
|
||||
}
|
||||
|
||||
async function renderStore(browser, name, store, font, marks, work) {
|
||||
const dir = path.join(output, name);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const view = await browser.newPage({
|
||||
viewport: { width: store.width, height: store.height },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const device = DEVICES[store.device];
|
||||
const frame = deviceLayout(store, device);
|
||||
const files = [];
|
||||
for (const [index, scene] of SCENES.entries()) {
|
||||
const stem = `${name}-${scene.id}`;
|
||||
const shot = { ...scene, ...scene[store.device] };
|
||||
const screen = await resize(
|
||||
raw(store.device, shot.capture),
|
||||
null,
|
||||
frame.screenWidth,
|
||||
frame.screenHeight,
|
||||
path.join(work, `${stem}-screen.png`),
|
||||
);
|
||||
const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null;
|
||||
const calloutImage = callout
|
||||
? await resize(
|
||||
raw(store.device, shot.callout.from),
|
||||
shot.callout.crop,
|
||||
callout.width,
|
||||
callout.height,
|
||||
path.join(work, `${stem}-callout.png`),
|
||||
)
|
||||
: null;
|
||||
const html = path.join(work, `${stem}.html`);
|
||||
await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks }));
|
||||
await view.goto(pathToFileURL(html).href);
|
||||
await view.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all(Array.from(document.images, (image) => image.decode()));
|
||||
});
|
||||
await expectLines(view, "h1", 2, `${stem} title`);
|
||||
await expectLines(view, "p", 1, `${stem} subline`);
|
||||
const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`);
|
||||
await sharp(await view.screenshot())
|
||||
.removeAlpha()
|
||||
.png()
|
||||
.toFile(file);
|
||||
files.push(file);
|
||||
console.log(path.relative(repo, file));
|
||||
}
|
||||
await view.close();
|
||||
return files;
|
||||
}
|
||||
|
||||
async function expectLines(view, selector, expected, label) {
|
||||
const lines = await view.$eval(selector, (element) =>
|
||||
Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)),
|
||||
);
|
||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||
}
|
||||
|
||||
async function renderPreview(rows) {
|
||||
const tile = 440;
|
||||
const gap = 40;
|
||||
const resized = [];
|
||||
let top = gap;
|
||||
let width = 0;
|
||||
for (const files of rows) {
|
||||
let left = gap;
|
||||
let rowHeight = 0;
|
||||
for (const file of files) {
|
||||
const meta = await sharp(file).metadata();
|
||||
const height = Math.round((meta.height * tile) / meta.width);
|
||||
const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer();
|
||||
resized.push({ input, left, top });
|
||||
left += tile + gap;
|
||||
rowHeight = Math.max(rowHeight, height);
|
||||
}
|
||||
width = Math.max(width, left);
|
||||
top += rowHeight + gap;
|
||||
}
|
||||
const file = path.join(output, "preview.png");
|
||||
await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } })
|
||||
.composite(resized)
|
||||
.png()
|
||||
.toFile(file);
|
||||
console.log(path.relative(repo, file));
|
||||
}
|
||||
|
||||
const font = findFont();
|
||||
const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-"));
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const marks = await loadAgentMarks(work);
|
||||
const rows = [];
|
||||
for (const [name, store] of Object.entries(STORES)) {
|
||||
rows.push(await renderStore(browser, name, store, font, marks, work));
|
||||
}
|
||||
await renderPreview(rows);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -25,54 +25,8 @@ Reload releases the old runtime before resuming its durable session: an idle pro
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
|
||||
Collection releases the process and preserves the agent record, conversation, relationships, and
|
||||
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
|
||||
host config to disable automatic collection. The host setting applies immediately; config-file edits
|
||||
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
|
||||
started finishes. Re-enabling keeps the existing idle timestamps.
|
||||
|
||||
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
|
||||
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
|
||||
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
|
||||
An idle managed child can still own background work.
|
||||
|
||||
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
|
||||
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
|
||||
check, the baseline decides collection after the same one-hour idle period. Once protected work
|
||||
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
|
||||
|
||||
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
|
||||
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
|
||||
Finish subscriptions expose caller protection through their public capability; the collector never
|
||||
reads their watches or durable records.
|
||||
|
||||
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
|
||||
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
|
||||
after closure starts waits for release and resumes the saved session.
|
||||
|
||||
Provider checks follow the signals their runtimes expose:
|
||||
|
||||
- Claude uses its process-local background roster and pending completion notifications. A fresh
|
||||
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
|
||||
demonstrate support by emitting one. Unknown versions stay resident until then.
|
||||
- Codex queries background terminals for loaded threads, including native children, and protects
|
||||
pending provider requests. App servers without the terminal-list RPC stay resident.
|
||||
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
|
||||
inbox inventories. The shared server remains leased while a session vetoes collection.
|
||||
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
|
||||
subagent signals also protect their parent through the daemon's subagent store.
|
||||
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
|
||||
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
|
||||
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
|
||||
runtimes without either field stay resident. These signals are not an inventory of arbitrary
|
||||
processes.
|
||||
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
|
||||
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
|
||||
prevent collection. This does not inspect processes launched internally by the provider.
|
||||
|
||||
These checks do not discover arbitrary detached processes or work hidden by third-party
|
||||
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
@@ -117,39 +71,9 @@ normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` for explicit reporting or no completion callback.
|
||||
|
||||
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
|
||||
|
||||
The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt
|
||||
crash can lose queued prompts. A durable send receipt remains pending until actual dispatch
|
||||
acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued
|
||||
admission within their original timeout and cancellation scope.
|
||||
|
||||
Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module
|
||||
persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses
|
||||
`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt
|
||||
acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch
|
||||
accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same
|
||||
caller, child, and host into one watch.
|
||||
|
||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
||||
reporting after detachment.
|
||||
|
||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||
retries the captured notification; it does not change the child's reported outcome. Remote host loss
|
||||
follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining
|
||||
outstanding intent. A crash after successful dispatch but before removal commits can repeat the
|
||||
notification. Clean shutdown can also flush an already queued callback while retaining its intent,
|
||||
so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
@@ -288,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -61,81 +61,39 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
font size or another unhandled configuration can otherwise disable image selection
|
||||
until the app process restarts. Rebuild the native app after changing this patch;
|
||||
a Metro reload cannot update it. Remove the patch when the installed
|
||||
`expo-modules-core` includes the fix.
|
||||
|
||||
Run its native regression tests from `packages/app/android` after prebuild:
|
||||
|
||||
```bash
|
||||
./gradlew :expo-modules-core:testDebugUnitTest \
|
||||
--tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
|
||||
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
|
||||
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
|
||||
npm run dev:android # Build/install the debug app and start development
|
||||
npm run dev:ios # Build/install the iOS development app
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
|
||||
`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
|
||||
`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
|
||||
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
|
||||
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
|
||||
watchdog recording does not send duplicate spans to that sink by default. Normal store/release
|
||||
workflows leave diagnostics disabled.
|
||||
|
||||
The older `android`, `android:development`, and `ios` commands remain aliases for development.
|
||||
`android:production` still builds and installs a release through Expo; use `build:apk` when you
|
||||
need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
|
||||
The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
|
||||
replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
|
||||
can leave older Downloads files that the new installation cannot manage.
|
||||
|
||||
Each file identifies its build and session. Events include application-transport frame sizes and
|
||||
message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
|
||||
render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
|
||||
are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
|
||||
already available, otherwise **UTF-16 code units** for text. These are
|
||||
client transport observations, after relay decryption where applicable, not packet arrival times.
|
||||
|
||||
The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
|
||||
A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
|
||||
Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
|
||||
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
|
||||
when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
|
||||
but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
|
||||
|
||||
`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
|
||||
faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
|
||||
measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
|
||||
means the bounded queue overflowed; missing events in that interval must not be treated as absence
|
||||
of activity. Storage failure stops recording rather than blocking the app.
|
||||
|
||||
For an ordinary release APK with system trace markers only:
|
||||
For a production-ID release APK that local Android profiling tools can attach to:
|
||||
|
||||
```bash
|
||||
PASEO_PROFILE_BUILD=1 npm run android:production
|
||||
```
|
||||
|
||||
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
|
||||
they do not produce Downloads files.
|
||||
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
|
||||
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
|
||||
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
|
||||
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
|
||||
|
||||
Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Running on an emulator against a worktree daemon
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
Daemon-owned outbound connections and routed API scope ownership are described in
|
||||
[Connected hosts](connected-hosts.md).
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
@@ -66,9 +63,6 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
|
||||
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
|
||||
these dependency trees with tree-shaking disabled to match unbundled production imports.
|
||||
|
||||
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
|
||||
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
|
||||
|
||||
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
|
||||
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
|
||||
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
|
||||
@@ -77,13 +71,6 @@ server to capability-aware sessions. It deliberately does not use the broad recu
|
||||
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
|
||||
not retain non-Git directories.
|
||||
|
||||
Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store.
|
||||
Workspace descriptors, checkout diff subscriptions, and script metadata read the last known
|
||||
snapshot from that store, so reconnecting clients receive it before refresh completes.
|
||||
Refcounted git watches feed the store through snapshot-updated events; their lifetime controls
|
||||
refresh only. Session cleanup and workspace archive retain the data. Removing the final active
|
||||
workspace for a directory clears it. The store lives in memory and resets on daemon restart.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
@@ -185,9 +172,9 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag
|
||||
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`)
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
|
||||
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Connected hosts
|
||||
|
||||
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
|
||||
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
|
||||
`registrationId` created before B is reachable.
|
||||
|
||||
## Ownership and authority
|
||||
|
||||
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
|
||||
target description. Replacement inputs never come back through a read API. Keep this file private
|
||||
when collecting diagnostics or backing up daemon state.
|
||||
|
||||
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
|
||||
including a user's disabled state. Changed input requires an explicit update. Display labels and
|
||||
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
|
||||
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
|
||||
Disabling retains the opt-out.
|
||||
|
||||
Each enabled registration owns one physical daemon link, shared by status monitoring and every
|
||||
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
|
||||
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
|
||||
physical link principal, then intersects forwarded caller grants with that link's current authority.
|
||||
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
|
||||
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
|
||||
|
||||
A producer that registers a host through a routed API is qualified on B as
|
||||
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
|
||||
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
|
||||
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
|
||||
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
|
||||
|
||||
## Delegating agents
|
||||
|
||||
Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`,
|
||||
`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents`
|
||||
accept `serverId`; omission selects the calling daemon. Remote creation requires a
|
||||
destination workspace and explicit provider/model. Caller filesystem defaults apply only on the
|
||||
calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for
|
||||
finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them.
|
||||
|
||||
The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses
|
||||
the ordinary conversation on the destination's session. The runtime selects among that host's
|
||||
connection candidates, including direct connections and routes discovered through another daemon.
|
||||
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
|
||||
the destination workspace keeps its existing disconnected or reconnecting behavior.
|
||||
|
||||
Finish notifications name the destination server and child agent. The caller's daemon owns durable
|
||||
subscription intent and restores observation through the same routed wait and permission observation
|
||||
used during ordinary operation. Restarting the caller's daemon does not imply that a remote child
|
||||
stopped. Three failed remote observation attempts end the subscription with a notification that the
|
||||
child could not be observed and its status is unknown. This does not prove the host is offline: remote
|
||||
RPC failures also prevent observation. Attempts use capped backoff; a connection still
|
||||
in progress does not spend that budget. A confirmed removed registration reports host unavailability
|
||||
immediately through the same path. Neither notice claims the child finished. Intent remains stored
|
||||
until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The
|
||||
parent decides whether to inspect the child or delegate again.
|
||||
|
||||
A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt
|
||||
response retains intent because the destination may have admitted it; observation then follows the
|
||||
same retry policy. A confirmed missing child reports unavailability.
|
||||
|
||||
Remote creation and local subscription persistence are separate commits. A failed local write reports
|
||||
failure even if the destination already created the child; inspect it before retrying creation.
|
||||
See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment,
|
||||
and duplicate-delivery semantics. Concurrent prompts gain no turn attribution.
|
||||
|
||||
## Connections and recovery
|
||||
|
||||
The shared Node connector owns the target grammar and each connection attempt, including a fresh
|
||||
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
|
||||
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
|
||||
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
|
||||
registrations remain inspectable; a changed destination requires explicit identity replacement.
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
|
||||
within its existing deadline and rejects requests while disconnected. Connection loss clears old
|
||||
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
|
||||
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
|
||||
before retrying a mutation.
|
||||
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
|
||||
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
|
||||
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
|
||||
|
||||
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
|
||||
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
|
||||
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
|
||||
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
|
||||
existing frame limit.
|
||||
|
||||
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
|
||||
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
|
||||
frame size. Awaitable output completes when the physical send completes. An overflowing channel
|
||||
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
|
||||
subscription demand is rebuilt by its owning client on reconnect.
|
||||
|
||||
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
|
||||
[CLI reference](../public-docs/cli.md#connected-hosts), and
|
||||
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
|
||||
@@ -221,6 +221,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -238,8 +244,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
pluginsEnabled: boolean,
|
||||
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
|
||||
features: {
|
||||
dictation: { enabled, stt: SpeechSttConfig },
|
||||
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -311,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a
|
||||
session is created, resumed, imported, or reloaded, so configuration changes affect the next
|
||||
session rather than an already-running one.
|
||||
|
||||
`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen).
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
### Git process limits
|
||||
|
||||
@@ -347,27 +353,44 @@ Environment variables override `config.json`:
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
### Speech configuration
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Each use site owns its provider settings: `features.dictation.stt`,
|
||||
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
|
||||
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
|
||||
it regardless of provider. `providers.local.modelsDir` remains shared storage.
|
||||
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs
|
||||
into OpenAI sites that lack their own values, using endpoint settings before shared
|
||||
settings. A provider-less site becomes local; provider names are lower-cased, and
|
||||
fields ignored by the selected speech provider are discarded. The next save writes only the
|
||||
inline shape. A legacy key with no site to receive it is discarded with a warning
|
||||
that names its path, without logging its value.
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same
|
||||
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
|
||||
and report effective env sources in `envOverrides`. A site patch that changes
|
||||
`provider` replaces the entire object. The same or omitted `provider` merges fields
|
||||
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
|
||||
options from the previous variant.
|
||||
| Env var | Applies to |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -126,8 +126,6 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
@@ -150,20 +148,18 @@ One row off the rail makes the whole card look unconsidered.
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
|
||||
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
@@ -203,7 +199,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
|
||||
@@ -273,26 +273,6 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
|
||||
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
||||
measurements so profiling overhead does not contaminate them.
|
||||
|
||||
For diff layout, two benchmarks share one corpus
|
||||
(`packages/app/src/git/diff-document/benchmark-corpus.ts`: a real source file, a changelog with
|
||||
600-character lines, generated code with ligature punctuation, and JSON). The node bench runs the
|
||||
shared document model with the web and native measurers replaced by counters, so it reports how
|
||||
many strings each backend would have to shape, which is the cost that survives the move to canvas
|
||||
or Skia:
|
||||
|
||||
```bash
|
||||
cd packages/app && npx vitest bench --project unit src/git/diff-document/model.bench.ts
|
||||
```
|
||||
|
||||
The browser benchmark opens the same corpus as a working diff, toggles line wrap from the toolbar,
|
||||
and reports pointer-down to painted latency, the longest main-thread task, and the `DiffDocument`
|
||||
React commit time for cold and warm toggles, then scroll frame times in both modes:
|
||||
|
||||
```bash
|
||||
cd packages/app && PASEO_DIFF_PERF_E2E=1 npx playwright test --project=browser \
|
||||
e2e/browser/diff-performance.spec.ts -g "toggling line wrap"
|
||||
```
|
||||
|
||||
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
||||
That report includes input and composer height changes plus React work grouped into composer,
|
||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||
@@ -562,39 +542,6 @@ patch every build the consumers use: Metro resolves the `react-native` field of
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
## Dependency overrides and workspace tooling
|
||||
|
||||
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
|
||||
`react-native`, …). npm does not apply those overrides to packages it places under a
|
||||
workspace's own `node_modules`. When `expo-module-scripts` was declared in
|
||||
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
|
||||
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
|
||||
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
|
||||
where the overrides apply; workspace scripts still find the binary through the root
|
||||
`node_modules/.bin`.
|
||||
|
||||
## Dependency signature audit
|
||||
|
||||
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
|
||||
signature and, for packages published with provenance, fetches
|
||||
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
|
||||
for a year. An edge that cached it in the seconds before the registry attached the publish
|
||||
attestation keeps serving a document with only the provenance bundle, and npm fails with:
|
||||
|
||||
```
|
||||
npm error code EMISSINGSIGNATUREKEY
|
||||
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
|
||||
```
|
||||
|
||||
The same package's registry signature verified a moment earlier with the same keys, so the
|
||||
key list is intact. The failure names one package, repeats only for that package, and depends
|
||||
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
|
||||
rerun passes only by landing elsewhere.
|
||||
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
|
||||
@@ -11,7 +11,7 @@ contracts.
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
@@ -21,10 +21,7 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
|
||||
close-gesture lifetime. Platform adapters own resizing and inset geometry.
|
||||
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
|
||||
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Sheets
|
||||
|
||||
@@ -33,37 +30,23 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
|
||||
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
|
||||
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
|
||||
|
||||
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
|
||||
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
|
||||
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
|
||||
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
|
||||
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
|
||||
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
|
||||
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
|
||||
|
||||
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
|
||||
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
|
||||
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
|
||||
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
|
||||
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
|
||||
container owns Android Back; the pane never subscribes to it.
|
||||
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
|
||||
sheet through `SheetDragArea`, because the panel scrolls on its own and content panning would fight
|
||||
it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the
|
||||
full-screen glyph beside Close, so the title keeps its width. Compact sheets use the app overlay
|
||||
host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge
|
||||
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
|
||||
supported navigation context without moving their providers. Keep this capability at the workspace
|
||||
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
|
||||
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
|
||||
See
|
||||
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
@@ -73,16 +56,12 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
One Explorer host stays beside the center column across compact overlays and docks. Keep its
|
||||
providers and gesture roots at the same React position when width or appearance changes. The
|
||||
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
|
||||
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
|
||||
Ordinary leaf routes retain the stack appearance policy.
|
||||
The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
never read or modify that width.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching workspace tab in Explorer. Desktop Changes opens
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
|
||||
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
|
||||
@@ -97,42 +76,18 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
|
||||
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab,
|
||||
since a narrow X beside the label is easy to hit by accident; the long-press menu closes it.
|
||||
Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while
|
||||
pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates.
|
||||
The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact
|
||||
layouts omit Diff from it because compact Changes shows each file's diff inline.
|
||||
Web also supports dragging agents and terminals into Explorer.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
|
||||
accept only reordering inside the source pane.
|
||||
does not change workspace focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
|
||||
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
|
||||
overlay; at 600 points and above it reserves width beside the workspace. Native docking keeps its
|
||||
touch resize handle. Full workspace splits remain a separate web-only capability.
|
||||
|
||||
The dock reserves at least 240 points and the workspace 360. App navigation yields when it cannot
|
||||
share the remaining width with both. This keeps a portrait tablet from rendering three unusably
|
||||
narrow columns; widening restores navigation without changing its saved open state.
|
||||
|
||||
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
|
||||
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
|
||||
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
|
||||
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
|
||||
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
|
||||
actual destination before revealing it, including deduplicated targets.
|
||||
|
||||
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
|
||||
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
|
||||
up to ten with its existing inactive TTL.
|
||||
|
||||
PR discovery belongs to the active workspace: split layouts discover while active, and other
|
||||
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
|
||||
content also requests timeline data. Inactive consumers read cached data without request demand.
|
||||
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||
lifecycle.
|
||||
|
||||
## Side pane
|
||||
|
||||
|
||||
@@ -104,24 +104,17 @@ Do not collapse this topology with a catch-all, `getId`, or
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs name a host and agent. Both use
|
||||
`navigateToAgent()`, which reads the agent's current workspace from the store
|
||||
or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
|
||||
the named host, demands its directory, and waits for workspace and layout
|
||||
hydration before opening the tab. Keep the stable agent URL free of a workspace
|
||||
hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
|
||||
constraints.
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
|
||||
When a workspace target cannot open yet, `navigateToWorkspace()` records a
|
||||
pending open in the layout model under that host and workspace key. The owning
|
||||
workspace screen consumes it once after hydration. Archived-workspace recovery
|
||||
reads the same pending target, so startup redirects cannot discard it and a
|
||||
retained screen cannot consume another workspace's request.
|
||||
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||
directly to the workspace with the agent open intent.
|
||||
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||
the agent's workspace from the host, and then opens the agent there.
|
||||
|
||||
Legacy `?open=` links seed this model state at the matched route boundary and
|
||||
then lose the query parameter. Internal navigation does not use the query as a
|
||||
handoff. Read matched route params locally; global search params let retained
|
||||
workspace screens observe requests for other workspaces.
|
||||
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||
|
||||
## Params
|
||||
|
||||
@@ -164,9 +157,7 @@ a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
Navigators keep their identity across appearance changes. `ThemedStack` remounts
|
||||
each ordinary screen's content below the native stack when appearance tokens change.
|
||||
Workspace screens own retained panel lifetimes and update appearance at their
|
||||
leaves through `screenOwnedAppearance` ([Explorer](explorer-sidebar.md)). A
|
||||
each screen's content below the native stack when appearance tokens change; a
|
||||
keyed wrapper above a stack remounts the stack itself, and Android crashes when
|
||||
that happens while a FragmentManager transaction is running, which settings
|
||||
hydration at startup makes likely. A screen that mounts a nested navigator (the
|
||||
|
||||
@@ -115,10 +115,11 @@ quietly relying on:
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** The composer moves with the keyboard through a
|
||||
`KeyboardTranslateView` transform. A portal'd popover is outside that view, so
|
||||
it does not move with the composer unless it renders its own
|
||||
`KeyboardTranslateView` with the same translation.
|
||||
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
|
||||
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
|
||||
translate/padding styles. The composer and chat content must both read that
|
||||
provider-owned value. A portal'd popover is outside the composer tree — it
|
||||
does not get that transform unless you apply it yourself.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
@@ -157,7 +158,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
|
||||
shift hooks, shift style, translate view, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
@@ -255,27 +256,11 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native
|
||||
animation-end events because the controller can retain a nonzero value after
|
||||
the keyboard closes.
|
||||
|
||||
Anchor a portal to content inside a `KeyboardTranslateView` with
|
||||
`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the
|
||||
translation scope is React context and does not cross it (Gotcha 7). It returns:
|
||||
|
||||
- `measureAtRest(element)`: the element's window rect with the keyboard
|
||||
translation left out, or null once the anchor unmounts. Lay the popover out
|
||||
from this rect. It assumes the translate view's parent does not move with the
|
||||
keyboard, which holds for the composer dock.
|
||||
- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so
|
||||
the popover moves with the anchor on the same driver, including centered
|
||||
clearance and disabled translation.
|
||||
- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's
|
||||
height with it; never animate height per frame.
|
||||
|
||||
Do not measure an anchor with `measureInWindow` and correct it by the keyboard
|
||||
shift. On iOS the translate view uses the native driver, and `measureInWindow`
|
||||
includes its transform only when the view happened to re-render since the
|
||||
keyboard moved. Both results were observed, so any correction double-lifts the
|
||||
popover or leaves it behind the keyboard. `measureAtRest` measures through
|
||||
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
|
||||
settles; the composer's resting position changes as the draft grows.
|
||||
Measure portal anchors while the dock is at rest. If a popover opens during
|
||||
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
|
||||
dock's current layout and transform. Snapshot the shift at measurement time and
|
||||
apply only the subsequent shift delta to the animated `bottom`. Adding the full
|
||||
shift moves the portal twice and can place it over the composer controls.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
@@ -288,8 +273,7 @@ not be required to repair the offset.
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it
|
||||
moves with the keyboard (Gotcha 3).
|
||||
1. Measure the anchor with `measureInWindow`.
|
||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||
3. Position with anchor coordinates relative to the host:
|
||||
|
||||
@@ -298,12 +282,10 @@ left = anchorRect.x - hostRect.x;
|
||||
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
||||
```
|
||||
|
||||
Do this before adding any platform offset. The app draws edge-to-edge on Android, so
|
||||
`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate
|
||||
space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor:
|
||||
React Native 0.86 stopped subtracting the status bar from window coordinates under
|
||||
edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu
|
||||
one status bar below its trigger.
|
||||
Do this before adding any platform offset. If anchor and host are both measured
|
||||
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
|
||||
Only add a status-bar offset when the render surface is not measured in the same
|
||||
coordinate system. See `tooltip.tsx` for that separate case.
|
||||
|
||||
## Gotcha 5 — The two-measurement flash
|
||||
|
||||
@@ -363,10 +345,10 @@ track phase.
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
|
||||
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
|
||||
close. This includes native centered dialogs: separate React Native Modals cannot present a
|
||||
root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host
|
||||
and container measurement while forwarding the same modal queue. Compact
|
||||
[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars;
|
||||
wide layouts keep local geometry. That export is
|
||||
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
|
||||
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
|
||||
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
|
||||
portal host and container measurement while the sheet still joins the same stack. That export is
|
||||
what `patches/@gorhom+bottom-sheet+*.patch` adds.
|
||||
|
||||
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
|
||||
@@ -410,19 +392,6 @@ bridges the menu's two contexts; the rest pass `null`.
|
||||
|
||||
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
|
||||
|
||||
## Gotcha 8 — Read safe-area insets where the overlay draws
|
||||
|
||||
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
|
||||
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
|
||||
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
|
||||
gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
@@ -434,8 +403,8 @@ Before you write a new one, ask:
|
||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||
raw window coordinates as local Portal coordinates.
|
||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||
yes, anchor it with `useKeyboardAnchor()` and wrap it in a
|
||||
`KeyboardTranslateView` with the anchor's translation (Gotcha 3).
|
||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||
If no, you can probably skip the transform entirely.
|
||||
5. **Will the panel's content height vary?** If yes, you need both
|
||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||
|
||||
@@ -22,11 +22,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
@@ -34,7 +33,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
|
||||
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# I18n
|
||||
|
||||
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
|
||||
Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
|
||||
## Supported Locales
|
||||
|
||||
|
||||
@@ -52,12 +52,10 @@ is another, with its own icons and hover-revealed actions — but both hand the
|
||||
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
|
||||
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
|
||||
|
||||
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
|
||||
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
|
||||
replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab
|
||||
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
|
||||
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
|
||||
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
|
||||
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
|
||||
layout; a row with none opens its tab.
|
||||
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -103,23 +101,6 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
|
||||
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
|
||||
not grow, and the keyboard comes up over the field you are typing into.
|
||||
|
||||
## A page that fills the sheet
|
||||
|
||||
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
|
||||
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
|
||||
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
|
||||
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
|
||||
|
||||
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
|
||||
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
|
||||
into a fill page around the same `SheetPane` the workspace's own sheets render. See
|
||||
[explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
|
||||
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
|
||||
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
|
||||
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
|
||||
that opened it.
|
||||
|
||||
## Hover intent
|
||||
|
||||
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
|
||||
@@ -208,7 +189,7 @@ its own.
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it. A fill page is the one exception.
|
||||
rarely the height of the page before it.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
|
||||
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
|
||||
|
||||
@@ -32,9 +32,7 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
||||
Native worklets own overlay opacity and pointer events while
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
|
||||
@@ -53,18 +53,7 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
|
||||
tab, and New workspace. It checks empty-content taps and header taps at the cap
|
||||
without changing the input height. Each
|
||||
host also opens command/file autocomplete, the model selector, the attachment
|
||||
menu, and the forge picker from both keyboard states. Autocomplete opens with
|
||||
the keyboard closed, before the keyboard opens, and after it settles. Each time
|
||||
the expected best match must be drawn whole between the header and the composer,
|
||||
above the IME frame, and tapping it there must produce the exact composer text.
|
||||
Accessibility reports rows behind the keyboard too, so
|
||||
`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares
|
||||
bounds. Android leaves the chat and composer out of the accessibility tree while
|
||||
suggestions are open, so the composer bounds come from a snapshot with an empty
|
||||
input in the same keyboard state. The queries are partial names with known best
|
||||
matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts
|
||||
`/release` finds this repository's `release-beta` skill for the first Claude model,
|
||||
and `@app` finds `packages/app`. It then submits a long
|
||||
menu, and the forge picker from both keyboard states. It then submits a long
|
||||
fixture from each host and checks native stream displacement twice: after the
|
||||
response settles, and after returning to the bottom and letting the scrollbar
|
||||
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
|
||||
@@ -75,31 +64,6 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
To rerun only the autocomplete, model selector, attachment menu, and forge picker
|
||||
checks on each host, skipping the chat baseline and the growth, dismissal, and
|
||||
stream scenarios:
|
||||
|
||||
```bash
|
||||
PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android
|
||||
```
|
||||
|
||||
`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same
|
||||
autocomplete checks on iOS for chat, a workspace draft tab, and New workspace,
|
||||
with commands, files, and files under a grown draft. Suggestions open while the
|
||||
keyboard opens (`fill` taps and types at once), after it settles, with it closed,
|
||||
and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert
|
||||
accessibility state and cannot compare bounds. Run it where `agent-device`
|
||||
reaches the simulator, with the development app connected to the fixture host
|
||||
and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
|
||||
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
|
||||
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
|
||||
`.dev/agent-device-artifacts/composer-keyboard-ios`.
|
||||
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
|
||||
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
|
||||
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
|
||||
under an eight-line draft, selection with the keyboard shown and hidden, and
|
||||
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
|
||||
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Notifications
|
||||
|
||||
Use `notification-open` for OS notification taps. It owns delivery, parsing,
|
||||
process-local deduplication, startup readiness, and native acknowledgement.
|
||||
Keep agent and workspace navigation in their existing navigation modules.
|
||||
Import the pure API from `@/notification-open` and the app-shell component from
|
||||
`@/notification-open/react`; senders must not load the React routing runtime.
|
||||
|
||||
Agent payloads name a host and agent. Ignore their workspace hint when opening:
|
||||
the agent can move after a notification is sent. Use `navigateToAgent`, which
|
||||
uses the current store record or the agent route to resolve the workspace from
|
||||
the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
|
||||
|
||||
See [agent targets and deferred opens](expo-router.md#agent-targets) for route
|
||||
ownership and workspace recovery. Notification generation, presence, and push
|
||||
eligibility remain daemon concerns.
|
||||
|
||||
## Native delivery
|
||||
|
||||
Keep the router instance at module scope. Android's `singleTask` Activity can
|
||||
finish on Back while its JS context remains alive. A React ref is lost when the
|
||||
root remounts; process-local handled IDs must survive that remount.
|
||||
|
||||
Clear `lastNotificationResponse` after dispatch. Reading a launch response does
|
||||
not acknowledge it. Without clearing it, a later Activity launch can reopen the
|
||||
old agent.
|
||||
|
||||
With expo-notifications 57, Android also replays its pending response list to
|
||||
new native listeners. Clearing the last response does not drain that list.
|
||||
Deduplication covers listener registration within one JS context. A development
|
||||
JS reload creates another context and can replay a cold-launch response; do not
|
||||
add persisted handled IDs to compensate. Recheck this limitation when upgrading
|
||||
expo-notifications.
|
||||
|
||||
iOS drains pending responses when its delegate first handles them. Push data
|
||||
read by Expo arrives under `userInfo.body`; simulator payloads must match:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
|
||||
"body": {
|
||||
"serverId": "host",
|
||||
"workspaceId": "workspace",
|
||||
"agentId": "agent",
|
||||
"reason": "finished"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
|
||||
An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
|
||||
|
||||
Foreground pushes remain in Notification Center without a banner or sound.
|
||||
This preserves notifications sent with stale presence information.
|
||||
|
||||
## Startup and verification
|
||||
|
||||
Wait for the root navigator, host registry, remembered selection, and startup
|
||||
redirects before handing off a tap. A compact agent route must demand the host's
|
||||
directory itself; its sidebar can be closed for the entire cold launch.
|
||||
|
||||
Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
|
||||
and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
|
||||
workspace's saved layout, since a foreign pinned tab survives navigation.
|
||||
Use the daemon's payload shape on both iOS and Android; a browser event alone
|
||||
does not test native replay or acknowledgement.
|
||||
|
||||
Existing foreign pins belong to the reconciliation fix in
|
||||
[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
|
||||
selection is tracked separately in
|
||||
[#6875](https://github.com/getpaseo/paseo/issues/6875).
|
||||
@@ -31,10 +31,6 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
|
||||
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
|
||||
see [Connected hosts](connected-hosts.md#ownership-and-authority).
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
@@ -86,12 +86,6 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Client translations
|
||||
|
||||
Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime.
|
||||
|
||||
The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations).
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
|
||||
@@ -99,10 +93,6 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
|
||||
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
|
||||
checks cover that registry. Unlisted directories do not load.
|
||||
|
||||
The `worktree-include` built-in copies gitignored files selected by
|
||||
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
|
||||
before setup and agents, through `worktree.create.after`.
|
||||
|
||||
Desktop packaging ships the entire built-in plugin directory as an external resource,
|
||||
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
|
||||
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
|
||||
@@ -250,7 +240,7 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
|
||||
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
|
||||
so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
@@ -267,14 +257,14 @@ Classify every SDK export before adding it. All client entry points and implemen
|
||||
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
|
||||
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
|
||||
|
||||
| Entry | Owns | May depend on |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
|
||||
Server code imports shared helpers from the root and server capabilities from `/server`. Client
|
||||
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
|
||||
@@ -337,10 +327,11 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
The contribution function must return cleanup for resources created by plugin code. Server cleanup
|
||||
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
|
||||
not interrupt host teardown. Client installation retention and disposal follow the public
|
||||
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
|
||||
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
|
||||
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
|
||||
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
|
||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
@@ -385,15 +376,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||
catalog is complete.
|
||||
|
||||
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||
passing the bundle string still costs memory and time once per client lifetime.
|
||||
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||
in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
@@ -440,16 +422,14 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
|
||||
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
|
||||
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
|
||||
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
|
||||
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
||||
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
@@ -512,11 +492,6 @@ Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
the plugin bridge transports that query through the existing provider request/event protocol.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
@@ -581,49 +556,23 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
|
||||
and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML
|
||||
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
|
||||
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
|
||||
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
|
||||
editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place:
|
||||
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
|
||||
make that panel faster instead. Source highlights a large file on native in idle-time slices,
|
||||
the lines around the opening line first.
|
||||
|
||||
The handle checks every write against the version it last read or wrote, so two panels or a panel
|
||||
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
|
||||
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
|
||||
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
|
||||
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
|
||||
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
|
||||
owns the author API and placement rules. Keep presentation policy in this module so another
|
||||
placement can reuse behavior without copying registration or action state. The header overflow
|
||||
and the compact workspace menu share one builder that turns buttons into menu rows and pages; the
|
||||
workspace menu reaches it through `usePluginWorkspaceMenu`.
|
||||
placement can reuse behavior without copying registration or action state.
|
||||
|
||||
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
|
||||
query, and toast providers inside the surface content. A host menu can hold buttons from several
|
||||
installations, so each of its rows and pages resolves its own installation's runtime. Providers
|
||||
only around the trigger do not reach those bodies.
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
|
||||
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
|
||||
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
|
||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||
also run after reconnect; replace your view before applying its subsequent updates. An installation
|
||||
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
|
||||
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
|
||||
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
|
||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
||||
owns every observation created through its API and releases them on unload, including setup failure.
|
||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
|
||||
|
||||
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
|
||||
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
|
||||
|
||||
Two contracts follow from it.
|
||||
|
||||
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
|
||||
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
|
||||
|
||||
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB |
@@ -1,88 +0,0 @@
|
||||
# Relay endpoint security
|
||||
|
||||
The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`.
|
||||
|
||||
Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice.
|
||||
|
||||
## Two modes
|
||||
|
||||
| Mode | Construction | Replay protection |
|
||||
| ----------- | ------------------------------------------------ | -------------------------------- |
|
||||
| `legacy` | X25519 + NaCl `box`, random nonces | none |
|
||||
| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral |
|
||||
|
||||
Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged.
|
||||
|
||||
The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction.
|
||||
|
||||
## Noise mapping
|
||||
|
||||
- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity.
|
||||
- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`.
|
||||
- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`.
|
||||
- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: <base64> } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters.
|
||||
- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: <base64> } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated.
|
||||
- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake.
|
||||
- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies.
|
||||
|
||||
Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon.
|
||||
|
||||
## Records
|
||||
|
||||
Each application frame becomes one record of one or more Noise transport messages:
|
||||
|
||||
```
|
||||
chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
|
||||
wire record = (u16 BE ciphertextLength || ciphertext)+
|
||||
```
|
||||
|
||||
`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`.
|
||||
|
||||
The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this.
|
||||
|
||||
Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated.
|
||||
|
||||
## Retries and failures
|
||||
|
||||
- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored.
|
||||
- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart.
|
||||
- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted.
|
||||
- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`.
|
||||
- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing.
|
||||
- Keys are cleared on close as a best effort.
|
||||
|
||||
## Negotiation and admission
|
||||
|
||||
The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it.
|
||||
|
||||
The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it.
|
||||
|
||||
The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online.
|
||||
|
||||
Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted.
|
||||
|
||||
The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update.
|
||||
|
||||
The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known.
|
||||
|
||||
## Enforcement flag
|
||||
|
||||
`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart.
|
||||
|
||||
- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused.
|
||||
- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake.
|
||||
- Direct and local connections are outside the flag.
|
||||
|
||||
Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date.
|
||||
|
||||
## Known limitation
|
||||
|
||||
While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level.
|
||||
- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule.
|
||||
- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts.
|
||||
- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag.
|
||||
- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker.
|
||||
@@ -62,10 +62,9 @@ release. This applies to both beta and stable releases.
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
||||
push runs target only `main`, and GitHub permits only squash merges. Enable
|
||||
required-check protection for `next`, CI on its pushes, and merge commits for the
|
||||
integration PR. Handle PR
|
||||
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
|
||||
and GitHub permits only squash merges. Enable checks and required-check protection
|
||||
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
@@ -534,14 +533,6 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||
|
||||
## Changelog translations
|
||||
|
||||
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
|
||||
|
||||
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
|
||||
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
@@ -654,7 +645,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
@@ -677,7 +668,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Terminal worker
|
||||
|
||||
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
|
||||
|
||||
## Process model
|
||||
|
||||
```
|
||||
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
|
||||
exits (re-parented to init)
|
||||
```
|
||||
|
||||
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
|
||||
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
|
||||
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
## Record, socket and secret
|
||||
|
||||
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
|
||||
|
||||
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
|
||||
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
|
||||
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
|
||||
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
|
||||
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
|
||||
|
||||
## Protocol versions
|
||||
|
||||
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
|
||||
|
||||
| Worker reports | Daemon does |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| its own version | adopts it |
|
||||
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
|
||||
| two or more older | shuts it down; its terminals close (logged) |
|
||||
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
||||
|
||||
## Running from its own copy
|
||||
|
||||
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
|
||||
|
||||
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
|
||||
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
|
||||
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
|
||||
- Dev (`.ts` source) launches from source and copies nothing.
|
||||
|
||||
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
|
||||
|
||||
## Desktop
|
||||
|
||||
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
|
||||
|
||||
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
|
||||
|
||||
## Known limits
|
||||
|
||||
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
|
||||
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
|
||||
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
|
||||
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
|
||||
@@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures and the standalone Electron browser runner share the developer-toast dismissal helper while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||
|
||||
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
|
||||
The invariants are:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||
> agent establishes the daemon's current tail in one bounded request, with older history reachable
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
@@ -44,21 +44,7 @@ recovery is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
Opening or resuming an agent fetches the latest available turns with a soft target of 40
|
||||
projected items. Backward pages and prompt-jump windows also include whole turns, including
|
||||
all currently available rows of an active run. There is no per-turn cap: a large run can
|
||||
exceed the target. Local history mounting uses the same boundaries so revealing older rows
|
||||
does not change the collapse state of a turn already displayed.
|
||||
|
||||
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
|
||||
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
|
||||
Reading history remains available on older hosts, whose pages can still split turns. Forward
|
||||
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
|
||||
Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
@@ -163,13 +149,12 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
|
||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||
history. Code outside the owner does not distinguish cached and network timelines.
|
||||
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail of complete available turns. The item target is soft;
|
||||
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
|
||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
@@ -253,11 +238,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Every canonical run owns a visible turn, including heartbeats and agent notifications.
|
||||
Notifications delivered during a run stay inside it; batched notifications share the run
|
||||
that starts or accepts them. Received messages retain sender attribution, while lifecycle
|
||||
notifications remain visible events. Collapse, timing and locally mounted history share
|
||||
these boundaries.
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Website translations
|
||||
|
||||
paseo.sh is served in English and Simplified Chinese (`zh-CN`). This doc covers the website in `packages/website`. App UI translations are in [i18n.md](i18n.md).
|
||||
|
||||
## URLs own the language
|
||||
|
||||
- English pages keep their unprefixed paths. Every other language lives under its tag: `/docs/cli` and `/zh-CN/docs/cli` are the same page. `/en/...` is not a page.
|
||||
- The server renders the language in the URL. There is no browser-language detection or redirect, so a shared link always opens in the language it was shared in.
|
||||
- The language selector moves to the same page in the other language and keeps the query and hash. Every internal link on a page carries the page's language, so the choice sticks while the reader navigates. An explicit English URL always renders English.
|
||||
- The choice is not stored. A stored choice could only take effect by redirecting unprefixed URLs, which are the English pages. First-party links into the site carry the reader's language instead: `README.zh-CN.md` links to `/zh-CN/...`.
|
||||
- Routes live under `src/routes/{-$locale}/`. Build internal links with `localizePath` or `useLocalizePath` from `~/i18n`. Links inside Markdown are localized by `DocsMarkdown`. Files, `.md` sources, `llms.txt` and API endpoints keep one path for every language (`isPagePath`).
|
||||
- Give each page's `head` the language-neutral path through `pageMeta` (`src/meta.ts`). It writes the self canonical, the `hreflang` alternates with English as `x-default`, and `og:locale`. The sitemap in `vite.config.ts` lists the same alternates.
|
||||
|
||||
## Where copy lives
|
||||
|
||||
| Content | English | Chinese |
|
||||
| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Interface copy | `src/i18n/resources/en.json` | `src/i18n/resources/zh-CN.json` |
|
||||
| Docs | `public-docs/<page>.md` | `public-docs/<page>.zh-CN.md` |
|
||||
| Blog posts | `posts/<slug>.md` | `posts/<slug>.zh-CN.md` |
|
||||
| Comparison pages | `src/content/alternatives/<slug>.md` | `src/content/alternatives/<slug>.zh-CN.md` |
|
||||
| Legal pages | `legal/<page>.md` | `legal/<page>.zh-CN.md` |
|
||||
| Changelog | `CHANGELOG.md` | `CHANGELOG.zh-CN.md`, see [release.md](release.md#changelog-translations) |
|
||||
| Plugin listings | the plugin's `paseo-plugin.json` and `OVERVIEW.md` | `translations` and `OVERVIEW.zh-CN.md`, see `public-docs/plugins/publishing.md` |
|
||||
|
||||
`en.json` types the keys, so typecheck fails when `zh-CN.json` misses one. A translation file is grouped with its English original by name (`src/markdown-content.ts`). A translation without an English original fails the build.
|
||||
|
||||
Every first-party page has a translation. The changelog and plugin listings are the exceptions: a release or listing without one renders whole in English under a notice. Pages never mix languages paragraph by paragraph. Navigation order, categories and slugs always come from the English file's frontmatter.
|
||||
|
||||
## Editing a translated doc
|
||||
|
||||
Edit the English file and its `.zh-CN.md` in the same PR, and add the `.zh-CN.md` with any new page. Code blocks, commands, flags, config keys and links are copied verbatim, so code-only changes are a copy. Never delete a translation to get around an edit, and never leave one that describes behavior the English no longer has.
|
||||
|
||||
Translated headings pin the English anchor with a trailing `{#id}`, so `/zh-CN/docs/cli#running-agents` lands on the same section as the English link:
|
||||
|
||||
```md
|
||||
## 运行 Agent {#running-agents}
|
||||
|
||||
:::example[回复 Slack 提及 {#answer-a-slack-mention}]
|
||||
```
|
||||
|
||||
When you rename an English heading, change the `{#id}` in the translation to the new slug. `src/content-translations.test.ts` fails when a docs, blog, comparison or legal page has no translation, and when a translation's headings differ from the English in count, level, order or anchor. It cannot tell whether the prose under a heading is current. The `.md` source endpoints serve files verbatim, so `{#id}` shows there as written.
|
||||
|
||||
## Chinese style
|
||||
|
||||
- Keep code, commands, file paths, IDs, brand and product names, and command output in English.
|
||||
- Keep these terms in English: Agent, worktree, daemon, Provider, PR, diff, Composer, Hub, workflow, heartbeat, slug.
|
||||
- Translate these: workspace 工作区, host 主机, relay 中继, plugin 插件, skill 技能, review 审查, schedule 定时任务, orchestration 编排, configuration 配置, prompt 提示词, subagent 子 Agent.
|
||||
- Quote app UI labels with the app's Chinese wording from `packages/app/src/i18n/resources/zh-CN.ts`, such as **设置 → 插件**. Labels the app has not translated stay in English, as do labels in Hub, GitHub, Slack and Discord. Add the English label in parentheses only where readers search for it, such as an error message.
|
||||
- Put a space between Chinese and Latin text or numbers: `启动 Agent`, `共 3 个`.
|
||||
- Bold that ends in full-width punctuation does not render when Chinese text follows it directly: `**注意:**这里` shows the asterisks. Move the punctuation outside the bold (`**注意**:这里`).
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,7 +0,0 @@
|
||||
Fixed
|
||||
- Fixed repeated Android animation exceptions for views that have already unmounted
|
||||
- Fixed the Android animation run loop continuing while the app is in the background
|
||||
- Fixed finished Android chat messages being remeasured during streaming updates
|
||||
- Fixed bottom sheets retaining window-size listeners after closing
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -45,21 +45,6 @@
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
packages = self.packages.${system};
|
||||
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
|
||||
in
|
||||
{
|
||||
license-metadata =
|
||||
assert packages.paseo.meta.license.spdxId == license;
|
||||
assert packages.desktop.meta.license.spdxId == license;
|
||||
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
|
||||
@@ -257,7 +257,7 @@ buildNpmPackage {
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.asl20;
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
|
||||
@@ -148,7 +148,7 @@ buildNpmPackage rec {
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.asl20;
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||