Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 |
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: release-beta
|
name: release-beta
|
||||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta".
|
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Release beta
|
# Release beta
|
||||||
|
|
||||||
Follow the **Beta flow** and **Beta release** completion checklist in `docs/release.md` end-to-end.
|
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||||
|
|
||||||
|
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: release-stable
|
name: release-stable
|
||||||
description: Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable".
|
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Release stable
|
# Release stable
|
||||||
|
|
||||||
Follow the applicable flow and the **Stable release (or promotion)** completion checklist in `docs/release.md` end-to-end.
|
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||||
|
|
||||||
|
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,31 +1,11 @@
|
|||||||
.git
|
.git
|
||||||
.debug.conversations
|
|
||||||
.debug
|
|
||||||
.dev
|
.dev
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
**/.playwright-mcp
|
|
||||||
.paseo
|
.paseo
|
||||||
**/.paseo-provider-history
|
|
||||||
.plans
|
|
||||||
.tasks
|
.tasks
|
||||||
.valknut
|
|
||||||
.claude/settings.local.json
|
|
||||||
**/.claude/settings.local.json
|
|
||||||
.claude/scheduled_tasks.lock
|
|
||||||
.claude/worktrees
|
|
||||||
.wrangler
|
.wrangler
|
||||||
**/.wrangler
|
**/.wrangler
|
||||||
**/.tanstack
|
**/.tanstack
|
||||||
PLAN.md
|
|
||||||
valknut-report.html
|
|
||||||
valknut-report.json
|
|
||||||
.env*
|
|
||||||
**/.env*
|
|
||||||
.dev.vars
|
|
||||||
**/.dev.vars
|
|
||||||
*.pem
|
|
||||||
**/*.pem
|
|
||||||
**/.secrets
|
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/build
|
**/build
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically.
|
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||||
|
|
||||||
Before opening, please:
|
Before opening, please:
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Product discussion on Discord
|
- name: Discord
|
||||||
url: https://discord.gg/jz8T2uahpH
|
url: https://discord.gg/jz8T2uahpH
|
||||||
about: Product discussion, including feature requests
|
about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat.
|
||||||
- name: Product discussion on GitHub
|
|
||||||
url: https://github.com/getpaseo/paseo/discussions
|
|
||||||
about: Product discussion, including feature requests
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Propose a new feature or a change to existing behavior.
|
||||||
|
title: "feat: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
|
||||||
|
|
||||||
|
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: prior-search
|
||||||
|
attributes:
|
||||||
|
label: Prior search
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and discussions, and this isn't already proposed.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What's the problem
|
||||||
|
description: What are you actually trying to do, and why is the current behavior in the way?
|
||||||
|
placeholder: |
|
||||||
|
When I'm doing X, I want to Y, but Paseo currently Z.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: What would solve it
|
||||||
|
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives you considered
|
||||||
|
description: Optional. What else did you try, and why doesn't it work?
|
||||||
@@ -1,34 +1,27 @@
|
|||||||
<!--
|
<!--
|
||||||
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
|
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
|
||||||
|
|
||||||
You MUST read CONTRIBUTING.md before sending a PR.
|
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
### Linked issue
|
### Linked issue
|
||||||
|
|
||||||
Closes #
|
Closes #
|
||||||
|
|
||||||
|
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
|
||||||
|
|
||||||
### Type of change
|
### Type of change
|
||||||
|
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
- [ ] New feature
|
- [ ] New feature (with prior issue + design alignment)
|
||||||
- [ ] Enhancement
|
- [ ] Refactor / code improvement
|
||||||
- [ ] Refactor
|
|
||||||
- [ ] Docs
|
- [ ] Docs
|
||||||
|
|
||||||
### Reasoning
|
### What does this PR do
|
||||||
|
|
||||||
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
|
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
|
||||||
|
|
||||||
### Goals
|
### How did you verify it
|
||||||
|
|
||||||
<!-- A bullet list of what this PR wants to accomplish, include requirements and acceptance criteria. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
|
|
||||||
|
|
||||||
### Non-goals
|
|
||||||
|
|
||||||
<!-- A bullet list of what this PR does not want to accomplish. Add any intentional trade offs taken. This helps lock down the scope of the PR and prevent expanding scope over the intended goal. -->
|
|
||||||
|
|
||||||
### QA
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
|
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
|
||||||
@@ -36,14 +29,15 @@ This is the section I read most carefully. I need to see that *you* tested this,
|
|||||||
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
|
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
|
||||||
- For behavior changes: the actual steps you ran, and what you observed.
|
- For behavior changes: the actual steps you ran, and what you observed.
|
||||||
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
|
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
|
||||||
|
|
||||||
|
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable)
|
- [ ] One focused change. Unrelated cleanups split out.
|
||||||
- [ ] One focused change
|
|
||||||
- [ ] `npm run typecheck` passes
|
- [ ] `npm run typecheck` passes
|
||||||
- [ ] `npm run lint` passes
|
- [ ] `npm run lint` passes
|
||||||
- [ ] `npm run format` passes
|
- [ ] `npm run format` ran (Biome)
|
||||||
- [ ] QA evidence
|
- [ ] UI changes include screenshots or video for every affected platform
|
||||||
- [ ] Tests added or updated where it made sense
|
- [ ] Tests added or updated where it made sense
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
# Pull-request checks are routed by the behavior they prove. Package consumers
|
|
||||||
# do not inherit their dependencies' test suites; main and manual runs execute
|
|
||||||
# every contract regardless of these paths.
|
|
||||||
routing:
|
|
||||||
- ".github/ci-paths.yml"
|
|
||||||
|
|
||||||
workspace:
|
|
||||||
- ".mise.toml"
|
|
||||||
- ".tool-versions"
|
|
||||||
- "package.json"
|
|
||||||
- "package-lock.json"
|
|
||||||
- "patches/**"
|
|
||||||
- "scripts/**"
|
|
||||||
- "tsconfig.json"
|
|
||||||
- "tsconfig.base.json"
|
|
||||||
- "vitest.config.ts"
|
|
||||||
|
|
||||||
ci:
|
|
||||||
- ".github/actions/**"
|
|
||||||
- ".github/workflows/ci.yml"
|
|
||||||
|
|
||||||
format:
|
|
||||||
- ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
|
|
||||||
- ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
|
|
||||||
- "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}"
|
|
||||||
- "packages/expo-two-way-audio/**"
|
|
||||||
|
|
||||||
quality:
|
|
||||||
- "**/*.{cjs,js,json,jsx,mjs,ts,tsx}"
|
|
||||||
- "packages/expo-two-way-audio/**"
|
|
||||||
|
|
||||||
hub:
|
|
||||||
- "packages/cli/src/commands/hub/**"
|
|
||||||
- "packages/server/src/server/hub/**"
|
|
||||||
|
|
||||||
server:
|
|
||||||
- "plugins/**"
|
|
||||||
- "packages/server/**"
|
|
||||||
- "packages/app/e2e/support/fixtures/recording.*"
|
|
||||||
|
|
||||||
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}"
|
|
||||||
- "packages/app/package.json"
|
|
||||||
|
|
||||||
app:
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/expo-two-way-audio/**"
|
|
||||||
|
|
||||||
sdk:
|
|
||||||
- "packages/plugin/**"
|
|
||||||
- "plugin-examples/**"
|
|
||||||
- "public-docs/plugins/**"
|
|
||||||
- "packages/client/**"
|
|
||||||
- "packages/highlight/**"
|
|
||||||
- "packages/protocol/**"
|
|
||||||
|
|
||||||
browser:
|
|
||||||
- "packages/server/src/server/agent/provider-snapshot-manager.ts"
|
|
||||||
- "packages/server/src/server/session/provider/provider-catalog-session.ts"
|
|
||||||
- "packages/client/src/compat/normalize-provider-models.ts"
|
|
||||||
- "packages/protocol/src/client-capabilities.ts"
|
|
||||||
- "packages/server/src/server/agent/provider-registry.ts"
|
|
||||||
- "packages/server/src/server/agent/agent-sdk-types.ts"
|
|
||||||
- "packages/server/src/server/agent/providers/codex-app-server-agent.ts"
|
|
||||||
- "packages/server/src/server/agent/providers/claude/agent.ts"
|
|
||||||
- "packages/server/src/server/agent/plugin-provider.ts"
|
|
||||||
- "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts"
|
|
||||||
- "packages/server/src/executable-resolution/**"
|
|
||||||
- "packages/plugin/src/server/provider.ts"
|
|
||||||
- "packages/app/src/!(desktop)/**"
|
|
||||||
- "packages/app/e2e/browser/**"
|
|
||||||
- "packages/app/e2e/support/**"
|
|
||||||
- "packages/app/assets/**"
|
|
||||||
- "packages/app/public/**"
|
|
||||||
- "packages/app/index.ts"
|
|
||||||
- "packages/app/*config.{cjs,js,ts}"
|
|
||||||
- "packages/app/package.json"
|
|
||||||
|
|
||||||
relay:
|
|
||||||
- "packages/relay/**"
|
|
||||||
|
|
||||||
cli:
|
|
||||||
- "packages/cli/**"
|
|
||||||
@@ -20,50 +20,7 @@ env:
|
|||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ensure-release:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: scripts
|
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
|
||||||
|
|
||||||
- name: Resolve release tag
|
|
||||||
shell: bash
|
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Ensure GitHub release exists
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_args=(
|
|
||||||
release create "$RELEASE_TAG"
|
|
||||||
--repo "${{ github.repository }}"
|
|
||||||
--title "Paseo $RELEASE_TAG"
|
|
||||||
--notes ""
|
|
||||||
--draft
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
release_args+=(--prerelease)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! gh "${release_args[@]}"; then
|
|
||||||
echo "Release creation raced with another workflow; continuing."
|
|
||||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
publish-android-apk:
|
publish-android-apk:
|
||||||
needs: [ensure-release]
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -79,6 +36,32 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Ensure GitHub release exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_args=(
|
||||||
|
release create "$RELEASE_TAG"
|
||||||
|
--repo "${{ github.repository }}"
|
||||||
|
--title "Paseo $RELEASE_TAG"
|
||||||
|
--notes ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
release_args+=(--prerelease)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh "${release_args[@]}"; then
|
||||||
|
echo "Release creation raced with another workflow; continuing."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -88,7 +71,7 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -146,5 +129,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||||
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# A newer push to the same ref cancels the run still in progress, main included.
|
|
||||||
# A full run costs about 200 runner-minutes against the org's 20 concurrent
|
|
||||||
# jobs, and a superseded main run only answers a question the newer commit
|
|
||||||
# answers again.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||||
@@ -22,47 +18,12 @@ env:
|
|||||||
ONNXRUNTIME_NODE_INSTALL: skip
|
ONNXRUNTIME_NODE_INSTALL: skip
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
format:
|
||||||
name: changes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
outputs:
|
|
||||||
full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }}
|
|
||||||
format: ${{ steps.filter.outputs.format }}
|
|
||||||
quality: ${{ steps.filter.outputs.quality }}
|
|
||||||
hub: ${{ steps.filter.outputs.hub }}
|
|
||||||
server: ${{ steps.filter.outputs.server }}
|
|
||||||
desktop: ${{ steps.filter.outputs.desktop }}
|
|
||||||
app: ${{ steps.filter.outputs.app }}
|
|
||||||
sdk: ${{ steps.filter.outputs.sdk }}
|
|
||||||
browser: ${{ steps.filter.outputs.browser }}
|
|
||||||
relay: ${{ steps.filter.outputs.relay }}
|
|
||||||
cli: ${{ steps.filter.outputs.cli }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Detect affected CI contracts
|
|
||||||
id: filter
|
|
||||||
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
|
|
||||||
with:
|
|
||||||
filters: .github/ci-paths.yml
|
|
||||||
|
|
||||||
- name: Validate CI contracts
|
|
||||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
|
||||||
|
|
||||||
quality:
|
|
||||||
name: quality
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# This job never executes Electron. Skipping the hosted binary avoids
|
||||||
|
# unrelated npm ci failures when Electron's CDN returns 504.
|
||||||
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -72,73 +33,39 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
id: install
|
run: npm ci
|
||||||
run: node scripts/npm-retry.mjs ci
|
|
||||||
|
|
||||||
# Explicit status checks keep independent domains running after a failure.
|
|
||||||
# A failed step still fails quality; no continue-on-error or success shim.
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
run: npx oxfmt --check .
|
||||||
run: npm run format:check
|
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Lint lockfile
|
- name: Lint lockfile
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
|
||||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||||
|
|
||||||
- name: Verify dependency signatures
|
- name: Verify dependency signatures
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
|
||||||
run: npm audit signatures
|
run: npm audit signatures
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Test lint rules
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
|
||||||
run: node --test scripts/oxlint/paseo-plugin.test.mjs
|
|
||||||
|
|
||||||
- name: Build relay
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
|
||||||
run: npm run build:relay
|
|
||||||
|
|
||||||
- name: Run relay tests
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
|
||||||
run: npm run test --workspace=@getpaseo/relay
|
|
||||||
|
|
||||||
- name: Build client dependencies
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
run: npm run build:relay && npm run build:client
|
|
||||||
|
|
||||||
- name: Build plugin SDK
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
run: npm run build:plugin
|
|
||||||
|
|
||||||
- name: Run plugin SDK tests
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
run: npm run test --workspace=@getpaseo/plugin
|
|
||||||
|
|
||||||
- name: Run protocol tests
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
run: npm run test --workspace=@getpaseo/protocol
|
|
||||||
|
|
||||||
- name: Run client tests
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
run: npm run test --workspace=@getpaseo/client
|
|
||||||
|
|
||||||
- name: Run highlight tests
|
|
||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
|
||||||
run: npm run test --workspace=@getpaseo/highlight
|
|
||||||
|
|
||||||
- name: Typecheck client examples
|
|
||||||
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:
|
typecheck:
|
||||||
name: typecheck
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
env:
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -148,174 +75,65 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Verify dependency patch recovery
|
|
||||||
run: node --test scripts/postinstall-patches.test.mjs
|
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
|
|
||||||
- name: Verify built-in plugins from built output
|
|
||||||
run: node --test scripts/builtin-plugins-dist.test.mjs
|
|
||||||
|
|
||||||
- name: Typecheck all packages
|
- name: Typecheck all packages
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Run website tests
|
|
||||||
run: npx vitest run packages/website/src --bail=1
|
|
||||||
|
|
||||||
- name: Install website test browsers
|
|
||||||
run: npx playwright install --with-deps chromium webkit
|
|
||||||
|
|
||||||
- name: Verify website mockups
|
|
||||||
run: npm run test:e2e --workspace=@getpaseo/website
|
|
||||||
|
|
||||||
- name: Verify public package contents
|
- name: Verify public package contents
|
||||||
run: |
|
run: |
|
||||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
||||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
|
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
|
||||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||||
|
|
||||||
server-tests-ubuntu:
|
server-tests:
|
||||||
name: server-tests (ubuntu-latest)
|
strategy:
|
||||||
needs: changes
|
fail-fast: false
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
matrix:
|
||||||
runs-on: ubuntu-latest
|
os: [ubuntu-latest, windows-latest]
|
||||||
steps:
|
runs-on: ${{ matrix.os }}
|
||||||
- &server_capacity
|
name: server-tests (${{ matrix.os }})
|
||||||
name: Log runner capacity
|
env:
|
||||||
run: |
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
|
||||||
|
|
||||||
- &server_checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- &server_node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- &server_fetch
|
|
||||||
name: Fetch origin/main (worktree tests)
|
|
||||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
|
||||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
|
||||||
|
|
||||||
- &server_install
|
|
||||||
name: Install dependencies
|
|
||||||
run: node scripts/npm-retry.mjs ci
|
|
||||||
|
|
||||||
- &server_agent_clis
|
|
||||||
name: Install agent CLIs for provider tests
|
|
||||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
|
||||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
|
||||||
|
|
||||||
- &server_build
|
|
||||||
name: Build server dependencies
|
|
||||||
run: npm run build:server-deps
|
|
||||||
|
|
||||||
- name: Run server tests
|
|
||||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
|
||||||
run: npm run test --workspace=@getpaseo/server
|
|
||||||
env: &server_test_env
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
||||||
|
|
||||||
- &server_plugin_tests
|
|
||||||
name: Run built-in plugin tests
|
|
||||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
|
||||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
|
||||||
|
|
||||||
- &server_hub_contract
|
|
||||||
name: Run Hub CLI/daemon contract
|
|
||||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
|
||||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
|
||||||
|
|
||||||
server-tests-windows:
|
|
||||||
name: server-tests (windows-latest)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- *server_capacity
|
|
||||||
- *server_checkout
|
|
||||||
- *server_node
|
|
||||||
- *server_fetch
|
|
||||||
- *server_install
|
|
||||||
- *server_agent_clis
|
|
||||||
- *server_build
|
|
||||||
|
|
||||||
- name: Run server unit tests
|
|
||||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
|
||||||
run: npm run test:unit --workspace=@getpaseo/server
|
|
||||||
env: *server_test_env
|
|
||||||
|
|
||||||
- *server_plugin_tests
|
|
||||||
- *server_hub_contract
|
|
||||||
|
|
||||||
# Keep the serial integration chain off the Windows unit critical path.
|
|
||||||
server-integration-windows:
|
|
||||||
name: server-integration (windows-latest)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- *server_capacity
|
|
||||||
- *server_checkout
|
|
||||||
- *server_node
|
|
||||||
- *server_fetch
|
|
||||||
- *server_install
|
|
||||||
- *server_agent_clis
|
|
||||||
- *server_build
|
|
||||||
|
|
||||||
- name: Run server integration tests
|
|
||||||
run: npm run test:integration --workspace=@getpaseo/server
|
|
||||||
env: *server_test_env
|
|
||||||
|
|
||||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
|
||||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
|
||||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
|
||||||
# bound runtime.
|
|
||||||
server-tests-macos:
|
|
||||||
name: server-tests (macos-14, file observation)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
|
||||||
runs-on: macos-14
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Fetch origin/main (worktree tests)
|
||||||
|
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install agent CLIs for provider tests
|
||||||
|
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||||
|
|
||||||
- name: Build server dependencies
|
- name: Build server dependencies
|
||||||
run: npm run build:server-deps
|
run: npm run build:server-deps
|
||||||
|
|
||||||
- name: Run file observation tests
|
- name: Run server tests
|
||||||
run: |
|
run: npm run test --workspace=@getpaseo/server
|
||||||
npm run test:unit --workspace=@getpaseo/server -- \
|
env:
|
||||||
src/server/file-observer \
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
src/server/workspace-git-service.observation.test.ts \
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
src/server/workspace-git-service.observation.integration.test.ts \
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
--bail=1
|
|
||||||
|
|
||||||
desktop-tests-ubuntu:
|
desktop-tests:
|
||||||
name: desktop-tests (ubuntu-latest)
|
strategy:
|
||||||
needs: changes
|
fail-fast: false
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
|
matrix:
|
||||||
runs-on: ubuntu-24.04
|
os: [ubuntu-latest, windows-latest]
|
||||||
timeout-minutes: 30
|
runs-on: ${{ matrix.os }}
|
||||||
permissions:
|
steps:
|
||||||
contents: read
|
|
||||||
steps: &desktop_test_steps
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -323,8 +141,35 @@ jobs:
|
|||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies with retry
|
- name: Install dependencies with Electron retry
|
||||||
run: node scripts/npm-retry.mjs ci
|
if: runner.os != 'Windows'
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if npm ci; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit_code=$?
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
exit $exit_code
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Install dependencies with Electron retry
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
npm ci
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if ($attempt -eq 3) {
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds (20 * $attempt)
|
||||||
|
}
|
||||||
|
|
||||||
- name: Build server stack
|
- name: Build server stack
|
||||||
run: npm run build:server
|
run: npm run build:server
|
||||||
@@ -332,71 +177,10 @@ jobs:
|
|||||||
- name: Run desktop tests
|
- name: Run desktop tests
|
||||||
run: npm run test --workspace=@getpaseo/desktop
|
run: npm run test --workspace=@getpaseo/desktop
|
||||||
|
|
||||||
- name: Install virtual display
|
|
||||||
if: ${{ runner.os == 'Linux' }}
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
|
|
||||||
|
|
||||||
- name: Install Playwright browser
|
|
||||||
if: ${{ runner.os == 'Linux' }}
|
|
||||||
run: npx playwright install chromium
|
|
||||||
|
|
||||||
- name: Verify Windows daemon process control
|
|
||||||
if: ${{ runner.os == 'Windows' }}
|
|
||||||
run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home"
|
|
||||||
|
|
||||||
- name: Run desktop lifecycle E2E (Linux)
|
|
||||||
if: ${{ runner.os == 'Linux' }}
|
|
||||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
|
||||||
env:
|
|
||||||
PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle
|
|
||||||
|
|
||||||
- name: Upload desktop lifecycle screenshots
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ !cancelled() && runner.os == 'Linux' }}
|
|
||||||
with:
|
|
||||||
name: desktop-lifecycle
|
|
||||||
path: ${{ runner.temp }}/desktop-lifecycle
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Run desktop lifecycle E2E (Windows)
|
|
||||||
if: ${{ runner.os == 'Windows' }}
|
|
||||||
run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Run desktop renderer E2E
|
|
||||||
if: ${{ runner.os == 'Linux' }}
|
|
||||||
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Run desktop browser E2E
|
|
||||||
if: ${{ runner.os == 'Linux' }}
|
|
||||||
run: npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
|
|
||||||
env:
|
|
||||||
PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR: ${{ runner.temp }}/desktop-browser-e2e
|
|
||||||
|
|
||||||
- name: Upload desktop browser diagnostics
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ failure() && runner.os == 'Linux' }}
|
|
||||||
with:
|
|
||||||
name: desktop-browser-e2e
|
|
||||||
path: ${{ runner.temp }}/desktop-browser-e2e
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
desktop-tests-windows:
|
|
||||||
name: desktop-tests (windows-latest)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
|
|
||||||
runs-on: windows-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps: *desktop_test_steps
|
|
||||||
|
|
||||||
app-tests:
|
app-tests:
|
||||||
name: app-tests
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
env:
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -406,7 +190,18 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies with retry
|
- name: Install dependencies with retry
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if npm ci; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit_code=$?
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
exit $exit_code
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -418,144 +213,11 @@ jobs:
|
|||||||
- name: Run app unit tests
|
- name: Run app unit tests
|
||||||
run: npm run test --workspace=@getpaseo/app
|
run: npm run test --workspace=@getpaseo/app
|
||||||
|
|
||||||
playwright-1:
|
sdk-tests:
|
||||||
name: playwright (shard 1/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "1/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "1"
|
|
||||||
steps: &playwright_test_steps
|
|
||||||
- name: Log runner capacity
|
|
||||||
run: |
|
|
||||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies with retry
|
|
||||||
run: node scripts/npm-retry.mjs ci
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: npx playwright install chromium
|
|
||||||
|
|
||||||
- name: Build server stack
|
|
||||||
run: npm run build:server
|
|
||||||
|
|
||||||
- name: Build native app dependency
|
|
||||||
run: npm run build --workspace=@getpaseo/expo-two-way-audio
|
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
|
||||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
|
||||||
env:
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
E2E_WORKERS: "2"
|
|
||||||
# File groups are weighted by observed reporter durations.
|
|
||||||
# Share of the test list per shard, rebalanced from the Playwright step
|
|
||||||
# minutes of five eight-shard runs so the slowest and fastest shards meet.
|
|
||||||
PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: playwright-results-${{ env.PLAYWRIGHT_ARTIFACT }}
|
|
||||||
path: |
|
|
||||||
packages/app/test-results/
|
|
||||||
packages/app/playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
playwright-2:
|
|
||||||
name: playwright (shard 2/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "2/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "2"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
playwright-3:
|
|
||||||
name: playwright (shard 3/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "3/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "3"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
playwright-4:
|
|
||||||
name: playwright (shard 4/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "4/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "4"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
playwright-5:
|
|
||||||
name: playwright (shard 5/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "5/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "5"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
playwright-6:
|
|
||||||
name: playwright (shard 6/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "6/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "6"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
playwright-7:
|
|
||||||
name: playwright (shard 7/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "7/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "7"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
playwright-8:
|
|
||||||
name: playwright (shard 8/8)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_SHARD: "8/8"
|
|
||||||
PLAYWRIGHT_ARTIFACT: "8"
|
|
||||||
steps: *playwright_test_steps
|
|
||||||
|
|
||||||
cli-tests-1:
|
|
||||||
name: cli-tests (shard 1/3)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
PASEO_DICTATION_ENABLED: "0"
|
steps:
|
||||||
PASEO_VOICE_MODE_ENABLED: "0"
|
|
||||||
PASEO_CLI_TEST_SHARD: "1"
|
|
||||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
|
||||||
steps: &cli_test_steps
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -564,43 +226,128 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build client dependencies
|
||||||
|
run: npm run build:client
|
||||||
|
|
||||||
|
- name: Run protocol tests
|
||||||
|
run: npm run test --workspace=@getpaseo/protocol
|
||||||
|
|
||||||
|
- name: Run client tests
|
||||||
|
run: npm run test --workspace=@getpaseo/client
|
||||||
|
|
||||||
|
- name: Typecheck client examples
|
||||||
|
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [1, 2, 3, 4]
|
||||||
|
name: playwright (shard ${{ matrix.shard }}/4)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies with retry
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if npm ci; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit_code=$?
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
exit $exit_code
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: npx playwright install chromium
|
||||||
|
|
||||||
|
- name: Build app dependencies
|
||||||
|
run: npm run build:app-deps
|
||||||
|
|
||||||
|
- name: Build server stack
|
||||||
|
run: npm run build:server
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
# The local runner builds its dependencies and shards its own E2E files.
|
- name: Run Playwright E2E tests
|
||||||
- name: Run CLI E2E tests
|
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||||
run: npm run test:local --workspace=@getpaseo/cli
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
# Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the
|
- name: Upload test artifacts
|
||||||
# shortest E2E shard, after its runner has built the server stack.
|
uses: actions/upload-artifact@v4
|
||||||
- name: Run CLI source tests
|
if: failure()
|
||||||
if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }}
|
with:
|
||||||
run: npm run test:unit --workspace=@getpaseo/cli
|
name: playwright-results-${{ matrix.shard }}
|
||||||
|
path: |
|
||||||
|
packages/app/test-results/
|
||||||
|
packages/app/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
cli-tests-2:
|
relay-tests:
|
||||||
name: cli-tests (shard 2/3)
|
|
||||||
needs: changes
|
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
PASEO_DICTATION_ENABLED: "0"
|
steps:
|
||||||
PASEO_VOICE_MODE_ENABLED: "0"
|
- uses: actions/checkout@v4
|
||||||
PASEO_CLI_TEST_SHARD: "2"
|
|
||||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
|
||||||
steps: *cli_test_steps
|
|
||||||
|
|
||||||
cli-tests-3:
|
- uses: actions/setup-node@v4
|
||||||
name: cli-tests (shard 3/3)
|
with:
|
||||||
needs: changes
|
node-version: "22"
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build relay
|
||||||
|
run: npm run build:relay
|
||||||
|
|
||||||
|
- name: Run relay tests
|
||||||
|
run: npm run test --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
cli-tests:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [1, 2, 3]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||||
env:
|
env:
|
||||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||||
PASEO_DICTATION_ENABLED: "0"
|
steps:
|
||||||
PASEO_VOICE_MODE_ENABLED: "0"
|
- uses: actions/checkout@v4
|
||||||
PASEO_CLI_TEST_SHARD: "3"
|
|
||||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
- uses: actions/setup-node@v4
|
||||||
steps: *cli_test_steps
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install agent CLIs for provider tests
|
||||||
|
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
|
- name: Run CLI tests
|
||||||
|
run: npm run test --workspace=@getpaseo/cli
|
||||||
|
env:
|
||||||
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||||
|
PASEO_DICTATION_ENABLED: "0"
|
||||||
|
PASEO_VOICE_MODE_ENABLED: "0"
|
||||||
|
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
|
||||||
|
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build app dependencies
|
- name: Build app dependencies
|
||||||
run: npm run build:app-deps
|
run: npm run build:app-deps
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
name: Deploy Relay
|
name: Deploy Relay
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
push:
|
||||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "packages/relay/**"
|
||||||
|
- ".github/workflows/deploy-relay.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,7 +21,7 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/relay --include-workspace-root
|
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck --workspace=@getpaseo/relay
|
run: npm run typecheck --workspace=@getpaseo/relay
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
- "CHANGELOG.md"
|
- "CHANGELOG.md"
|
||||||
- "public-docs/**"
|
- "public-docs/**"
|
||||||
- "packages/website/**"
|
- "packages/website/**"
|
||||||
- "packages/protocol/**"
|
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- "package-lock.json"
|
- "package-lock.json"
|
||||||
- "patches/**"
|
- "patches/**"
|
||||||
@@ -30,10 +29,7 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root
|
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||||
|
|
||||||
- name: Build protocol
|
|
||||||
run: npm run build:protocol
|
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck --workspace=@getpaseo/website
|
run: npm run typecheck --workspace=@getpaseo/website
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
name: Desktop Packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
# 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:
|
|
||||||
paths:
|
|
||||||
- "packages/desktop/**"
|
|
||||||
- "plugins/**"
|
|
||||||
- "nix/**"
|
|
||||||
- ".github/workflows/desktop-packages.yml"
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: desktop-packages-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
ONNXRUNTIME_NODE_INSTALL: skip
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
linux:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: node scripts/npm-retry.mjs ci
|
|
||||||
|
|
||||||
- name: Install package smoke dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
|
||||||
|
|
||||||
- name: Build Linux desktop artifacts
|
|
||||||
# Smoke package contents and installation without spending minutes on xz compression.
|
|
||||||
run: |
|
|
||||||
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
|
|
||||||
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
|
|
||||||
env:
|
|
||||||
EP_GH_IGNORE_TIME: true
|
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
|
||||||
|
|
||||||
- name: Smoke installed artifacts with restricted user namespaces
|
|
||||||
run: |
|
|
||||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
|
||||||
if unshare --user --map-root-user --net true; then
|
|
||||||
echo "Expected the launching user's namespace probe to be denied"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sudo apt-get install -y ./packages/desktop/release/*.deb
|
|
||||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
|
|
||||||
env:
|
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
|
|
||||||
|
|
||||||
- name: Smoke sandbox-preserving portable launches
|
|
||||||
run: |
|
|
||||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
|
||||||
unshare --user --map-root-user --net true
|
|
||||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
|
|
||||||
env:
|
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
|
|
||||||
|
|
||||||
- name: Smoke installed RPM with restricted user namespaces
|
|
||||||
run: |
|
|
||||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
|
||||||
sudo dpkg --remove paseo
|
|
||||||
# Ubuntu supplies the runtime libraries under Debian package names.
|
|
||||||
# RPM still performs the real payload installation and postinstall.
|
|
||||||
sudo rpm --install --nodeps packages/desktop/release/*.rpm
|
|
||||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
|
|
||||||
env:
|
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
|
|
||||||
|
|
||||||
- name: Upload packaged smoke diagnostics
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
with:
|
|
||||||
name: desktop-packaged-smoke-linux-x64
|
|
||||||
path: ${{ runner.temp }}/desktop-smoke
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||||
else
|
else
|
||||||
prerelease_flag=""
|
prerelease_flag=""
|
||||||
@@ -86,10 +86,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--notes "" \
|
--notes "" \
|
||||||
--draft \
|
|
||||||
$prerelease_flag || {
|
$prerelease_flag || {
|
||||||
echo "Release creation raced with another workflow; continuing."
|
echo "Release creation raced with another workflow; continuing."
|
||||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
|
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -102,10 +100,8 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- runner: macos-14
|
- runner: macos-14
|
||||||
electron_arch: arm64
|
electron_arch: arm64
|
||||||
node_heap_mb: 4096
|
|
||||||
- runner: macos-15-intel
|
- runner: macos-15-intel
|
||||||
electron_arch: x64
|
electron_arch: x64
|
||||||
node_heap_mb: 8192
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -131,7 +127,7 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -162,55 +158,13 @@ jobs:
|
|||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
|
||||||
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
|
|
||||||
# the last module in the web bundle. Node's default heap is sized from
|
|
||||||
# runner RAM, which is not enough on the Intel runner.
|
|
||||||
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
npm run build:desktop "${build_args[@]}"
|
npm run build:desktop "${build_args[@]}"
|
||||||
|
|
||||||
- name: Upload packaged smoke diagnostics
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: desktop-smoke-macos-${{ matrix.electron_arch }}
|
|
||||||
path: ${{ runner.temp }}/desktop-smoke
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Upload desktop artifacts to release
|
- name: Upload desktop artifacts to release
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -219,8 +173,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
files=()
|
||||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
while IFS= read -r -d '' f; do
|
||||||
|
files+=("$f")
|
||||||
|
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||||
|
if (( ${#files[@]} == 0 )); then
|
||||||
|
echo "::error::No release artifacts found in $release_dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
- name: Upload desktop artifacts to workflow
|
- name: Upload desktop artifacts to workflow
|
||||||
if: env.SHOULD_PUBLISH != 'true'
|
if: env.SHOULD_PUBLISH != 'true'
|
||||||
@@ -266,7 +227,7 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -287,7 +248,7 @@ jobs:
|
|||||||
NODE
|
NODE
|
||||||
|
|
||||||
- name: Install Linux smoke display
|
- name: Install Linux smoke display
|
||||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -295,7 +256,6 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
EP_GH_IGNORE_TIME: true
|
EP_GH_IGNORE_TIME: true
|
||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
build_args=(-- --publish never --linux --x64)
|
build_args=(-- --publish never --linux --x64)
|
||||||
@@ -303,15 +263,6 @@ jobs:
|
|||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
npm run build:desktop "${build_args[@]}"
|
npm run build:desktop "${build_args[@]}"
|
||||||
|
|
||||||
- name: Upload packaged smoke diagnostics
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: desktop-smoke-linux-x64
|
|
||||||
path: ${{ runner.temp }}/desktop-smoke
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Upload desktop artifacts to release
|
- name: Upload desktop artifacts to release
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -320,8 +271,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
files=()
|
||||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
while IFS= read -r -d '' f; do
|
||||||
|
files+=("$f")
|
||||||
|
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||||
|
if (( ${#files[@]} == 0 )); then
|
||||||
|
echo "::error::No release artifacts found in $release_dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
- name: Upload desktop artifacts to workflow
|
- name: Upload desktop artifacts to workflow
|
||||||
if: env.SHOULD_PUBLISH != 'true'
|
if: env.SHOULD_PUBLISH != 'true'
|
||||||
@@ -367,7 +325,19 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Windows arm64 native search packages
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
|
||||||
|
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
|
||||||
|
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
|
||||||
|
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
|
||||||
|
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -393,7 +363,6 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
EP_GH_IGNORE_TIME: true
|
EP_GH_IGNORE_TIME: true
|
||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
build_args=(-- --publish never --win --x64 --arm64)
|
build_args=(-- --publish never --win --x64 --arm64)
|
||||||
@@ -401,15 +370,6 @@ jobs:
|
|||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
npm run build:desktop "${build_args[@]}"
|
npm run build:desktop "${build_args[@]}"
|
||||||
|
|
||||||
- name: Upload packaged smoke diagnostics
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: desktop-smoke-windows-x64
|
|
||||||
path: ${{ runner.temp }}/desktop-smoke
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Upload desktop artifacts to release
|
- name: Upload desktop artifacts to release
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -418,8 +378,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
files=()
|
||||||
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
|
while IFS= read -r -d '' f; do
|
||||||
|
files+=("$f")
|
||||||
|
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||||
|
if (( ${#files[@]} == 0 )); then
|
||||||
|
echo "::error::No release artifacts found in $release_dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
- name: Upload desktop artifacts to workflow
|
- name: Upload desktop artifacts to workflow
|
||||||
if: env.SHOULD_PUBLISH != 'true'
|
if: env.SHOULD_PUBLISH != 'true'
|
||||||
@@ -439,7 +406,7 @@ jobs:
|
|||||||
|
|
||||||
finalize-rollout:
|
finalize-rollout:
|
||||||
needs: [publish-macos, publish-linux, publish-windows]
|
needs: [publish-macos, publish-linux, publish-windows]
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -470,7 +437,7 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -495,7 +462,7 @@ jobs:
|
|||||||
name: windows-manifest
|
name: windows-manifest
|
||||||
path: release-manifests/windows-manifest
|
path: release-manifests/windows-manifest
|
||||||
|
|
||||||
- name: Assemble manifests and publish release
|
- name: Assemble and upload stamped manifests
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -506,33 +473,6 @@ jobs:
|
|||||||
manifests_dir="$PWD/final"
|
manifests_dir="$PWD/final"
|
||||||
mkdir -p "$manifests_dir"
|
mkdir -p "$manifests_dir"
|
||||||
|
|
||||||
expected_manifests=(
|
|
||||||
"${RELEASE_CHANNEL}-mac.yml"
|
|
||||||
"${RELEASE_CHANNEL}-linux.yml"
|
|
||||||
"${RELEASE_CHANNEL}.yml"
|
|
||||||
)
|
|
||||||
release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)"
|
|
||||||
existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
|
|
||||||
for manifest_name in "${expected_manifests[@]}"; do
|
|
||||||
if grep -Fxq "$manifest_name" <<< "$existing_assets"; then
|
|
||||||
case "$manifest_name" in
|
|
||||||
"${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;;
|
|
||||||
"${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;;
|
|
||||||
"${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;;
|
|
||||||
esac
|
|
||||||
if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then
|
|
||||||
gh release download "$release_lookup" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--pattern "$manifest_name" \
|
|
||||||
--dir "$manifests_dir"
|
|
||||||
else
|
|
||||||
gh release delete-asset "$release_lookup" "$manifest_name" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--yes
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
|
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
|
||||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||||
node ../scripts/merge-mac-manifest.mjs \
|
node ../scripts/merge-mac-manifest.mjs \
|
||||||
@@ -557,32 +497,25 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
||||||
node ../scripts/validate-desktop-manifests.mjs \
|
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||||
--release-date "$timestamp" \
|
const yaml = require("js-yaml");
|
||||||
--rollout-hours "$ROLLOUT_HOURS" \
|
const fs = require("fs");
|
||||||
"${files[@]}"
|
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
|
||||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
|
||||||
|
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
|
||||||
failed_builds=()
|
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
|
||||||
[[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS)
|
}
|
||||||
[[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux)
|
for (const f of process.argv.slice(1)) {
|
||||||
[[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows)
|
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
|
||||||
if (( ${#failed_builds[@]} > 0 )); then
|
if (m.rolloutHours !== expectedHours) {
|
||||||
echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft"
|
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
|
||||||
exit 1
|
}
|
||||||
fi
|
if (m.releaseDate !== expectedDate) {
|
||||||
|
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
|
||||||
missing_manifests=()
|
}
|
||||||
for manifest_name in "${expected_manifests[@]}"; do
|
if (typeof m.version !== "string" || m.version.length === 0) {
|
||||||
[[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name")
|
throw new Error(`${f}: missing or invalid version`);
|
||||||
done
|
}
|
||||||
if (( ${#missing_manifests[@]} > 0 )); then
|
}
|
||||||
echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft"
|
' "${files[@]}"
|
||||||
exit 1
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
fi
|
|
||||||
|
|
||||||
publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}")
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
publish_args+=(--prerelease)
|
|
||||||
fi
|
|
||||||
gh "${publish_args[@]}"
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
scope: "@boudra"
|
scope: "@boudra"
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
name: Docker
|
name: Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "docker/**"
|
||||||
|
- ".github/workflows/docker.yml"
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags:
|
tags:
|
||||||
@@ -8,7 +13,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
paseo_version:
|
paseo_version:
|
||||||
description: "Expected source version to build. Required when publish is true."
|
description: "Paseo version to build. Required when publish is true."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
publish:
|
publish:
|
||||||
@@ -19,6 +24,15 @@ on:
|
|||||||
options:
|
options:
|
||||||
- "false"
|
- "false"
|
||||||
- "true"
|
- "true"
|
||||||
|
source_build:
|
||||||
|
description: "Build from the checked-out source tree instead of npm."
|
||||||
|
required: false
|
||||||
|
default: "auto"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- auto
|
||||||
|
- "false"
|
||||||
|
- "true"
|
||||||
publish_latest:
|
publish_latest:
|
||||||
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
|
||||||
required: false
|
required: false
|
||||||
@@ -30,7 +44,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
@@ -43,6 +57,7 @@ jobs:
|
|||||||
image: ${{ steps.values.outputs.image }}
|
image: ${{ steps.values.outputs.image }}
|
||||||
install_version: ${{ steps.values.outputs.install_version }}
|
install_version: ${{ steps.values.outputs.install_version }}
|
||||||
publish: ${{ steps.values.outputs.publish }}
|
publish: ${{ steps.values.outputs.publish }}
|
||||||
|
source_build: ${{ steps.values.outputs.source_build }}
|
||||||
check_tag: ${{ steps.values.outputs.check_tag }}
|
check_tag: ${{ steps.values.outputs.check_tag }}
|
||||||
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
publish_tags: ${{ steps.values.outputs.publish_tags }}
|
||||||
steps:
|
steps:
|
||||||
@@ -53,6 +68,7 @@ jobs:
|
|||||||
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
|
||||||
INPUT_PUBLISH: ${{ inputs.publish }}
|
INPUT_PUBLISH: ${{ inputs.publish }}
|
||||||
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||||
|
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
|
||||||
REPO_OWNER: ${{ github.repository_owner }}
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -60,9 +76,9 @@ jobs:
|
|||||||
|
|
||||||
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||||
image="ghcr.io/${owner}/paseo"
|
image="ghcr.io/${owner}/paseo"
|
||||||
package_version="$(node -p "require('./package.json').version")"
|
install_version="${INPUT_PASEO_VERSION:-latest}"
|
||||||
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
|
|
||||||
publish=false
|
publish=false
|
||||||
|
source_build=false
|
||||||
publish_latest=false
|
publish_latest=false
|
||||||
prerelease=false
|
prerelease=false
|
||||||
|
|
||||||
@@ -71,6 +87,7 @@ jobs:
|
|||||||
publish=true
|
publish=true
|
||||||
if [[ "${REF_NAME}" == *-* ]]; then
|
if [[ "${REF_NAME}" == *-* ]]; then
|
||||||
prerelease=true
|
prerelease=true
|
||||||
|
source_build=true
|
||||||
else
|
else
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -87,6 +104,24 @@ jobs:
|
|||||||
prerelease=true
|
prerelease=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "${INPUT_SOURCE_BUILD:-auto}" in
|
||||||
|
true)
|
||||||
|
source_build=true
|
||||||
|
;;
|
||||||
|
false)
|
||||||
|
source_build=false
|
||||||
|
;;
|
||||||
|
auto)
|
||||||
|
if [[ "${prerelease}" == "true" ]]; then
|
||||||
|
source_build=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::source_build must be auto, true, or false."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
|
||||||
publish_latest=true
|
publish_latest=true
|
||||||
fi
|
fi
|
||||||
@@ -102,13 +137,14 @@ jobs:
|
|||||||
echo "image=${image}"
|
echo "image=${image}"
|
||||||
echo "install_version=${install_version}"
|
echo "install_version=${install_version}"
|
||||||
echo "publish=${publish}"
|
echo "publish=${publish}"
|
||||||
|
echo "source_build=${source_build}"
|
||||||
echo "check_tag=${check_tag}"
|
echo "check_tag=${check_tag}"
|
||||||
echo "publish_tags<<EOF"
|
echo "publish_tags<<EOF"
|
||||||
echo "${publish_tags}"
|
echo "${publish_tags}"
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
|
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: setup
|
needs: setup
|
||||||
@@ -124,8 +160,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||||
file: docker/base/Dockerfile
|
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
@@ -157,8 +193,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/build-push-action@v7
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
|
||||||
file: docker/base/Dockerfile
|
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ on:
|
|||||||
- "package.json"
|
- "package.json"
|
||||||
- "package-lock.json"
|
- "package-lock.json"
|
||||||
- "packages/highlight/**"
|
- "packages/highlight/**"
|
||||||
- "packages/plugin/**"
|
|
||||||
- "packages/server/**"
|
- "packages/server/**"
|
||||||
- "packages/relay/**"
|
- "packages/relay/**"
|
||||||
- "packages/cli/**"
|
- "packages/cli/**"
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
name: Nix
|
name: Nix
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "nix/**"
|
||||||
|
- "flake.nix"
|
||||||
|
- "flake.lock"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
- "packages/highlight/**"
|
||||||
|
- "packages/protocol/**"
|
||||||
|
- "packages/client/**"
|
||||||
|
- "packages/server/**"
|
||||||
|
- "packages/relay/**"
|
||||||
|
- "packages/cli/**"
|
||||||
|
- "scripts/update-nix.sh"
|
||||||
|
- "scripts/fix-lockfile.mjs"
|
||||||
|
- ".github/workflows/nix.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -12,6 +27,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -36,7 +53,7 @@ jobs:
|
|||||||
PASEO_HOME="$(mktemp -d)"
|
PASEO_HOME="$(mktemp -d)"
|
||||||
export PASEO_LISTEN=127.0.0.1:6767
|
export PASEO_LISTEN=127.0.0.1:6767
|
||||||
|
|
||||||
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
|
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||||
@@ -47,7 +64,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 &
|
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||||
DAEMON_PID=$!
|
DAEMON_PID=$!
|
||||||
|
|
||||||
deadline=$((SECONDS + 30))
|
deadline=$((SECONDS + 30))
|
||||||
@@ -55,24 +72,7 @@ jobs:
|
|||||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||||
echo "$STATUS_JSON"
|
echo "$STATUS_JSON"
|
||||||
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
exit 0
|
||||||
[[ -s "$PASEO_HOME/web-ui.html" ]]
|
|
||||||
if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")"
|
|
||||||
./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter
|
|
||||||
deadline=$((SECONDS + 15))
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \
|
|
||||||
| jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then
|
|
||||||
./result/bin/paseo terminal kill "$TERMINAL_ID"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "Nix terminal did not execute the command."
|
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
echo "::group::paseo daemon run stdout/stderr"
|
echo "::group::paseo-server stdout/stderr"
|
||||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
@@ -99,25 +99,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Nix desktop package
|
- name: Build Nix desktop package
|
||||||
run: nix build .#desktop -o result-desktop
|
run: nix build .#desktop -o result-desktop
|
||||||
|
|
||||||
build-desktop-darwin:
|
|
||||||
runs-on: macos-14
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: cachix/install-nix-action@v31
|
|
||||||
with:
|
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
|
||||||
|
|
||||||
- name: Build Nix desktop package
|
|
||||||
run: nix build .#desktop -o result-desktop
|
|
||||||
|
|
||||||
- name: Verify macOS application bundle
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
app="result-desktop/Applications/Paseo.app"
|
|
||||||
test -x "$app/Contents/MacOS/Paseo"
|
|
||||||
test -L result-desktop/bin/paseo-desktop
|
|
||||||
test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = "sh.paseo.desktop"
|
|
||||||
find "$app" -name Info.plist -print0 | xargs -0 -n 1 plutil -lint
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
create_if_missing:
|
create_if_missing:
|
||||||
description: "Create a draft release if missing (normally only needed for tag events)."
|
description: "Create release if missing (normally only needed for tag events)."
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -29,6 +29,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -84,17 +84,8 @@ valknut-report.json/
|
|||||||
.plans/
|
.plans/
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||||
packages/protocol/src/generated/validation/*.aot.ts
|
|
||||||
packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts
|
|
||||||
packages/protocol/codegen/generated/
|
|
||||||
|
|
||||||
/artifacts
|
/artifacts
|
||||||
packages/desktop/.cache/
|
packages/desktop/.cache/
|
||||||
packages/desktop/src-tauri/resources/managed-runtime/
|
packages/desktop/src-tauri/resources/managed-runtime/
|
||||||
app.json
|
app.json
|
||||||
|
|
||||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
|
||||||
*.heapsnapshot
|
|
||||||
|
|
||||||
# Project-local Serena configuration and memories.
|
|
||||||
.serena/
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
[vars]
|
|
||||||
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
|
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
|
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
|
||||||
_.path = [
|
_.path = [
|
||||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
|
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
|
||||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
|
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
|
||||||
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
java = "17"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
},
|
},
|
||||||
"ignorePatterns": [".dev/**"],
|
"ignorePatterns": [".dev/**"],
|
||||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
|
||||||
"categories": {
|
"categories": {
|
||||||
"correctness": "error",
|
"correctness": "error",
|
||||||
"suspicious": "error",
|
"suspicious": "error",
|
||||||
@@ -85,561 +84,8 @@
|
|||||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"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}"],
|
"files": ["**/e2e/fixtures.ts"],
|
||||||
"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",
|
|
||||||
"packages/plugin/src/server/acp-internal/**/*.ts"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "node:child_process",
|
|
||||||
"importNames": [
|
|
||||||
"spawn",
|
|
||||||
"exec",
|
|
||||||
"execFile",
|
|
||||||
"spawnSync",
|
|
||||||
"execSync",
|
|
||||||
"execFileSync"
|
|
||||||
],
|
|
||||||
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "child_process",
|
|
||||||
"importNames": [
|
|
||||||
"spawn",
|
|
||||||
"exec",
|
|
||||||
"execFile",
|
|
||||||
"spawnSync",
|
|
||||||
"execSync",
|
|
||||||
"execFileSync"
|
|
||||||
],
|
|
||||||
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
|
||||||
"rules": {
|
|
||||||
// React Native style arrays must read Unistyles proxies during render. Hoisting them to
|
|
||||||
// satisfy this allocation rule captures the temporary startup theme instead.
|
|
||||||
"react-perf/jsx-no-new-array-as-prop": "off",
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native",
|
|
||||||
"importNames": ["TextInput"],
|
|
||||||
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "@tanstack/react-query",
|
|
||||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
|
||||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/data/**/*.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.test.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.spec.{ts,tsx}"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native",
|
|
||||||
"importNames": ["TextInput"],
|
|
||||||
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/assistant-file-links/use-file-link.ts",
|
|
||||||
"packages/app/src/components/message.tsx",
|
|
||||||
"packages/app/src/components/worktree-setup-callout-source.tsx",
|
|
||||||
"packages/app/src/desktop/hooks/use-daemon-status.ts",
|
|
||||||
"packages/app/src/desktop/hooks/use-install-status.ts",
|
|
||||||
"packages/app/src/desktop/settings/desktop-settings.ts",
|
|
||||||
"packages/app/src/git/pull-request-panel/use-data.ts",
|
|
||||||
"packages/app/src/git/use-github-search-query.ts",
|
|
||||||
"packages/app/src/git/use-pr-status-query.ts",
|
|
||||||
"packages/app/src/git/use-status-query.ts",
|
|
||||||
"packages/app/src/hooks/use-archive-agent.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-autocomplete.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-commands-query.ts",
|
|
||||||
"packages/app/src/hooks/use-agent-history.ts",
|
|
||||||
"packages/app/src/hooks/use-branch-switcher.ts",
|
|
||||||
"packages/app/src/hooks/use-changes-preferences/index.ts",
|
|
||||||
"packages/app/src/hooks/use-draft-agent-features.ts",
|
|
||||||
"packages/app/src/hooks/use-form-preferences.ts",
|
|
||||||
"packages/app/src/hooks/use-is-local-daemon.ts",
|
|
||||||
"packages/app/src/hooks/use-keyboard-shortcut-overrides.ts",
|
|
||||||
"packages/app/src/hooks/use-preferred-editor.ts",
|
|
||||||
"packages/app/src/hooks/use-settings/index.ts",
|
|
||||||
"packages/app/src/panels/terminal-panel.tsx",
|
|
||||||
"packages/app/src/projects/icons.ts",
|
|
||||||
"packages/app/src/provider-usage/use-provider-usage.ts",
|
|
||||||
"packages/app/src/screens/project-settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/workspace/use-workspace-checkout-status.ts",
|
|
||||||
"packages/app/src/workspace/desktop-open-targets.ts"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native",
|
|
||||||
"importNames": ["TextInput"],
|
|
||||||
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "react-native-unistyles",
|
|
||||||
"importNames": ["useUnistyles"],
|
|
||||||
"message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/app/_layout.tsx",
|
|
||||||
"packages/app/src/app/pair-scan.tsx",
|
|
||||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
|
||||||
"packages/app/src/components/add-host-method-modal.tsx",
|
|
||||||
"packages/app/src/components/add-host-modal.tsx",
|
|
||||||
"packages/app/src/components/agent-list.tsx",
|
|
||||||
"packages/app/src/components/agent-status-dot.tsx",
|
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
|
||||||
"packages/app/src/desktop/browser/pane/index.electron.tsx",
|
|
||||||
"packages/app/src/desktop/browser/pane/index.tsx",
|
|
||||||
"packages/app/src/desktop/browser/pane/index.web.tsx",
|
|
||||||
"packages/app/src/components/command-center.tsx",
|
|
||||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
|
||||||
"packages/app/src/components/context-window-meter.tsx",
|
|
||||||
"packages/app/src/components/dictation-controls.tsx",
|
|
||||||
"packages/app/src/components/download-toast.tsx",
|
|
||||||
"packages/app/src/components/draggable-list.native.tsx",
|
|
||||||
"packages/app/src/components/headers/back-header.tsx",
|
|
||||||
"packages/app/src/components/headers/screen-header.tsx",
|
|
||||||
"packages/app/src/components/host-status-dot.tsx",
|
|
||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
|
||||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
|
||||||
"packages/app/src/components/left-sidebar.tsx",
|
|
||||||
"packages/app/src/components/pair-link-modal.tsx",
|
|
||||||
"packages/app/src/components/plan-card.tsx",
|
|
||||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
|
||||||
"packages/app/src/components/question-form-card.tsx",
|
|
||||||
"packages/app/src/components/quitting-overlay.tsx",
|
|
||||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
|
||||||
"packages/app/src/components/resize-handle.tsx",
|
|
||||||
"packages/app/src/components/rewind/rewind-menu.tsx",
|
|
||||||
"packages/app/src/components/settings-textarea.tsx",
|
|
||||||
"packages/app/src/components/sidebar-callout.tsx",
|
|
||||||
"packages/app/src/components/split-container.tsx",
|
|
||||||
"packages/app/src/components/split-drop-zone.tsx",
|
|
||||||
"packages/app/src/components/terminal-pane.tsx",
|
|
||||||
"packages/app/src/components/toast-host.tsx",
|
|
||||||
"packages/app/src/components/tool-call-sheet.tsx",
|
|
||||||
"packages/app/src/components/ui/alert.tsx",
|
|
||||||
"packages/app/src/components/ui/autocomplete.tsx",
|
|
||||||
"packages/app/src/components/ui/combobox.tsx",
|
|
||||||
"packages/app/src/components/ui/context-menu.tsx",
|
|
||||||
"packages/app/src/components/ui/dropdown-menu.tsx",
|
|
||||||
"packages/app/src/components/ui/external-link.tsx",
|
|
||||||
"packages/app/src/components/volume-meter.tsx",
|
|
||||||
"packages/app/src/components/web-desktop-scrollbar.tsx",
|
|
||||||
"packages/app/src/components/welcome-screen.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/index.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/mode-control.tsx",
|
|
||||||
"packages/app/src/constants/layout.ts",
|
|
||||||
"packages/app/src/desktop/components/desktop-permission-row.tsx",
|
|
||||||
"packages/app/src/desktop/components/desktop-permissions-section.tsx",
|
|
||||||
"packages/app/src/desktop/components/desktop-updates-section.tsx",
|
|
||||||
"packages/app/src/desktop/components/integrations-section.tsx",
|
|
||||||
"packages/app/src/desktop/updates/update-callout-source.tsx",
|
|
||||||
"packages/app/src/git/actions-split-button.tsx",
|
|
||||||
"packages/app/src/hooks/use-web-scrollbar-style.web.ts",
|
|
||||||
"packages/app/src/hosts/host-chooser.tsx",
|
|
||||||
"packages/app/src/screens/open-project-screen.tsx",
|
|
||||||
"packages/app/src/screens/projects-screen.tsx",
|
|
||||||
"packages/app/src/screens/sessions-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings/host-page.tsx",
|
|
||||||
"packages/app/src/screens/settings/providers-section.tsx",
|
|
||||||
"packages/app/src/screens/settings/settings-group.tsx",
|
|
||||||
"packages/app/src/screens/startup-splash-screen.tsx",
|
|
||||||
"packages/app/src/screens/workspace/workspace-route-state-views.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native",
|
|
||||||
"importNames": ["TextInput"],
|
|
||||||
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "@tanstack/react-query",
|
|
||||||
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
|
|
||||||
"message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Both burn-downs: 6 overlapping files. They may only shrink out of this exemption.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/components/file-explorer-pane.tsx",
|
|
||||||
"packages/app/src/components/file-pane.tsx",
|
|
||||||
"packages/app/src/components/import-session-sheet.tsx",
|
|
||||||
"packages/app/src/components/project-picker-modal.tsx",
|
|
||||||
"packages/app/src/desktop/components/pair-device-section.tsx",
|
|
||||||
"packages/app/src/screens/new-workspace-screen.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "react-native",
|
|
||||||
"importNames": ["TextInput"],
|
|
||||||
"message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/components/ui/text-input/**/*.{ts,tsx}",
|
|
||||||
"packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
|
||||||
"rules": {
|
|
||||||
"paseo/no-raw-pressable": "error"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
|
|
||||||
// 94 files. They may only leave this list.
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
"packages/app/src/components/ui/**/*.{ts,tsx}",
|
|
||||||
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
|
|
||||||
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
|
|
||||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
|
||||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
|
||||||
"packages/app/src/agent-stream/view.tsx",
|
|
||||||
"packages/app/src/app/pair-scan.tsx",
|
|
||||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
|
||||||
"packages/app/src/command-center/command-center.tsx",
|
|
||||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
|
||||||
"packages/app/src/components/add-host-method-modal.tsx",
|
|
||||||
"packages/app/src/components/add-host-modal.tsx",
|
|
||||||
"packages/app/src/components/add-project-flow.tsx",
|
|
||||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
|
||||||
"packages/app/src/components/agent-list.tsx",
|
|
||||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
|
||||||
"packages/app/src/components/attachment-lightbox.tsx",
|
|
||||||
"packages/app/src/components/attachment-pill.tsx",
|
|
||||||
"packages/app/src/components/combined-model-selector.tsx",
|
|
||||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
|
||||||
"packages/app/src/components/context-window-meter.tsx",
|
|
||||||
"packages/app/src/components/desktop/window-controls.tsx",
|
|
||||||
"packages/app/src/components/dictation-controls.tsx",
|
|
||||||
"packages/app/src/components/download-toast.tsx",
|
|
||||||
"packages/app/src/components/file-explorer-pane.tsx",
|
|
||||||
"packages/app/src/components/headers/back-header.tsx",
|
|
||||||
"packages/app/src/components/highlighted-code-block.tsx",
|
|
||||||
"packages/app/src/components/hosts/host-filter.tsx",
|
|
||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
|
||||||
"packages/app/src/components/import-session-sheet.tsx",
|
|
||||||
"packages/app/src/components/left-sidebar.tsx",
|
|
||||||
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
|
|
||||||
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
|
|
||||||
"packages/app/src/components/message.tsx",
|
|
||||||
"packages/app/src/components/model-browser.tsx",
|
|
||||||
"packages/app/src/components/plan-card.tsx",
|
|
||||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
|
||||||
"packages/app/src/components/question-form-card.tsx",
|
|
||||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
|
||||||
"packages/app/src/components/schedules/schedule-row.tsx",
|
|
||||||
"packages/app/src/components/settings/collapsible-row.tsx",
|
|
||||||
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
|
|
||||||
"packages/app/src/components/sidebar-callout.tsx",
|
|
||||||
"packages/app/src/components/sidebar-resize-handle.tsx",
|
|
||||||
"packages/app/src/components/sidebar-workspace-list.tsx",
|
|
||||||
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
|
|
||||||
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
|
|
||||||
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
|
|
||||||
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
|
|
||||||
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
|
|
||||||
"packages/app/src/components/terminal-copy-paste-actions.tsx",
|
|
||||||
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
|
|
||||||
"packages/app/src/components/terminal-pane.tsx",
|
|
||||||
"packages/app/src/components/toast-host.tsx",
|
|
||||||
"packages/app/src/components/tool-call-sheet.tsx",
|
|
||||||
"packages/app/src/components/welcome-screen.tsx",
|
|
||||||
"packages/app/src/components/workspace-hover-card.tsx",
|
|
||||||
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
|
|
||||||
"packages/app/src/composer/agent-controls/index.tsx",
|
|
||||||
"packages/app/src/composer/diff-stat-pill.tsx",
|
|
||||||
"packages/app/src/composer/dock/internal/background.native.tsx",
|
|
||||||
"packages/app/src/composer/draft/import-pill.tsx",
|
|
||||||
"packages/app/src/composer/index.tsx",
|
|
||||||
"packages/app/src/composer/tracks.tsx",
|
|
||||||
"packages/app/src/desktop/browser/pane/index.electron.tsx",
|
|
||||||
"packages/app/src/git/actions-split-button.tsx",
|
|
||||||
"packages/app/src/git/commits-section/commit-row.tsx",
|
|
||||||
"packages/app/src/git/commits-section/commits-section.tsx",
|
|
||||||
"packages/app/src/git/diff-pane.tsx",
|
|
||||||
"packages/app/src/git/forges/gitlab.view.tsx",
|
|
||||||
"packages/app/src/git/pull-request-panel/checks-section.tsx",
|
|
||||||
"packages/app/src/git/pull-request-panel/pane.tsx",
|
|
||||||
"packages/app/src/git/pull-request-panel/section-kit.tsx",
|
|
||||||
"packages/app/src/hosts/host-chooser.tsx",
|
|
||||||
"packages/app/src/mobile-panels/presentation.tsx",
|
|
||||||
"packages/app/src/panels/new-tab-panel.tsx",
|
|
||||||
"packages/app/src/panels/setup-panel.tsx",
|
|
||||||
"packages/app/src/plugins/buttons/view.tsx",
|
|
||||||
"packages/app/src/review/surface.tsx",
|
|
||||||
"packages/app/src/screens/open-project-screen.tsx",
|
|
||||||
"packages/app/src/screens/project-settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/projects-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings-screen.tsx",
|
|
||||||
"packages/app/src/screens/settings/host-appearance-section.tsx",
|
|
||||||
"packages/app/src/screens/settings/host-page.tsx",
|
|
||||||
"packages/app/src/screens/settings/providers-section.tsx",
|
|
||||||
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
|
|
||||||
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
|
|
||||||
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
|
|
||||||
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
|
|
||||||
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
|
|
||||||
"packages/app/src/usage/sidebar-item.tsx",
|
|
||||||
"packages/app/src/usage/window-bar.tsx",
|
|
||||||
"packages/app/src/workspace-labels/manager-modal.tsx",
|
|
||||||
"packages/app/src/workspace-labels/swatch.tsx",
|
|
||||||
"packages/app/src/workspace/open-in-editor/button.tsx"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"paseo/no-raw-pressable": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["**/e2e/**/fixtures.ts"],
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-empty-pattern": "off"
|
"no-empty-pattern": "off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
rust 1.85.1
|
rust 1.85.1
|
||||||
nodejs 22.20.0
|
nodejs 22.20.0
|
||||||
java 21
|
java 21
|
||||||
android-sdk 21.0
|
android-sdk latest
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||||
|
|
||||||
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
|
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
@@ -19,81 +19,36 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
|
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
|
||||||
|
|
||||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
|
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||||
|
|
||||||
| Doc | What's in it |
|
| Doc | What's in it |
|
||||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
| [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/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/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||||
| [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/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [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/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||||
| [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 |
|
|
||||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
|
||||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
|
||||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
|
||||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
|
||||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
|
||||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
|
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
|
||||||
| [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 |
|
|
||||||
|
|
||||||
### Writing docs
|
|
||||||
|
|
||||||
- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like.
|
|
||||||
- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it.
|
|
||||||
- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link.
|
|
||||||
- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it.
|
|
||||||
- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example.
|
|
||||||
- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block.
|
|
||||||
- **New doc?** Add a row to the table above and link it from the docs that should send readers there.
|
|
||||||
- Code-level facts belong in comments next to the code, not here.
|
|
||||||
|
|
||||||
### Doc voice
|
|
||||||
|
|
||||||
Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing.
|
|
||||||
|
|
||||||
Do not:
|
|
||||||
|
|
||||||
- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape.
|
|
||||||
- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical".
|
|
||||||
- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful".
|
|
||||||
- Restate something you already said, in different words, for emphasis.
|
|
||||||
- Hedge with "generally", "typically", or "you may want to" when the answer is "do this".
|
|
||||||
- Clear your throat: "It's worth noting that", "In order to", "This section covers".
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -111,17 +66,8 @@ 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`.
|
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.
|
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||||
|
|
||||||
## Release branches
|
|
||||||
|
|
||||||
When the user says "this goes to next", create or
|
|
||||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
|
||||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
|
||||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
|
||||||
|
|
||||||
## Critical rules
|
## Critical rules
|
||||||
|
|
||||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||||
@@ -134,7 +80,6 @@ and updating `next`, integrating it after a release, and releasing a hotfix from
|
|||||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||||
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
|
|
||||||
- **Always run typecheck and lint after every change.**
|
- **Always run typecheck and lint after every change.**
|
||||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
||||||
- `npm run build:client` — rebuild protocol and client declarations.
|
- `npm run build:client` — rebuild protocol and client declarations.
|
||||||
@@ -144,11 +89,20 @@ and updating `next`, integrating it after a release, and releasing a hotfix from
|
|||||||
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||||
- `npm run lint -- packages/app/src/components/message.tsx`
|
- `npm run lint -- packages/app/src/components/message.tsx`
|
||||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||||
- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version:
|
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||||
- **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`.
|
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||||
- **Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches.
|
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||||
- **Every shim is tagged.** `// COMPAT(name): added in vX, remove after <date>` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident.
|
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||||
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
|
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||||
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
|
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||||
|
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||||
|
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||||
|
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
|
||||||
|
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
|
||||||
|
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
|
||||||
|
|
||||||
|
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
|
||||||
|
|
||||||
## Platform gating
|
## Platform gating
|
||||||
|
|
||||||
@@ -186,12 +140,12 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
|
|||||||
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||||
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
|
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
|
||||||
```
|
```
|
||||||
desktop/browser/pane/
|
components/
|
||||||
index.electron.tsx ← Electron <webview> implementation
|
browser-pane.electron.tsx ← Electron <webview> implementation
|
||||||
index.web.tsx ← plain web fallback
|
browser-pane.web.tsx ← plain web fallback
|
||||||
index.tsx ← native fallback
|
browser-pane.tsx ← native fallback
|
||||||
```
|
```
|
||||||
Import as `@/desktop/browser/pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
|
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
|
||||||
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
||||||
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
||||||
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
||||||
|
|||||||
@@ -1,104 +1,56 @@
|
|||||||
# Contributing to Paseo
|
# Contributing to Paseo
|
||||||
|
|
||||||
This guide is here to save us both time and help you find a useful way to contribute.
|
Paseo is an opinionated product maintained by one person right now.
|
||||||
|
|
||||||
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
|
The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups.
|
||||||
|
|
||||||
## Philosophy
|
Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||||
|
|
||||||
**Paseo has a lean, opinionated core built to be extended.**
|
## Becoming a maintainer
|
||||||
|
|
||||||
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
|
There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one.
|
||||||
|
|
||||||
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
|
Here's the progression:
|
||||||
|
|
||||||
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
|
1. Get involved in the community: answer questions in Discord and on GitHub
|
||||||
|
2. Triage bugs: replicate and help fix them
|
||||||
|
3. Work on maintainer-approved features
|
||||||
|
|
||||||
## Build a plugin
|
The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer.
|
||||||
|
|
||||||
Most specialized workflows and integrations are better served by plugins.
|
Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute.
|
||||||
|
|
||||||
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
|
## Pull requests
|
||||||
|
|
||||||
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
|
✅ Will be accepted
|
||||||
|
|
||||||
## Report bugs
|
- Keep it to one focused change
|
||||||
|
- Link to an issue
|
||||||
|
- Explain the problem you're solving
|
||||||
|
- Include repro steps if it's a bug
|
||||||
|
- Include QA/testing evidence
|
||||||
|
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||||
|
- If you only tested one platform, say that clearly
|
||||||
|
|
||||||
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
|
⛔️ Will be rejected
|
||||||
|
|
||||||
Include:
|
- Bundle unrelated changes
|
||||||
|
- Fail basic checks like typecheck, formatting or linting
|
||||||
|
- Add a feature or design change that wasn't discussed first
|
||||||
|
- Submit no evidence of testing
|
||||||
|
- Skip the linked issue
|
||||||
|
- Clearly fully AI-generated PR
|
||||||
|
|
||||||
- What you did, what you expected, and what happened.
|
## Requesting features
|
||||||
- Reproduction steps, Paseo version, and platform.
|
|
||||||
- Relevant logs, screenshots, or a recording.
|
|
||||||
|
|
||||||
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
|
If you need a feature implemented, create a Github issue or a thread in Discord.
|
||||||
|
|
||||||
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
|
Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect.
|
||||||
|
|
||||||
## Discuss workflows before proposing features
|
## AI assistance
|
||||||
|
|
||||||
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
|
Using AI to help write code is fine, but you must:
|
||||||
|
|
||||||
Explain:
|
- Ensure your agents read the docs
|
||||||
|
- Understand the code you submit
|
||||||
- What are you trying to do?
|
- Review and test the code yourself
|
||||||
- How do you do it today?
|
|
||||||
- Where does Paseo get in the way?
|
|
||||||
- What would a better workflow look like?
|
|
||||||
|
|
||||||
This helps other people describe their needs and gives me useful context when deciding what to build.
|
|
||||||
|
|
||||||
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
|
|
||||||
|
|
||||||
## If you still want to submit a pull request
|
|
||||||
|
|
||||||
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
|
|
||||||
|
|
||||||
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
|
|
||||||
|
|
||||||
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
|
|
||||||
|
|
||||||
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
|
|
||||||
|
|
||||||
If you choose to submit a PR, these are the basics to cover.
|
|
||||||
|
|
||||||
### What I look for in a core feature
|
|
||||||
|
|
||||||
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
|
|
||||||
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
|
|
||||||
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
|
|
||||||
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
|
|
||||||
|
|
||||||
### Keep the scope small
|
|
||||||
|
|
||||||
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
|
|
||||||
|
|
||||||
### QA evidence
|
|
||||||
|
|
||||||
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
|
|
||||||
|
|
||||||
Explain the problem and link the relevant bug report or discussion. Include:
|
|
||||||
|
|
||||||
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
|
|
||||||
- The commands you ran and their output.
|
|
||||||
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
|
|
||||||
- The platforms you tested and any affected platforms you could not test.
|
|
||||||
|
|
||||||
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
|
|
||||||
|
|
||||||
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
|
|
||||||
|
|
||||||
**Satisfying all of the above does not mean your pull request will be merged.**
|
|
||||||
|
|
||||||
### What happens to your contribution
|
|
||||||
|
|
||||||
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
|
|
||||||
|
|
||||||
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
|
|
||||||
|
|
||||||
## Help the community
|
|
||||||
|
|
||||||
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
|
|
||||||
|
|
||||||
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
|
|
||||||
|
|||||||
@@ -2,210 +2,670 @@ Copyright (c) 2025-present Mohamed Boudra
|
|||||||
|
|
||||||
Portions of this software are licensed as follows:
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
* Third-party components incorporated into Paseo remain licensed under
|
* All third party components incorporated into the Paseo Software are
|
||||||
the licenses provided by their respective copyright holders.
|
licensed under the original license provided by the owner of the
|
||||||
* Except for those third-party components, Paseo is licensed under the
|
applicable component.
|
||||||
Apache License, Version 2.0, reproduced below.
|
* All content outside of the above mentioned restrictions is available
|
||||||
|
under the "AGPLv3" license as defined below.
|
||||||
|
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Apache License
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Version 2.0, January 2004
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
http://www.apache.org/licenses/
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
Preamble
|
||||||
|
|
||||||
1. Definitions.
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
The licenses for most software and other practical works are designed
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
When we speak of free software, we are referring to freedom, not
|
||||||
the copyright owner that is granting the License.
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
Developers that use our General Public Licenses protect your rights
|
||||||
other entities that control, are controlled by, or are under common
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
control with that entity. For the purposes of this definition,
|
you this License which gives you legal permission to copy, distribute
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
and/or modify the software.
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
A secondary benefit of defending all users' freedom is that
|
||||||
exercising permissions granted by this License.
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
The GNU Affero General Public License is designed specifically to
|
||||||
including but not limited to software source code, documentation
|
ensure that, in such cases, the modified source code becomes available
|
||||||
source, and configuration files.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
An older license, called the Affero General Public License and
|
||||||
transformation or translation of a Source form, including but
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
not limited to compiled object code, generated documentation,
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
and conversions to other media types.
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
The precise terms and conditions for copying, distribution and
|
||||||
Object form, made available under the License, as indicated by a
|
modification follow.
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
TERMS AND CONDITIONS
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
0. Definitions.
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
works, such as semiconductor masks.
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
"recipients" may be individuals or organizations.
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
modifications, and in Source or Object form, provided that You
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
meet the following conditions:
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
A "covered work" means either the unmodified Program or a work based
|
||||||
Derivative Works a copy of this License; and
|
on the Program.
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
To "propagate" a work means to do anything with it that, without
|
||||||
stating that You changed the files; and
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
To "convey" a work means any kind of propagation that enables other
|
||||||
that You distribute, all copyright, patent, trademark, and
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
attribution notices from the Source form of the Work,
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
distribution, then any Derivative Works that You distribute must
|
to the extent that it includes a convenient and prominently visible
|
||||||
include a readable copy of the attribution notices contained
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
within such NOTICE file, excluding those notices that do not
|
tells the user that there is no warranty for the work (except to the
|
||||||
pertain to any part of the Derivative Works, in at least one
|
extent that warranties are provided), that licensees may convey the
|
||||||
of the following places: within a NOTICE text file distributed
|
work under this License, and how to view a copy of this License. If
|
||||||
as part of the Derivative Works; within the Source form or
|
the interface presents a list of user commands or options, such as a
|
||||||
documentation, if provided along with the Derivative Works; or,
|
menu, a prominent item in the list meets this criterion.
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
1. Source Code.
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
The "source code" for a work means the preferred form of the work
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
for making modifications to it. "Object code" means any non-source
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
form of a work.
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
A "Standard Interface" means an interface that either is an official
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
standard defined by a recognized standards body, or, in the case of
|
||||||
except as required for reasonable and customary use in describing the
|
interfaces specified for a particular programming language, one that
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
The "System Libraries" of an executable work include anything, other
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
packaging a Major Component, but which is not part of that Major
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
Component, and (b) serves only to enable use of the work with that
|
||||||
implied, including, without limitation, any warranties or conditions
|
Major Component, or to implement a Standard Interface for which an
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
implementation is available to the public in source code form. A
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
"Major Component", in this context, means a major essential component
|
||||||
appropriateness of using or redistributing the Work and assume any
|
(kernel, window system, and so on) of the specific operating system
|
||||||
risks associated with Your exercise of permissions under this License.
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
The "Corresponding Source" for a work in object code form means all
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
the source code needed to generate, install, and (for an executable
|
||||||
unless required by applicable law (such as deliberate and grossly
|
work) run the object code and to modify the work, including scripts to
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
control those activities. However, it does not include the work's
|
||||||
liable to You for damages, including any direct, indirect, special,
|
System Libraries, or general-purpose tools or generally available free
|
||||||
incidental, or consequential damages of any character arising as a
|
programs which are used unmodified in performing those activities but
|
||||||
result of this License or out of the use or inability to use the
|
which are not part of the work. For example, Corresponding Source
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
includes interface definition files associated with source files for
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
the work, and the source code for shared libraries and dynamically
|
||||||
other commercial damages or losses), even if such Contributor
|
linked subprograms that the work is specifically designed to require,
|
||||||
has been advised of the possibility of such damages.
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
The Corresponding Source need not include anything that users
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
can regenerate automatically from other parts of the Corresponding
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
Source.
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
2. Basic Permissions.
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
All rights granted under this License are granted for the term of
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
replaced with your own identifying information. (Don't include
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
permission to run the unmodified Program. The output from running a
|
||||||
comment syntax for the file format. We also recommend that a
|
covered work is covered by this License only if the output, given its
|
||||||
file or class name and description of purpose be included on the
|
content, constitutes a covered work. This License acknowledges your
|
||||||
same "printed page" as the copyright notice for easier
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Conveying under any other circumstances is permitted solely under
|
||||||
you may not use this file except in compliance with the License.
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
You may obtain a copy of the License at
|
makes it unnecessary.
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
No covered work shall be deemed part of an effective technological
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
measure under any applicable law fulfilling obligations under article
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
See the License for the specific language governing permissions and
|
similar laws prohibiting or restricting circumvention of such
|
||||||
limitations under the License.
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">English</a> ·
|
<a href="README.md">English</a> ·
|
||||||
<a href="README.zh-CN.md">简体中文</a> ·
|
<a href="README.zh-CN.md">简体中文</a> ·
|
||||||
<a href="README.ja.md">日本語</a> ·
|
<a href="README.ja.md">日本語</a>
|
||||||
<a href="README.ko.md">한국어</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -53,17 +52,6 @@
|
|||||||
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
|
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
|
||||||
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
|
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
|
||||||
|
|
||||||
## スポンサー
|
|
||||||
|
|
||||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
|
||||||
<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/sponsor">Paseo のスポンサーになる</a></td>
|
|
||||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## はじめかた
|
## はじめかた
|
||||||
|
|
||||||
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
|
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
|
||||||
@@ -129,6 +117,7 @@ npx skills add getpaseo/paseo
|
|||||||
どのエージェントとの会話でも使用できます。
|
どのエージェントとの会話でも使用できます。
|
||||||
|
|
||||||
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
||||||
|
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
|
||||||
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
||||||
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
||||||
|
|
||||||
@@ -162,15 +151,23 @@ npm run build:server
|
|||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
## Paseo を支援する
|
## コミュニティ
|
||||||
|
|
||||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
|
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||||
|
|
||||||
## 関連プロジェクト
|
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||||
|
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## ライセンス
|
## ライセンス
|
||||||
|
|
||||||
Apache-2.0
|
AGPL-3.0
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo 로고">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="README.md">English</a> ·
|
|
||||||
<a href="README.zh-CN.md">简体中文</a> ·
|
|
||||||
<a href="README.ja.md">日本語</a> ·
|
|
||||||
<a href="README.ko.md">한국어</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/getpaseo/paseo/stargazers">
|
|
||||||
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/getpaseo/paseo/releases">
|
|
||||||
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
|
||||||
</a>
|
|
||||||
<a href="https://x.com/moboudra">
|
|
||||||
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
|
||||||
</a>
|
|
||||||
<a href="https://discord.gg/jz8T2uahpH">
|
|
||||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
|
||||||
</a>
|
|
||||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
|
||||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">Claude Code, Codex, Copilot, OpenCode, Pi 에이전트를 위한 하나의 인터페이스</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
내 컴퓨터에서 에이전트를 병렬로 실행하세요. 데스크톱이나 휴대폰에서 배포하세요.
|
|
||||||
|
|
||||||
- **셀프 호스팅:** 에이전트는 완전한 개발 환경이 갖춰진 내 컴퓨터에서 실행됩니다. 평소 쓰던 도구, 설정, 스킬을 그대로 쓸 수 있습니다.
|
|
||||||
- **여러 제공자 지원:** Claude Code, Codex, Copilot, OpenCode, Pi를 하나의 인터페이스에서 사용할 수 있습니다. 작업마다 알맞은 모델을 고를 수 있습니다.
|
|
||||||
- **음성 제어:** 음성 모드에서 작업을 말로 지시하거나 문제를 음성으로 함께 검토할 수 있습니다. 손을 쓰지 않고 작업해야 할 때 유용합니다.
|
|
||||||
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
|
|
||||||
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
|
|
||||||
|
|
||||||
## 스폰서
|
|
||||||
|
|
||||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
|
||||||
<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/sponsor">Paseo 스폰서 되기</a></td>
|
|
||||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## 시작하기
|
|
||||||
|
|
||||||
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
|
|
||||||
|
|
||||||
### 준비 사항
|
|
||||||
|
|
||||||
아래 에이전트 CLI 중 하나 이상을 설치하고 인증 정보를 설정해야 합니다.
|
|
||||||
|
|
||||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
||||||
- [Codex](https://github.com/openai/codex)
|
|
||||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
|
||||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
|
||||||
- [Pi](https://pi.dev)
|
|
||||||
|
|
||||||
### 데스크톱 앱(권장)
|
|
||||||
|
|
||||||
[paseo.sh/download](https://paseo.sh/download) 또는 [GitHub 릴리스 페이지](https://github.com/getpaseo/paseo/releases)에서 다운로드하세요. 앱을 열면 데몬이 자동으로 시작됩니다. 별도로 설치할 것은 없습니다.
|
|
||||||
|
|
||||||
휴대폰에서 연결하려면 **설정 → 호스트 → 기기 페어링**을 여세요.
|
|
||||||
|
|
||||||
### CLI / 헤드리스 환경
|
|
||||||
|
|
||||||
CLI를 설치하고 Paseo를 시작하세요.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @getpaseo/cli
|
|
||||||
paseo
|
|
||||||
```
|
|
||||||
|
|
||||||
Paseo가 로컬에서 시작된 뒤 기기 페어링을 위한 종단 간 암호화 릴레이를 켤지 묻습니다. 거절하면 TCP, Tailscale 또는 다른 VPN으로 직접 연결할 수 있습니다. 이 방식은 서버나 원격 머신에서 유용합니다.
|
|
||||||
|
|
||||||
자세한 설치와 설정은 아래 문서를 참고하세요.
|
|
||||||
|
|
||||||
- [문서](https://paseo.sh/docs)
|
|
||||||
- [연결 가이드](https://paseo.sh/docs/connectivity)
|
|
||||||
- [설정 레퍼런스](https://paseo.sh/docs/configuration)
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
Docker에서 Paseo 데몬과 셀프 호스팅 웹 UI를 실행하세요:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d --name paseo \
|
|
||||||
-p 6767:6767 \
|
|
||||||
-e PASEO_PASSWORD=change-me \
|
|
||||||
-v "$PWD/paseo-home:/home/paseo" \
|
|
||||||
-v "$PWD:/workspace" \
|
|
||||||
ghcr.io/getpaseo/paseo:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
컨테이너가 시작되면 `http://localhost:6767`을 여세요. 사용하는 에이전트 CLI를 기본 이미지에 추가한 뒤, 환경 변수나 영구 `/home/paseo` 볼륨으로 인증 정보를 설정하세요. 자세한 내용은 [Docker 문서](docs/docker.md)를 참고하세요.
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
앱에서 할 수 있는 모든 작업은 터미널에서도 할 수 있습니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
|
||||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
|
||||||
|
|
||||||
paseo ls # 실행 중인 에이전트 목록
|
|
||||||
paseo attach abc123 # 실시간 출력 스트리밍
|
|
||||||
paseo send abc123 "also add tests" # 후속 작업 전송
|
|
||||||
|
|
||||||
# 원격 데몬에서 실행
|
|
||||||
paseo --host workstation.local:6767 run "run the full test suite"
|
|
||||||
```
|
|
||||||
|
|
||||||
자세한 내용은 [전체 CLI 레퍼런스](https://paseo.sh/docs/cli)를 참고하세요.
|
|
||||||
|
|
||||||
## 스킬
|
|
||||||
|
|
||||||
스킬은 에이전트가 Paseo를 통해 다른 에이전트를 오케스트레이션하는 방법을 알려 줍니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx skills add getpaseo/paseo
|
|
||||||
```
|
|
||||||
|
|
||||||
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
|
|
||||||
|
|
||||||
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
|
|
||||||
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
|
|
||||||
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
|
|
||||||
|
|
||||||
## 개발
|
|
||||||
|
|
||||||
모노레포 패키지 구성은 다음과 같습니다.
|
|
||||||
|
|
||||||
- `packages/server`: Paseo 데몬(에이전트 프로세스 오케스트레이션, WebSocket API, MCP 서버 제공)
|
|
||||||
- `packages/app`: Expo 클라이언트(iOS, Android, 웹)
|
|
||||||
- `packages/cli`: `paseo` CLI(데몬과 에이전트 워크플로)
|
|
||||||
- `packages/desktop`: Electron 데스크톱 앱
|
|
||||||
- `packages/relay`: 데몬과 클라이언트가 쓰는 릴레이 전송 및 암호화 패키지
|
|
||||||
- `packages/website`: 마케팅 사이트 및 문서(`paseo.sh`)
|
|
||||||
|
|
||||||
자주 쓰는 명령:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 모든 로컬 개발 서비스 실행
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# 개별 환경 실행
|
|
||||||
npm run dev:server
|
|
||||||
npm run dev:app
|
|
||||||
npm run dev:desktop
|
|
||||||
npm run dev:website
|
|
||||||
|
|
||||||
# 서버 스택 빌드
|
|
||||||
npm run build:server
|
|
||||||
|
|
||||||
# 레포 전체 검사 실행
|
|
||||||
npm run typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
## Paseo 후원하기
|
|
||||||
|
|
||||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
|
|
||||||
|
|
||||||
## 관련 프로젝트
|
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
|
||||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — 커뮤니티 테마와 Agent Skill을 제공하고, 코드 수정 없이 쓸 수 있는 데스크톱 테마 로더
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 확장 프로그램
|
|
||||||
|
|
||||||
## 라이선스
|
|
||||||
|
|
||||||
Apache-2.0
|
|
||||||
@@ -7,8 +7,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">English</a> ·
|
<a href="README.md">English</a> ·
|
||||||
<a href="README.zh-CN.md">简体中文</a> ·
|
<a href="README.zh-CN.md">简体中文</a> ·
|
||||||
<a href="README.ja.md">日本語</a> ·
|
<a href="README.ja.md">日本語</a>
|
||||||
<a href="README.ko.md">한국어</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.</p>
|
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||||
@@ -39,45 +38,20 @@
|
|||||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
> [!NOTE]
|
||||||
|
> I'm a solo maintainer and don't always keep up with GitHub Issues daily.
|
||||||
|
> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||||
|
|
||||||
- **Parallel agents:** Run many agents at once, each in its own worktree.
|
|
||||||
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
|
|
||||||
- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
|
||||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
|
||||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||||
|
|
||||||
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
|
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
|
||||||
<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="56"></picture></a><br><sub>Cloud coding agents</sub></td>
|
|
||||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
|
||||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Plugins
|
|
||||||
|
|
||||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
|
||||||
iOS, and Android. Write a plugin once and it is on your phone.
|
|
||||||
|
|
||||||
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
|
|
||||||
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
|
|
||||||
- **Providers:** add a coding agent as a provider.
|
|
||||||
|
|
||||||
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
|
|
||||||
|
|
||||||
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
|
|
||||||
|
|
||||||
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
||||||
@@ -91,30 +65,27 @@ You need at least one agent CLI installed and configured with your credentials:
|
|||||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||||
- [Pi](https://pi.dev)
|
- [Pi](https://pi.dev)
|
||||||
- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity)
|
|
||||||
- [Muse Code](https://paseo.sh/docs/muse-code)
|
|
||||||
|
|
||||||
### Desktop app (recommended)
|
### Desktop app (recommended)
|
||||||
|
|
||||||
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
|
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
|
||||||
|
|
||||||
To connect from your phone, open **Settings → your host → Pair Device**.
|
To connect from your phone, scan the QR code shown in Settings.
|
||||||
|
|
||||||
### Server
|
### CLI / headless
|
||||||
|
|
||||||
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
|
Install the CLI and start Paseo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @getpaseo/cli
|
npm install -g @getpaseo/cli
|
||||||
paseo
|
paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
|
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
||||||
|
|
||||||
For full setup and configuration, see:
|
For full setup and configuration, see:
|
||||||
|
|
||||||
- [Docs](https://paseo.sh/docs)
|
- [Docs](https://paseo.sh/docs)
|
||||||
- [Connectivity guide](https://paseo.sh/docs/connectivity)
|
|
||||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -138,42 +109,18 @@ Everything you can do in the app, you can do from the terminal.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||||
paseo run --provider codex/gpt-5.5 --worktree feature-x "implement feature X"
|
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||||
|
|
||||||
paseo ls # list running agents
|
paseo ls # list running agents
|
||||||
paseo attach abc123 # stream live output
|
paseo attach abc123 # stream live output
|
||||||
paseo send abc123 "also add tests" # follow-up task
|
paseo send abc123 "also add tests" # follow-up task
|
||||||
|
|
||||||
# run on a remote daemon; --cwd is a path on that host
|
# run on a remote daemon
|
||||||
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
|
paseo --host workstation.local:6767 run "run the full test suite"
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||||
|
|
||||||
## TypeScript SDK
|
|
||||||
|
|
||||||
Build issue integrations, dashboards, and orchestration services with `@getpaseo/client`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createPaseoClient } from "@getpaseo/client";
|
|
||||||
|
|
||||||
const client = createPaseoClient({ url: "ws://127.0.0.1:6767/ws" });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
const agent = await client.agents.create({
|
|
||||||
config: { provider: "codex/gpt-5.5" },
|
|
||||||
cwd: "/Users/me/dev/storefront",
|
|
||||||
prompt: "Review the current diff and name the riskiest change.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await agent.waitForFinish();
|
|
||||||
console.log(result.lastMessage);
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [SDK quickstart](https://paseo.sh/docs/sdk/quickstart), [recipes](https://paseo.sh/docs/sdk/recipes), and [API reference](https://paseo.sh/docs/sdk/reference).
|
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Skills teach your agent to use Paseo to orchestrate other agents.
|
Skills teach your agent to use Paseo to orchestrate other agents.
|
||||||
@@ -185,6 +132,7 @@ npx skills add getpaseo/paseo
|
|||||||
Then use them in any agent conversation:
|
Then use them in any agent conversation:
|
||||||
|
|
||||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||||
|
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||||
|
|
||||||
@@ -196,7 +144,7 @@ Quick monorepo package map:
|
|||||||
- `packages/app`: Expo client (iOS, Android, web)
|
- `packages/app`: Expo client (iOS, Android, web)
|
||||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||||
- `packages/desktop`: Electron desktop app
|
- `packages/desktop`: Electron desktop app
|
||||||
- `packages/relay`: Relay transport and encryption used by the daemon and clients
|
- `packages/relay`: Relay package for remote connectivity
|
||||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||||
|
|
||||||
Common commands:
|
Common commands:
|
||||||
@@ -218,15 +166,23 @@ npm run build:server
|
|||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support Paseo
|
## Community
|
||||||
|
|
||||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs.
|
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||||
|
|
||||||
## Related projects
|
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||||
|
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache-2.0
|
AGPL-3.0
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">English</a> ·
|
<a href="README.md">English</a> ·
|
||||||
<a href="README.zh-CN.md">简体中文</a> ·
|
<a href="README.zh-CN.md">简体中文</a> ·
|
||||||
<a href="README.ja.md">日本語</a> ·
|
<a href="README.ja.md">日本語</a>
|
||||||
<a href="README.ko.md">한국어</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -53,17 +52,6 @@
|
|||||||
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
|
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
|
||||||
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
|
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
|
||||||
|
|
||||||
## 赞助商
|
|
||||||
|
|
||||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
|
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
|
||||||
@@ -80,7 +68,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
|
|||||||
|
|
||||||
### 桌面 app(推荐)
|
### 桌面 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 中扫描显示的二维码。
|
如果要从手机连接,在 Settings 中扫描显示的二维码。
|
||||||
|
|
||||||
@@ -97,8 +85,8 @@ paseo
|
|||||||
|
|
||||||
完整安装和配置见:
|
完整安装和配置见:
|
||||||
|
|
||||||
- [文档](https://paseo.sh/zh-CN/docs)
|
- [文档](https://paseo.sh/docs)
|
||||||
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
|
- [配置参考](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@@ -116,7 +104,7 @@ paseo send abc123 "also add tests" # 发送后续任务
|
|||||||
paseo --host workstation.local:6767 run "run the full test suite"
|
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
|
## Skills
|
||||||
|
|
||||||
@@ -129,6 +117,7 @@ npx skills add getpaseo/paseo
|
|||||||
然后在任意 agent 对话中使用:
|
然后在任意 agent 对话中使用:
|
||||||
|
|
||||||
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
|
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
|
||||||
|
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
|
||||||
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
|
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
|
||||||
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
|
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
|
||||||
|
|
||||||
@@ -162,13 +151,9 @@ npm run build:server
|
|||||||
npm run typecheck
|
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-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||||
|
|
||||||
## 相关项目
|
|
||||||
|
|
||||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
|
||||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||||
|
|
||||||
### 自托管 relay TLS
|
### 自托管 relay TLS
|
||||||
@@ -217,6 +202,18 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||||
|
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache-2.0
|
AGPL-3.0
|
||||||
|
|||||||
@@ -21,10 +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`
|
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.
|
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.
|
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. 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.
|
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 wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||||
|
|
||||||
@@ -34,28 +32,22 @@ 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
|
- **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.
|
- **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
|
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||||
- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode
|
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||||
- **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.
|
- **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
|
### 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.
|
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.
|
|
||||||
|
|
||||||
## Local daemon trust boundary
|
## Local daemon trust boundary
|
||||||
|
|
||||||
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||||
|
|
||||||
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||||
|
|
||||||
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
|
||||||
|
|
||||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||||
|
|
||||||
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
|
|
||||||
|
|
||||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||||
|
|
||||||
In Docker, the official image runs the daemon and agents as the non-root
|
In Docker, the official image runs the daemon and agents as the non-root
|
||||||
@@ -72,26 +64,10 @@ CORS is not a complete security boundary. It controls which browser origins can
|
|||||||
|
|
||||||
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
|
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
|
||||||
|
|
||||||
## HTML file preview
|
|
||||||
|
|
||||||
Previewing an `.html` file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.
|
|
||||||
|
|
||||||
The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no `fetch`, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.
|
|
||||||
|
|
||||||
One gap remains on web and desktop: a sandboxed document may navigate _itself_, and no CSP directive in current browsers prevents that. `navigate-to` was dropped from CSP Level 3 and is not enforced, and `<meta http-equiv="refresh">` needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.
|
|
||||||
|
|
||||||
Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.
|
|
||||||
|
|
||||||
If you don't trust a page, read it in `Source`, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.
|
|
||||||
|
|
||||||
## Agent authentication
|
## Agent authentication
|
||||||
|
|
||||||
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
|
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
|
||||||
|
|
||||||
## Forge host trust
|
|
||||||
|
|
||||||
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
|
|
||||||
|
|
||||||
## Reporting vulnerabilities
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
|
||||||
|
|
||||||
ARG PASEO_VERSION
|
|
||||||
|
|
||||||
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
|
||||||
|
|
||||||
WORKDIR /tmp/paseo-src
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
if [ -n "${PASEO_VERSION:-}" ]; then \
|
|
||||||
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
|
||||||
fi; \
|
|
||||||
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
RUN set -eux; \
|
|
||||||
mkdir -p /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/plugin --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
|
||||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
|
||||||
|
|
||||||
FROM ${NODE_IMAGE}
|
FROM ${NODE_IMAGE}
|
||||||
|
|
||||||
|
ARG PASEO_VERSION=latest
|
||||||
|
|
||||||
ENV HOME=/home/paseo \
|
ENV HOME=/home/paseo \
|
||||||
PASEO_HOME=/home/paseo/.paseo \
|
PASEO_HOME=/home/paseo/.paseo \
|
||||||
PASEO_LISTEN=0.0.0.0:6767 \
|
PASEO_LISTEN=0.0.0.0:6767 \
|
||||||
@@ -53,14 +29,13 @@ RUN set -eux; \
|
|||||||
gosu \
|
gosu \
|
||||||
lbzip2 \
|
lbzip2 \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
procps \
|
|
||||||
tini; \
|
tini; \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
npm install -g /tmp/paseo-packs/*.tgz; \
|
npm install -g --omit=optional \
|
||||||
rm -rf /tmp/paseo-packs; \
|
"@getpaseo/server@${PASEO_VERSION}" \
|
||||||
|
"@getpaseo/cli@${PASEO_VERSION}"; \
|
||||||
npm cache clean --force; \
|
npm cache clean --force; \
|
||||||
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||||
test -f "$server_entry"; \
|
test -f "$server_entry"; \
|
||||||
@@ -91,7 +66,7 @@ RUN set -eux; \
|
|||||||
"$XDG_CACHE_HOME"; \
|
"$XDG_CACHE_HOME"; \
|
||||||
chown -R paseo:paseo /home/paseo /workspace
|
chown -R paseo:paseo /home/paseo /workspace
|
||||||
|
|
||||||
COPY docker/base/rootfs/ /
|
COPY rootfs/ /
|
||||||
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
ARG NODE_IMAGE=node:22-bookworm-slim
|
||||||
|
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
|
||||||
|
|
||||||
|
ARG PASEO_VERSION
|
||||||
|
|
||||||
|
ENV ONNXRUNTIME_NODE_INSTALL=skip
|
||||||
|
|
||||||
|
WORKDIR /tmp/paseo-src
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
|
||||||
|
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
mkdir -p /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||||
|
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE}
|
||||||
|
|
||||||
|
ENV HOME=/home/paseo \
|
||||||
|
PASEO_HOME=/home/paseo/.paseo \
|
||||||
|
PASEO_LISTEN=0.0.0.0:6767 \
|
||||||
|
PASEO_WEB_UI_ENABLED=true \
|
||||||
|
PASEO_LOG_FORMAT=json \
|
||||||
|
PASEO_LOG_LEVEL=info \
|
||||||
|
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
|
||||||
|
CODEX_HOME=/home/paseo/.codex \
|
||||||
|
XDG_CONFIG_HOME=/home/paseo/.config \
|
||||||
|
XDG_DATA_HOME=/home/paseo/.local/share \
|
||||||
|
XDG_STATE_HOME=/home/paseo/.local/state \
|
||||||
|
XDG_CACHE_HOME=/home/paseo/.cache \
|
||||||
|
ONNXRUNTIME_NODE_INSTALL=skip
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
gosu \
|
||||||
|
lbzip2 \
|
||||||
|
openssh-client \
|
||||||
|
tini; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
|
||||||
|
RUN set -eux; \
|
||||||
|
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
|
||||||
|
rm -rf /tmp/paseo-packs; \
|
||||||
|
npm cache clean --force; \
|
||||||
|
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
|
||||||
|
test -f "$server_entry"; \
|
||||||
|
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
|
||||||
|
node --check "$server_entry"
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
|
||||||
|
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
|
||||||
|
groupmod --new-name paseo "$existing_group"; \
|
||||||
|
elif [ -z "$existing_group" ]; then \
|
||||||
|
groupadd --gid 1000 paseo; \
|
||||||
|
fi; \
|
||||||
|
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
|
||||||
|
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
|
||||||
|
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
|
||||||
|
elif [ -z "$existing_user" ]; then \
|
||||||
|
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
|
||||||
|
fi; \
|
||||||
|
mkdir -p \
|
||||||
|
/workspace \
|
||||||
|
"$PASEO_HOME" \
|
||||||
|
"$CLAUDE_CONFIG_DIR" \
|
||||||
|
"$CODEX_HOME" \
|
||||||
|
"$XDG_CONFIG_HOME" \
|
||||||
|
"$XDG_DATA_HOME" \
|
||||||
|
"$XDG_STATE_HOME" \
|
||||||
|
"$XDG_CACHE_HOME"; \
|
||||||
|
chown -R paseo:paseo /home/paseo /workspace
|
||||||
|
|
||||||
|
COPY docker/base/rootfs/ /
|
||||||
|
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
EXPOSE 6767
|
||||||
|
VOLUME ["/home/paseo"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
|
||||||
@@ -51,7 +51,7 @@ const client = new DaemonClient({
|
|||||||
appVersion: "0.1.70", // see gotcha #1
|
appVersion: "0.1.70", // see gotcha #1
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
await client.fetchAgents({ subscribe: {} });
|
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||||
|
|
||||||
// ... do your testing ...
|
// ... do your testing ...
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ const client = new DaemonClient({
|
|||||||
appVersion: "0.1.70",
|
appVersion: "0.1.70",
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
await client.fetchAgents({ subscribe: {} });
|
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||||
|
|
||||||
// ... test ...
|
// ... test ...
|
||||||
|
|
||||||
|
|||||||
@@ -10,190 +10,33 @@ initializing → idle → running → idle (or error → closed)
|
|||||||
└────────┘ (agent completes a turn, awaits next prompt)
|
└────────┘ (agent completes a turn, awaits next prompt)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||||
|
|
||||||
## Runtime residency
|
|
||||||
|
|
||||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
|
||||||
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
|
|
||||||
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
|
|
||||||
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
|
|
||||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
|
||||||
primed.
|
|
||||||
|
|
||||||
Reload releases the old runtime before resuming its durable session: an idle provider process can
|
|
||||||
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).
|
|
||||||
|
|
||||||
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
|
|
||||||
workflows all live in the CLI process, and the completion notification that would have woken the
|
|
||||||
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
|
|
||||||
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
|
|
||||||
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
|
|
||||||
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
|
|
||||||
the unfinished turn is lost.
|
|
||||||
|
|
||||||
### Cancellation
|
|
||||||
|
|
||||||
Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior
|
|
||||||
foreground turn can no longer run, including when the provider reports that it is already idle. It
|
|
||||||
rejects only when the provider may still own the turn. Provider adapters translate native errors
|
|
||||||
into that contract; lifecycle callers do not interpret provider-specific errors.
|
|
||||||
|
|
||||||
After an acknowledged interrupt, the manager settles the captured run even when no terminal event
|
|
||||||
arrives or the run was still waiting for its provider turn id. The captured run token prevents an
|
|
||||||
older cancellation from settling a newer turn. If interruption is rejected or times out, the agent
|
|
||||||
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
|
|
||||||
Accepting new work after an ambiguous interruption would create a split-brain session.
|
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
|
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
|
||||||
|
|
||||||
|
- `relationship` decides whether the new agent belongs under the caller.
|
||||||
|
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
|
||||||
|
|
||||||
|
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
|
||||||
|
|
||||||
|
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
|
||||||
|
|
||||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
|
||||||
|
|
||||||
Parent archive detaches a subagent instead of archiving it when either condition holds:
|
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
|
||||||
|
|
||||||
- The child belongs to another workspace.
|
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: 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.
|
||||||
- The child is currently open in an agent tab.
|
|
||||||
|
|
||||||
All other children archive with the parent. After the workspace layout hydrates, the client marks
|
`notifyOnFinish` defaults to `true` for agent-scoped creation and background 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.
|
||||||
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
|
|
||||||
generic agent metadata update. This includes background and restored tabs; navigation does not own
|
|
||||||
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
|
|
||||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Provider-managed child agents
|
|
||||||
|
|
||||||
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
|
|
||||||
|
|
||||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
|
||||||
|
|
||||||
## Background workspaces
|
|
||||||
|
|
||||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
|
||||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
|
||||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
|
||||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
|
||||||
creates one without an override, and accept explicit true or false.
|
|
||||||
|
|
||||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
|
||||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
|
||||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
|
||||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
|
||||||
|
|
||||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
|
||||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
|
||||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
|
||||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
|
||||||
|
|
||||||
### Private daemon helpers
|
|
||||||
|
|
||||||
Branch-name and commit-message generators still use private disposable internal runtimes.
|
|
||||||
`AgentManager` does not persist them or their provider sessions, excludes them from public
|
|
||||||
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
|
|
||||||
its exact-ID result briefly to cover completion before a waiter attaches. This internal
|
|
||||||
lifecycle is separate from public workspace visibility and is not a public creation option.
|
|
||||||
|
|
||||||
## Archive
|
## Archive
|
||||||
|
|
||||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||||
|
|
||||||
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
|
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
|
||||||
children.
|
|
||||||
|
|
||||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
|
|
||||||
|
|
||||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||||
|
|
||||||
@@ -201,45 +44,10 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
|||||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||||
3. Notify subscribers
|
3. Notify subscribers
|
||||||
4. Close the runtime (kills the process if still running)
|
4. Close the runtime (kills the process if still running)
|
||||||
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
|
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||||
|
|
||||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||||
|
|
||||||
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
|
|
||||||
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
|
|
||||||
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
|
|
||||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
|
||||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
|
||||||
|
|
||||||
History navigation opens the selected agent without changing either archive state. Workspace
|
|
||||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
|
||||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
|
||||||
history hydration. Other archived agents stay archived.
|
|
||||||
|
|
||||||
Opening an agent is a navigation choice, independent of whether its details are cached. The
|
|
||||||
layout retains that choice across reload while the panel fetches the agent from the daemon.
|
|
||||||
Once the daemon reports the agent active, its tab follows normal archive propagation again.
|
|
||||||
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
|
|
||||||
not own selection or release the explicit open.
|
|
||||||
|
|
||||||
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
|
|
||||||
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
|
|
||||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
|
||||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
|
||||||
|
|
||||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
|
|
||||||
history session contains only the read results and must release its provider process once replay
|
|
||||||
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
|
|
||||||
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
|
|
||||||
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
|
|
||||||
before returning. Interactive resume remains responsible for repairing a provider session archived
|
|
||||||
outside Paseo while its Paseo agent is active.
|
|
||||||
|
|
||||||
Provider session connection owns every process it spawns until the session is registered with
|
|
||||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
|
||||||
`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never
|
|
||||||
received.
|
|
||||||
|
|
||||||
## Tabs vs archive
|
## Tabs vs archive
|
||||||
|
|
||||||
These are two distinct concepts that used to be conflated:
|
These are two distinct concepts that used to be conflated:
|
||||||
@@ -251,69 +59,27 @@ These are two distinct concepts that used to be conflated:
|
|||||||
|
|
||||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||||
|
|
||||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
|
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||||
|
|
||||||
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
|
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||||
|
|
||||||
## Workspace activity
|
## Workspace activity
|
||||||
|
|
||||||
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
||||||
|
|
||||||
Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.
|
Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||||
|
|
||||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
|
||||||
|
|
||||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
|
||||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
|
||||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
|
||||||
|
|
||||||
Attention is set by the agent finishing or failing and cleared by the client's
|
|
||||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
|
||||||
neither, so resuming carries the stored attention and the stored last-activity time through
|
|
||||||
untouched. Forging either makes a background resume look like the user read a workspace and like the
|
|
||||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
|
||||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
|
||||||
|
|
||||||
## The subagents track
|
## The subagents track
|
||||||
|
|
||||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||||
|
|
||||||
The rows combine two kinds of children:
|
|
||||||
|
|
||||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
parentAgentId === thisAgent.id AND !archivedAt
|
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.
|
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||||
|
|
||||||
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.
|
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.
|
|
||||||
|
|
||||||
### Claude provider subagents: the task protocol
|
|
||||||
|
|
||||||
Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:
|
|
||||||
|
|
||||||
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
|
|
||||||
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
|
|
||||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
|
|
||||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
|
||||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
|
||||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
|
||||||
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
|
|
||||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
|
||||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
|
||||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
|
||||||
|
|
||||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
|
||||||
|
|
||||||
The **Archive finished** row at the foot of the panel covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
|
|
||||||
|
|
||||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
|
||||||
|
|
||||||
## Why this shape
|
## Why this shape
|
||||||
|
|
||||||
@@ -331,7 +97,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
|||||||
|
|
||||||
### Subagent accumulation under long-lived parents
|
### Subagent accumulation under long-lived parents
|
||||||
|
|
||||||
A parent that spawns many subagents will see the panel's list grow; the pill only counts them. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
|
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||||
|
|
||||||
### Cross-client tab dismissal
|
### Cross-client tab dismissal
|
||||||
|
|
||||||
@@ -347,12 +113,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
|||||||
|
|
||||||
Each agent is a single JSON file. Fields relevant to this doc:
|
Each agent is a single JSON file. Fields relevant to this doc:
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `id` | `string` | Stable identifier |
|
| `id` | `string` | Stable identifier |
|
||||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
|
||||||
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
|
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
|
||||||
|
|
||||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
# Agent stream performance
|
|
||||||
|
|
||||||
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
|
|
||||||
|
|
||||||
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
|
|
||||||
|
|
||||||
## The pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
provider deltas (every provider streams incrementally)
|
|
||||||
→ AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent)
|
|
||||||
→ recordTimeline: one canonical row per flushed item
|
|
||||||
→ agent_stream ws message
|
|
||||||
→ reducer queue (app, one commit per frame) → session store
|
|
||||||
→ source-item plugin transforms → Markdown presentation / tool grouping
|
|
||||||
→ paced reveal (app, per displayed item) → paint
|
|
||||||
```
|
|
||||||
|
|
||||||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
|
||||||
|
|
||||||
## Why the reveal is paced
|
|
||||||
|
|
||||||
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
|
|
||||||
|
|
||||||
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
|
||||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
|
||||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
|
||||||
- **One source assistant message is one display row.** Presentation projects plugin output and caches unchanged history. The shared [Markdown renderer](markdown-renderer.md) owns rendering on every platform. Chat paces the visible source and applies a 32,000-character render cap on web; native keeps the complete message. The native renderer keeps closed blocks in a stable head and sends the paced suffix to a separate tail view during streaming; completion reunites them for selection. This stays inside the renderer, without promoting blocks into timeline history.
|
|
||||||
- **Message identity survives completion.** Assistant row ids are source message ids. Plugin rows can replace a source, so find, scroll-to-message and history reveal still use `getStreamItemMessageId`. Width-specific measurements describe whole rendered messages.
|
|
||||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
|
||||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
|
||||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
|
||||||
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
|
||||||
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
|
||||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
|
||||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
|
||||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
|
||||||
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
|
|
||||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
|
||||||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
|
||||||
silently stops.
|
|
||||||
- **Streaming cost must not grow with revealed history.** Reading older messages must not
|
|
||||||
make the next reply progressively more expensive to display. Reuse unchanged history work
|
|
||||||
while text arrives, and release obsolete window caches as the reader reveals more history;
|
|
||||||
window caches must stay bounded while their source timeline remains loaded.
|
|
||||||
|
|
||||||
## Native parsing cost
|
|
||||||
|
|
||||||
The native library reparses and measures its entire `markdown` prop on each update. During streaming, the renderer splits source at an mdast block boundary and memoizes the head, so per-frame updates parse only the tail. Boundary discovery runs when a source line completes, rather than on every reveal frame. The unfinished line and final parsed block stay in the tail, preserving list continuation, setext headings, tables and open fences. Reference-dependent source and HTML retain a larger tail because later source can change their meaning. A single long block therefore still carries its full parsing cost.
|
|
||||||
|
|
||||||
Completion restores one native text view per Mermaid-delimited segment, so selection crosses prose paragraphs again. The head retains its trailing block margin while split. JS reveal remains the pacing source; native opacity animation stays disabled.
|
|
||||||
|
|
||||||
## Measuring
|
|
||||||
|
|
||||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
|
||||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
|
||||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
|
||||||
- **Per-delta cost on a long timeline:** `npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts` from `packages/app` replays 200 deltas over a 2,000-item timeline through the pure model and layout pipeline, with the default mounted window and with history fully revealed. It measures the JS work per delta, not paint.
|
|
||||||
|
|
||||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
|
||||||
|
|
||||||
| | paint on arrival | paced |
|
|
||||||
| -------------------------------- | ---------------- | ----- |
|
|
||||||
| frames that advanced the text | 6% | 87% |
|
|
||||||
| chars-per-frame CV | 4.11 | 1.86 |
|
|
||||||
| gap between visible updates, p50 | 317ms | 17ms |
|
|
||||||
| gap between visible updates, p95 | 383ms | 17ms |
|
|
||||||
|
|
||||||
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
|
|
||||||
|
|
||||||
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
|
|
||||||
@@ -13,226 +13,31 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
|
|||||||
|
|
||||||
`development` uses Android `debug`.
|
`development` uses Android `debug`.
|
||||||
|
|
||||||
## Version codes
|
|
||||||
|
|
||||||
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
|
|
||||||
|
|
||||||
The base version code comes from the package version:
|
|
||||||
|
|
||||||
```text
|
|
||||||
major * 1_000_000 + minor * 1_000 + patch
|
|
||||||
```
|
|
||||||
|
|
||||||
Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce `1102`. The same value is used as the iOS `buildNumber` because `packages/app/eas.json` uses EAS's local app version source. Do not re-enable EAS remote version counters or Android `autoIncrement`; F-Droid and other source-based builders need the native build number to be visible in the repo.
|
|
||||||
|
|
||||||
The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release.
|
|
||||||
|
|
||||||
## Prerequisites (local dev)
|
|
||||||
|
|
||||||
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise install # java 21 + android-sdk 21.0 command-line tools
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
|
|
||||||
|
|
||||||
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sdkmanager --licenses
|
|
||||||
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
|
|
||||||
"system-images;android-35;google_apis;arm64-v8a"
|
|
||||||
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
|
|
||||||
emulator @paseo # start it; leave running
|
|
||||||
```
|
|
||||||
|
|
||||||
On an Intel Mac, use the `x86_64` system image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sdkmanager --licenses
|
|
||||||
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
|
|
||||||
"system-images;android-35;google_apis;x86_64"
|
|
||||||
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7
|
|
||||||
emulator @paseo # start it; leave running
|
|
||||||
```
|
|
||||||
|
|
||||||
Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust `android-35` only if it asks for a different level.
|
|
||||||
|
|
||||||
## Local build + install
|
## 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:
|
From repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
|
npm run android:development # Debug build
|
||||||
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
|
npm run android:production # Release build
|
||||||
npm run dev:android # Build/install the debug app and start development
|
npm run android:clear # Remove generated Android project
|
||||||
npm run dev:ios # Build/install the iOS development app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
|
Or from `packages/app`:
|
||||||
`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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PASEO_PROFILE_BUILD=1 npm run android:production
|
# Debug
|
||||||
|
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
|
||||||
|
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||||
|
|
||||||
|
# Release
|
||||||
|
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
|
||||||
|
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||||
|
|
||||||
|
# Clear generated Android project
|
||||||
|
rm -rf android
|
||||||
```
|
```
|
||||||
|
|
||||||
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
|
|
||||||
they do not produce Downloads files.
|
|
||||||
|
|
||||||
## Running on an emulator against a worktree daemon
|
|
||||||
|
|
||||||
`npm run android` builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and **the emulator does not share the host's loopback**: `localhost` inside the emulator is the emulator itself. Reach the host at `10.0.2.2` (the standard AVD's host alias) for both:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
|
|
||||||
EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \
|
|
||||||
npm run android
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2`** — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with `Failed to connect to /<lan-ip>:8081` before any JS loads.
|
|
||||||
- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port>`** — the client's daemon endpoint (`packages/app/src/runtime/host-runtime.ts`); when unset it defaults to `localhost:6767`, the production daemon. Use `$PASEO_SERVICE_DAEMON_PORT` for a worktree daemon running as a Paseo service, or `6768` for a standalone `npm run dev:server`. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (`npx expo start -c`) if a change doesn't take.
|
|
||||||
|
|
||||||
**Alternative — `adb reverse` + `localhost`** (if `10.0.2.2` misbehaves):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
adb reverse tcp:8081 tcp:8081
|
|
||||||
adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT
|
|
||||||
REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
|
||||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \
|
|
||||||
npm run android
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
|
||||||
|
|
||||||
## Inverted timeline selection
|
|
||||||
|
|
||||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
|
||||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
|
||||||
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
|
|
||||||
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
|
|
||||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
|
||||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
|
||||||
|
|
||||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
|
||||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
|
||||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
|
||||||
|
|
||||||
## F-Droid / source-only Android builds
|
|
||||||
|
|
||||||
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd packages/app
|
|
||||||
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
|
|
||||||
cd android
|
|
||||||
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
|
||||||
```
|
|
||||||
|
|
||||||
The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.
|
|
||||||
|
|
||||||
For a single-ABI APK, pass React Native's architecture property to Gradle:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \
|
|
||||||
-PreactNativeArchitectures=arm64-v8a \
|
|
||||||
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code.
|
|
||||||
|
|
||||||
Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.
|
|
||||||
|
|
||||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
|
||||||
|
|
||||||
### F-Droid store metadata
|
|
||||||
|
|
||||||
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/` **at the repo root**. This location provides the best compatibility with the F-Droid release process.
|
|
||||||
|
|
||||||
```text
|
|
||||||
fastlane/metadata/android/
|
|
||||||
├── en-US/ (F-Droid fallback locale, mandatory)
|
|
||||||
│ ├── title.txt (<=50 chars)
|
|
||||||
│ ├── short_description.txt (<=80 chars)
|
|
||||||
│ ├── full_description.txt (<=4000 chars, limited HTML)
|
|
||||||
│ ├── images/
|
|
||||||
│ │ ├── icon.png (512x512)
|
|
||||||
│ │ ├── featureGraphic.png (1024x500)
|
|
||||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
|
||||||
│ └── changelogs/ (generated — see below)
|
|
||||||
├── ja/
|
|
||||||
└── zh-CN/
|
|
||||||
```
|
|
||||||
|
|
||||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
|
||||||
|
|
||||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
|
||||||
|
|
||||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
|
||||||
|
|
||||||
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
|
|
||||||
|
|
||||||
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
|
|
||||||
|
|
||||||
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
|
|
||||||
|
|
||||||
### React version lockstep
|
### React version lockstep
|
||||||
|
|
||||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||||
@@ -247,12 +52,12 @@ adb exec-out screencap -p > screenshot.png
|
|||||||
|
|
||||||
Stable tag pushes like `v0.1.0` trigger:
|
Stable tag pushes like `v0.1.0` trigger:
|
||||||
|
|
||||||
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
|
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||||
|
|
||||||
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
|
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||||
|
|
||||||
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
|
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||||
|
|
||||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## System overview
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -57,50 +54,22 @@ The heart of Paseo. A Node.js process that:
|
|||||||
|
|
||||||
All paths are under `packages/server/src/`.
|
All paths are under `packages/server/src/`.
|
||||||
|
|
||||||
Desktop and CLI import server capabilities through explicit package subpaths such as
|
|
||||||
`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export
|
|
||||||
loads daemon bootstrap eagerly, even when a caller only uses a path or process helper.
|
|
||||||
Keep process-management and configuration dependencies independent of bootstrap and
|
|
||||||
WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules;
|
|
||||||
`messages` imports and re-exports them. This prevents every supervising process from
|
|
||||||
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
|
|
||||||
for empty projects and without connected clients, then fans metadata changes through the WebSocket
|
|
||||||
server to capability-aware sessions. It deliberately does not use the broad recursive working-tree
|
|
||||||
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:**
|
**Key modules:**
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
| ------------------------------- | ------------------------------------------------------------------------------ |
|
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||||
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
|
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||||
| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions |
|
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
|
||||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
| `server/schedule/` | Cron-based scheduled agents |
|
||||||
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
|
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||||
| `server/schedule/` | Cron-based scheduled agents |
|
|
||||||
|
|
||||||
### `packages/protocol` — Wire schemas and shared protocol types
|
### `packages/protocol` — Wire schemas and shared protocol types
|
||||||
|
|
||||||
@@ -116,92 +85,47 @@ facade. App and CLI may import the low-level driver from
|
|||||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||||
code imports from `@getpaseo/client`.
|
code imports from `@getpaseo/client`.
|
||||||
|
|
||||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
|
||||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
|
||||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
|
|
||||||
[the client contract](protocol-compatibility.md#client-capability-ownership).
|
|
||||||
|
|
||||||
### `packages/app` — Mobile + web client (Expo)
|
### `packages/app` — Mobile + web client (Expo)
|
||||||
|
|
||||||
Cross-platform React Native app that connects to one or more daemons.
|
Cross-platform React Native app that connects to one or more daemons.
|
||||||
|
|
||||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
|
||||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
|
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||||
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
|
|
||||||
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
|
||||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
|
||||||
- `SessionContext` wraps the daemon client for the active session
|
- `SessionContext` wraps the daemon client for the active session
|
||||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||||
- Voice features: dictation (STT) and voice agent (realtime)
|
- Voice features: dictation (STT) and voice agent (realtime)
|
||||||
|
|
||||||
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
|
|
||||||
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
|
|
||||||
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
|
|
||||||
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
|
|
||||||
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
|
|
||||||
reads directory rows for that host and establishes its live subscription when connected. Accepted
|
|
||||||
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
|
|
||||||
host connection do not create directory demand or install replicas. The directory owner retains
|
|
||||||
declared surface demand and re-establishes network reconciliation after reconnect; React does not
|
|
||||||
track connection generations. Late cache reads cannot replace state already advanced by live or
|
|
||||||
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
|
|
||||||
checkpoint share one storage transaction. See
|
|
||||||
[data-model.md](data-model.md#replica-row-store)
|
|
||||||
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
|
|
||||||
resume behavior.
|
|
||||||
|
|
||||||
The three directory entity types have independent monotonic sequences and share one daemon
|
|
||||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
|
||||||
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
|
|
||||||
independent records; a project with no workspaces does not need a workspace placeholder.
|
|
||||||
|
|
||||||
Workspace label definitions use a separate, explicitly subscribed sequence. The list request both
|
|
||||||
fetches and grants live updates for that session. A current cursor receives an empty correlated
|
|
||||||
catch-up response when nothing changed; idle sessions and unsubscribed sessions receive no label
|
|
||||||
traffic. Workspace assignments stay on the workspace directory sequence.
|
|
||||||
|
|
||||||
### `packages/cli` — Command-line client
|
### `packages/cli` — Command-line client
|
||||||
|
|
||||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||||
|
|
||||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||||
- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password`
|
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||||
|
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||||
- `paseo script ls/start/stop`
|
- `paseo loop run/ls/inspect/logs/stop`
|
||||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||||
- `paseo heartbeat create/update/delete`
|
|
||||||
- `paseo project create/ls/rename/delete`
|
|
||||||
- `paseo workspace create/ls/rename/archive`
|
|
||||||
- `paseo permit allow/deny/ls`
|
- `paseo permit allow/deny/ls`
|
||||||
- `paseo provider ls/models`
|
- `paseo provider ls/models`
|
||||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
- `paseo worktree create/ls/archive`
|
||||||
- `paseo speech …`
|
- `paseo speech …`
|
||||||
|
|
||||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||||
|
|
||||||
### `packages/relay` — Relay transport and E2E encryption
|
### `packages/relay` — E2E encrypted relay
|
||||||
|
|
||||||
Enables remote access when the daemon is behind a firewall.
|
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 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
- Relay server is zero-knowledge — it routes encrypted bytes, 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
|
- 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
|
|
||||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||||
|
|
||||||
The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed.
|
|
||||||
|
|
||||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||||
|
|
||||||
### Paseo Hub
|
|
||||||
|
|
||||||
The optional Hub relationship is daemon-outbound and does not use the relay. Its connection,
|
|
||||||
authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md).
|
|
||||||
|
|
||||||
### `packages/desktop` — Desktop app (Electron)
|
### `packages/desktop` — Desktop app (Electron)
|
||||||
|
|
||||||
Electron wrapper for macOS, Linux, and Windows.
|
Electron wrapper for macOS, Linux, and Windows.
|
||||||
@@ -210,33 +134,11 @@ Electron wrapper for macOS, Linux, and Windows.
|
|||||||
- Native file access for workspace integration
|
- Native file access for workspace integration
|
||||||
- Same WebSocket client as mobile app
|
- Same WebSocket client as mobile app
|
||||||
|
|
||||||
The desktop does not manage agent skills. It retains one compatibility reader for the old
|
|
||||||
`skill-selection.json`, imports that preference into its managed local daemon, then deletes the old
|
|
||||||
file after the daemon confirms persistence.
|
|
||||||
|
|
||||||
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
|
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
|
||||||
|
|
||||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||||
>
|
>
|
||||||
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `<webview>` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
|
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||||
>
|
|
||||||
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
|
|
||||||
>
|
|
||||||
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
|
|
||||||
>
|
|
||||||
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Human key -> guest WebContents
|
|
||||||
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
|
|
||||||
`-- page keydown
|
|
||||||
|-- page prevents ------> page owns it
|
|
||||||
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
|
|
||||||
|
|
||||||
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|
|
||||||
|-- guest handles ------------> page owns it
|
|
||||||
`-- guest does not handle ----> stop; never redispatch to the host window
|
|
||||||
```
|
|
||||||
|
|
||||||
### `packages/website` — Marketing site
|
### `packages/website` — Marketing site
|
||||||
|
|
||||||
@@ -261,28 +163,15 @@ Server → Client: status message with payload { status: "server_info",
|
|||||||
serverId, hostname, version, capabilities?, features }
|
serverId, hostname, version, capabilities?, features }
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no dedicated welcome message; the server emits one initial `status.server_info` after
|
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||||
accepting hello. Modern clients negotiate `owned_subscriptions` against
|
|
||||||
`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data
|
|
||||||
demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client
|
|
||||||
correlates the initial reply by request ID, then routes updates by subscription ID.
|
|
||||||
|
|
||||||
A logical Session can contain several physical sockets. Each socket owns its capabilities,
|
|
||||||
subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's
|
|
||||||
request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown.
|
|
||||||
The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges
|
|
||||||
its teardown; reconnect creates new server IDs for surviving client handles. See
|
|
||||||
[protocol compatibility](protocol-compatibility.md) for the legacy boundary.
|
|
||||||
|
|
||||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||||
|
|
||||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||||
|
|
||||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
|
||||||
|
|
||||||
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
|
||||||
|
|
||||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||||
|
|
||||||
**Notable session message types:**
|
**Notable session message types:**
|
||||||
|
|
||||||
@@ -292,24 +181,10 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such
|
|||||||
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
|
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
|
||||||
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
|
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
|
||||||
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
|
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
|
||||||
|
|
||||||
Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events
|
|
||||||
optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's
|
|
||||||
status once at the directory boundary rather than maintaining a second activity model.
|
|
||||||
|
|
||||||
- Terminal subscribe/input/capture commands
|
- Terminal subscribe/input/capture commands
|
||||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||||
|
|
||||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
|
||||||
configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy,
|
|
||||||
entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A
|
|
||||||
request without `cwd` searches the host home for absolute project paths; a request with `cwd`
|
|
||||||
searches that workspace and returns relative entries. Clients may prepend their small host-scoped
|
|
||||||
recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a
|
|
||||||
correlated daemon response. The legacy `directories` response field remains a projection of the
|
|
||||||
typed `entries` list.
|
|
||||||
|
|
||||||
**Binary frames (terminal stream protocol):**
|
**Binary frames (terminal stream protocol):**
|
||||||
|
|
||||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||||
@@ -321,16 +196,12 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
|
|||||||
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
|
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
|
||||||
|
|
||||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||||
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
|
|
||||||
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
|
|
||||||
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
|
|
||||||
messages or transfers.
|
|
||||||
|
|
||||||
### Compatibility rules
|
### Compatibility rules
|
||||||
|
|
||||||
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
|
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
|
||||||
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
|
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
|
||||||
- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities.
|
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||||
|
|
||||||
Example: adding a new enum value
|
Example: adding a new enum value
|
||||||
|
|
||||||
@@ -341,40 +212,6 @@ Example: adding a new enum value
|
|||||||
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
|
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creation ownership
|
|
||||||
|
|
||||||
Creation is owned by the daemon across socket lifetimes. A workspace request can include
|
|
||||||
its initial agent and prompt. Workspace readiness is published before provider startup;
|
|
||||||
the app keeps its existing disk-ready navigation and observes the remaining creation.
|
|
||||||
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
|
|
||||||
identities, not ready workspace records.
|
|
||||||
|
|
||||||
The creation journal (`server/creation/`) owns identity and execution for both legacy and
|
|
||||||
modern creation RPCs. Requesting progress never selects a different journal. Existing
|
|
||||||
agent receipts are imported at this boundary; message delivery receipts remain separate.
|
|
||||||
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
|
|
||||||
key. Reconnect subscribes to that key; retries
|
|
||||||
join active work or reuse committed stages. A persisted resource alone cannot prove
|
|
||||||
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
|
|
||||||
receipt return an unknown outcome instead of being repeated.
|
|
||||||
|
|
||||||
Agent registration commits creation. Rejecting the first prompt is a turn error on that
|
|
||||||
agent; reporting creation failure leaves an orphaned agent and invites another Create.
|
|
||||||
Receipt replay must not resend the prompt.
|
|
||||||
|
|
||||||
`packages/client/src/creation/` owns capability selection and legacy orchestration.
|
|
||||||
Callers always pass the initial prompt to agent creation. On an older host, the client
|
|
||||||
adapts keyed creation to the legacy create/send sequence; it cannot continue that
|
|
||||||
sequence after the client process disappears. Explicitly requested IDs or receipts
|
|
||||||
that an old host cannot honor produce an unsupported error. Keep these adapters inside
|
|
||||||
the client package, as an exception to the default no-fallback feature policy.
|
|
||||||
|
|
||||||
Creation executes through the existing Session capabilities. Connection-owned delivery
|
|
||||||
controls observation only: detaching a socket or cleaning up its Session does not cancel
|
|
||||||
accepted creation. Updates require an explicit subscription and go only to that socket;
|
|
||||||
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
|
|
||||||
existing response contract.
|
|
||||||
|
|
||||||
## Agent lifecycle
|
## Agent lifecycle
|
||||||
|
|
||||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||||
@@ -396,13 +233,10 @@ initializing → idle ⇄ running
|
|||||||
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
|
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
|
||||||
|
|
||||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||||
- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one
|
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||||
permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted
|
|
||||||
prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for
|
|
||||||
client-side dedup; the default fetch page is 200 items.
|
|
||||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`. Timeline rows are runtime memory; provider history is the durable transcript authority and resumed agents rebuild from it. That storage path is derived from `cwd`, not from workspace id.
|
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
|
||||||
|
|
||||||
## Right-sidebar boundary: directory-backed vs workspace-owned
|
## Right-sidebar boundary: directory-backed vs workspace-owned
|
||||||
|
|
||||||
@@ -410,26 +244,26 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
|
|||||||
|
|
||||||
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
|
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
|
||||||
|
|
||||||
| Surface | Key | Source |
|
| Surface | Key | Source |
|
||||||
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||||
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
|
||||||
| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
|
||||||
| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
|
||||||
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
|
||||||
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||||
|
|
||||||
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
|
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
|
||||||
|
|
||||||
| State | Key builder / store | Source |
|
| State | Key builder / store | Source |
|
||||||
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` |
|
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
|
||||||
| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` |
|
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
|
||||||
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
|
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
|
||||||
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||||
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
|
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
|
||||||
|
|
||||||
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys.
|
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
|
||||||
|
|
||||||
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
|
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
|
||||||
|
|
||||||
@@ -477,24 +311,21 @@ Providers that can accept native tool definitions should set `supportsNativePase
|
|||||||
|
|
||||||
```
|
```
|
||||||
$PASEO_HOME/
|
$PASEO_HOME/
|
||||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record
|
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
||||||
├── projects/projects.json # Project registry
|
├── projects/projects.json # Project registry
|
||||||
├── projects/workspaces.json # Workspace registry
|
├── projects/workspaces.json # Workspace registry
|
||||||
├── projects/icons/ # Custom project icon images
|
├── chat/ # Chat rooms
|
||||||
├── schedules/ # Scheduled-agent definitions and runs
|
├── schedules/ # Scheduled-agent definitions and runs
|
||||||
|
├── loops/ # Loop runs and logs
|
||||||
├── config.json # Daemon config (mutable)
|
├── config.json # Daemon config (mutable)
|
||||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||||
├── push-tokens.json # Mobile push tokens
|
├── push-tokens.json # Mobile push tokens
|
||||||
├── paseo.pid # Supervisor identity and published bound endpoint
|
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||||
└── daemon.log # Daemon trace logs (rotated)
|
└── daemon.log # Daemon trace logs (rotated)
|
||||||
```
|
```
|
||||||
|
|
||||||
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
|
|
||||||
|
|
||||||
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
|
|
||||||
|
|
||||||
## Deployment models
|
## Deployment models
|
||||||
|
|
||||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||||
2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
|
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
# Browser Capture Harness
|
|
||||||
|
|
||||||
The desktop capture harness is the real-Electron verification path for browser screenshots.
|
|
||||||
It validates the compositor behavior that unit tests cannot see:
|
|
||||||
|
|
||||||
- the resident automation `<webview>` starts in the production parking state;
|
|
||||||
- the parked guest remains paintable and has a copyable viewport frame;
|
|
||||||
- a never-presented resident webview guest defaults to 1280x800 logical pixels;
|
|
||||||
- multiple resident webviews are parked as an overlapping stack without per-capture
|
|
||||||
stacking changes;
|
|
||||||
- a newly attached resident webview whose first useful frame is delayed can be captured
|
|
||||||
by retrying until the frame appears;
|
|
||||||
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
|
|
||||||
the permanent production parking state;
|
|
||||||
- parked guests remain capturable with Chromium background throttling enabled;
|
|
||||||
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
|
|
||||||
host composer;
|
|
||||||
- the automation group loads the compiled production keyboard boundary and guest
|
|
||||||
preload, then proves that initial page window handlers get first refusal, unhandled
|
|
||||||
shortcuts synchronously suppress editable browser defaults before crossing the host
|
|
||||||
boundary, shortcuts marked unavailable in editable targets retain the browser field's
|
|
||||||
native behavior, handlers registered after preload still get first refusal, focused
|
|
||||||
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
|
|
||||||
stays in the guest.
|
|
||||||
|
|
||||||
Run it with the repo Electron:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run capture-harness --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the desktop main process before the automation group so its production guest
|
|
||||||
preload is available:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build:main --workspace=@getpaseo/desktop
|
|
||||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the shared browser profile fixture with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
On macOS, test native titlebar dragging after scrolling with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
This group needs Xcode Command Line Tools and Accessibility permission for the
|
|
||||||
terminal running it. It sends real mouse input to its test window and checks
|
|
||||||
window movement, including button clicks and a portal over the titlebar. Leave
|
|
||||||
the mouse idle during this group. It reads the app's production CSS and writes
|
|
||||||
screenshots and window bounds to the output directory.
|
|
||||||
|
|
||||||
The browser profile group runs two Electron processes in sequence. It verifies that each
|
|
||||||
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
|
|
||||||
tabs share cookies and local storage through one persistent session, and that the data is
|
|
||||||
still present after the first Electron process exits and the second starts.
|
|
||||||
|
|
||||||
The automation group uses a real guest webview to verify the page-side ref contract:
|
|
||||||
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
|
||||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
|
||||||
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
|
|
||||||
page-context evaluation, including passing a resolved ref element as the function argument.
|
|
||||||
Keyboard containment runs last because the host-composer sentinel intentionally leaves
|
|
||||||
native focus in the host. It reuses an existing fixture button: adding a test-only control
|
|
||||||
changes the inline fixture geometry exercised by the earlier actionability checks.
|
|
||||||
|
|
||||||
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
|
|
||||||
hide the Dock icon before creating any window. `showInactive()` only prevents window
|
|
||||||
focus; a normal Electron app launch can still activate the app and steal focus.
|
|
||||||
Harness windows are then created hidden, positioned in a screen corner, skipped from
|
|
||||||
the taskbar where Electron supports it, and revealed with `showInactive()` from
|
|
||||||
`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`:
|
|
||||||
the compositor only needs visible inactive windows, and harness runs must not steal
|
|
||||||
focus from the person using the machine.
|
|
||||||
|
|
||||||
The harness writes PNG evidence and `results.json` to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/desktop/capture-harness/out/
|
|
||||||
```
|
|
||||||
|
|
||||||
A passing run prints `PASS` lines for the production P1 default-throttling parking state,
|
|
||||||
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
|
|
||||||
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
|
|
||||||
is usually saved as 2560x1600.
|
|
||||||
|
|
||||||
The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey
|
|
||||||
verifies that a hidden window stops guest animation, captures fresh viewport pixels,
|
|
||||||
and resumes animation after restoring the window. Its artifacts include the screenshot
|
|
||||||
and animation measurements. Full-page content correctness remains separately tracked in
|
|
||||||
[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196).
|
|
||||||
|
|
||||||
## Mechanism
|
|
||||||
|
|
||||||
Electron captures copy from the guest web contents' compositor surface. A resident
|
|
||||||
webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
|
|
||||||
copyable surface. Each production webview keeps one permanent body-level surface. Presenting
|
|
||||||
or parking changes that surface's geometry without reparenting the webview. The parking state
|
|
||||||
uses `left:0`, `top:0`, `width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and
|
|
||||||
`pointer-events:none`. The webview stays at its resolved logical viewport, defaulting to
|
|
||||||
1280x800 before first presentation, with `display:inline-flex` at `left:0`, `top:0`.
|
|
||||||
Presentation resolves responsive guests to the pane's exact pixel dimensions after the surface
|
|
||||||
has visible bounds. Do not apply percentage guest sizing against the parked surface: Electron
|
|
||||||
exposes the 1x1 parking geometry as a real guest resize before expanding it again.
|
|
||||||
|
|
||||||
The permanent browser host and `overlay-root` are explicit sibling paint planes. The browser
|
|
||||||
plane stays below the overlay plane regardless of body insertion order; menus keep their relative
|
|
||||||
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
|
|
||||||
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
|
|
||||||
|
|
||||||
There is no renderer prep/restore handshake or lifetime background-throttling override.
|
|
||||||
Screenshot capture temporarily enables frame production inside the shared serialized queue,
|
|
||||||
restores the previous throttling policy on success or failure, and retains a 5-second capture budget.
|
|
||||||
The browser tool takes one viewport frame through Electron's frame subscription and releases the
|
|
||||||
subscription on completion or cancellation. A resized resident guest can produce fresh pixels
|
|
||||||
while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or
|
|
||||||
retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout
|
|
||||||
metrics and screenshot clip.
|
|
||||||
@@ -14,10 +14,6 @@ For testing rules, see [testing.md](testing.md).
|
|||||||
- **`interface`** over `type` when both work.
|
- **`interface`** over `type` when both work.
|
||||||
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
|
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
|
||||||
|
|
||||||
## Shell scripts
|
|
||||||
|
|
||||||
- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS.
|
|
||||||
|
|
||||||
## Comments and noise
|
## Comments and noise
|
||||||
|
|
||||||
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
|
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
|
||||||
@@ -51,8 +47,6 @@ For testing rules, see [testing.md](testing.md).
|
|||||||
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
||||||
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
||||||
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
||||||
- Every fallible user action owns explicit pending, success, and failure UI. Console logs and unverified platform alerts do not satisfy this contract. See [testing.md](testing.md#fallible-user-actions).
|
|
||||||
- A capability advertised to a client means the current runtime can perform the action, not merely that its RPC handler exists. If an unavailable action needs explanatory UI, send the runtime fact or reason separately and keep the server-side refusal fail-closed.
|
|
||||||
|
|
||||||
## Density
|
## Density
|
||||||
|
|
||||||
@@ -90,22 +84,10 @@ For testing rules, see [testing.md](testing.md).
|
|||||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||||
- Never define components inside other components. Module-scope only.
|
- Never define components inside other components. Module-scope only.
|
||||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||||
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
|
|
||||||
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
|
|
||||||
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
|
|
||||||
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
|
|
||||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||||
|
|
||||||
### Retained panel measurements
|
|
||||||
|
|
||||||
Inactive retained panels use `display: none`, so DOM and layout APIs report zero width and height. A non-positive measurement means the surface is not rendered; it is never a valid compact layout.
|
|
||||||
|
|
||||||
- Suspend geometry-derived updates while either measured dimension is non-positive. Preserve the last valid state until the surface reports positive geometry again.
|
|
||||||
- Enforce the rule at the measurement boundary shared by every trigger. Guarding only an `onLayout` or `ResizeObserver` callback still lets effects, content changes, and imperative remeasurement publish hidden geometry.
|
|
||||||
- Test the hide-and-reveal path through a real retained surface. Settled assertions do not catch stale geometry painted during the first returned frames.
|
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||||
|
|
||||||
Provider definitions live under `agents.providers` in config.json:
|
All provider configuration lives under `agents.providers` in config.json:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -17,49 +17,6 @@ Provider definitions live under `agents.providers` in config.json:
|
|||||||
|
|
||||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||||
|
|
||||||
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
|
|
||||||
agent catalog during startup, raise the limit in milliseconds:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"catalogRefreshTimeoutMs": 180000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The limit applies independently to every provider refresh and covers availability plus the entire
|
|
||||||
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
|
|
||||||
|
|
||||||
## Provider options
|
|
||||||
|
|
||||||
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
|
|
||||||
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
|
|
||||||
`Record<string, unknown>` values: the provider owns validation and application.
|
|
||||||
Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error.
|
|
||||||
Providers without option handling ignore the record.
|
|
||||||
|
|
||||||
The daemon deep-merges the agent's options over the provider defaults at each
|
|
||||||
session launch or resume. Plain objects merge recursively; arrays, scalars, and
|
|
||||||
`null` replace the base value. Only the agent's own options are persisted, so edits
|
|
||||||
to provider defaults apply on the next launch or resume. A profile inherits its
|
|
||||||
base provider's options unless it sets its own options record.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"codex": {
|
|
||||||
"options": { "sandbox_mode": "read-only", "approval_policy": "never" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for
|
|
||||||
per-agent examples and provider-specific keys.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -214,7 +171,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
| `qwen3-coder-next` | Optimized for coding |
|
| `qwen3-coder-next` | Optimized for coding |
|
||||||
| `kimi-k2.5` | Vision capable |
|
| `kimi-k2.5` | Vision capable |
|
||||||
| `glm-5` | Zhipu GLM |
|
| `glm-5` | Zhipu GLM |
|
||||||
| `MiniMax-M3` | MiniMax |
|
| `MiniMax-M2.5` | MiniMax |
|
||||||
|
|
||||||
**Additional models (pay-as-you-go):**
|
**Additional models (pay-as-you-go):**
|
||||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||||
@@ -288,8 +245,6 @@ requires_openai_auth = false
|
|||||||
|
|
||||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||||
|
|
||||||
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
|
|
||||||
|
|
||||||
Example: two different Anthropic accounts as separate profiles:
|
Example: two different Anthropic accounts as separate profiles:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -392,9 +347,9 @@ Override the command used to launch any provider with the `command` field. This
|
|||||||
|
|
||||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||||
|
|
||||||
### OMP profiles and Pi-compatible forks
|
### Pi-compatible forks with their own session directory
|
||||||
|
|
||||||
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
|
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -406,35 +361,6 @@ OMP ships as a first-class built-in provider option. It is disabled by default;
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"omp-work": {
|
|
||||||
"extends": "omp",
|
|
||||||
"label": "Oh My Pi (Work)",
|
|
||||||
"command": ["omp"],
|
|
||||||
"env": {
|
|
||||||
"XDG_CONFIG_HOME": "~/.config/omp-work",
|
|
||||||
"XDG_STATE_HOME": "~/.local/state/omp-work"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
|
||||||
"rpcTimeoutMs": 60000,
|
|
||||||
"smolModel": "openai/gpt-5-mini",
|
|
||||||
"slowModel": "anthropic/claude-opus-4-1",
|
|
||||||
"planModel": "openai/o3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines.
|
|
||||||
|
|
||||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -445,9 +371,8 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
|||||||
"extends": "pi",
|
"extends": "pi",
|
||||||
"label": "My Pi Fork",
|
"label": "My Pi Fork",
|
||||||
"command": ["my-pi-fork"],
|
"command": ["my-pi-fork"],
|
||||||
"options": {
|
"params": {
|
||||||
"sessionDir": "~/.my-pi-fork/sessions",
|
"sessionDir": "~/.my-pi-fork/sessions"
|
||||||
"rpcTimeoutMs": 60000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,7 +380,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -484,7 +409,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
|||||||
|
|
||||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||||
|
|
||||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead.
|
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||||
|
|
||||||
### Adding a generic ACP provider
|
### Adding a generic ACP provider
|
||||||
|
|
||||||
@@ -513,7 +438,7 @@ Required fields for ACP providers:
|
|||||||
- `label`
|
- `label`
|
||||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||||
|
|
||||||
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`:
|
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -523,7 +448,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
|||||||
"extends": "acp",
|
"extends": "acp",
|
||||||
"label": "My Agent",
|
"label": "My Agent",
|
||||||
"command": ["my-agent", "acp"],
|
"command": ["my-agent", "acp"],
|
||||||
"options": {
|
"params": {
|
||||||
"supportsMcpServers": false
|
"supportsMcpServers": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,39 +457,6 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
ACP agents execute filesystem operations in their own environment by default,
|
|
||||||
while terminal operations run through Paseo on the host. To customize which
|
|
||||||
operations Paseo handles, configure client capabilities in provider options:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"container-agent": {
|
|
||||||
"extends": "acp",
|
|
||||||
"label": "Container Agent",
|
|
||||||
"command": ["container-agent", "acp"],
|
|
||||||
"options": {
|
|
||||||
"clientCapabilities": {
|
|
||||||
"fs": {
|
|
||||||
"readTextFile": false,
|
|
||||||
"writeTextFile": false
|
|
||||||
},
|
|
||||||
"terminal": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When an agent runs in a container or remote environment that manages its own
|
|
||||||
terminal, set `terminal: false` to keep command execution inside the agent
|
|
||||||
container. When delegating filesystem operations to Paseo (`fs.readTextFile: true`
|
|
||||||
or `fs.writeTextFile: true`), ensure the agent and Paseo share equivalent
|
|
||||||
absolute workspace paths.
|
|
||||||
|
|
||||||
### Generic ACP diagnostics
|
### Generic ACP diagnostics
|
||||||
|
|
||||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||||
@@ -633,11 +525,6 @@ When you launch an agent with an ACP provider:
|
|||||||
4. Paseo creates a session and sends prompts through the ACP protocol
|
4. Paseo creates a session and sends prompts through the ACP protocol
|
||||||
5. The agent streams responses, tool calls, and permission requests back over stdout
|
5. The agent streams responses, tool calls, and permission requests back over stdout
|
||||||
|
|
||||||
Every ACP provider exposes an **Auto Accept** toggle. Enable it per session to let Paseo approve
|
|
||||||
ACP permission requests without surfacing each prompt. If the provider sends no allow option,
|
|
||||||
Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you
|
|
||||||
explicitly disable it.
|
|
||||||
|
|
||||||
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
|
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -713,7 +600,7 @@ Every entry under `agents.providers` accepts these fields:
|
|||||||
| `description` | `string` | No | Short description shown in the UI |
|
| `description` | `string` | No | Short description shown in the UI |
|
||||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||||
| `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
||||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||||
@@ -743,9 +630,9 @@ Each entry in the `models` array:
|
|||||||
|
|
||||||
### Claude settings.json model discovery
|
### Claude settings.json model discovery
|
||||||
|
|
||||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||||
|
|
||||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||||
|
|
||||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,9 @@
|
|||||||
# Data Model
|
# Data Model
|
||||||
|
|
||||||
## Project identity
|
|
||||||
|
|
||||||
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
|
|
||||||
|
|
||||||
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
|
|
||||||
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
|
|
||||||
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
|
|
||||||
Creation persists it with the project, and normal boot reconciliation fills it for
|
|
||||||
older records where the field is absent—there is no migration.
|
|
||||||
|
|
||||||
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
|
|
||||||
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
|
|
||||||
names, and workspace foreign keys. Attached workspaces are independently refreshed
|
|
||||||
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
|
|
||||||
are observed too.
|
|
||||||
|
|
||||||
The workspace registry model defines placement once: initial directory/worktree construction,
|
|
||||||
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
|
|
||||||
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
|
|
||||||
registry writes, so directory opens, agent imports, and worktree creation all enter through that
|
|
||||||
service instead of constructing records independently. The workspace record is then the durable
|
|
||||||
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
|
|
||||||
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
|
|
||||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
|
||||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
|
||||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
|
||||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
|
||||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
|
||||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
|
||||||
|
|
||||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||||
|
|
||||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
|
|
||||||
## Store Surface Rules
|
|
||||||
|
|
||||||
Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
@@ -48,25 +14,22 @@ $PASEO_HOME/
|
|||||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||||
├── paseo.pid # Daemon PID lock file
|
├── paseo.pid # Daemon PID lock file
|
||||||
├── local-credential # Per-run local client credential (mode 0600)
|
|
||||||
├── daemon.log # Default log file (path configurable)
|
├── daemon.log # Default log file (path configurable)
|
||||||
├── agents/
|
├── agents/
|
||||||
│ └── {sanitized-cwd}/
|
│ └── {sanitized-cwd}/
|
||||||
│ └── {agentId}.json # One file per agent
|
│ └── {agentId}.json # One file per agent
|
||||||
├── schedules/
|
├── schedules/
|
||||||
│ └── {scheduleId}.json # One file per schedule
|
│ └── {scheduleId}.json # One file per schedule
|
||||||
|
├── chat/
|
||||||
|
│ └── rooms.json # All rooms + messages
|
||||||
|
├── loops/
|
||||||
|
│ └── loops.json # All loop records
|
||||||
├── projects/
|
├── projects/
|
||||||
│ ├── projects.json # Project registry
|
│ ├── projects.json # Project registry
|
||||||
│ ├── workspaces.json # Workspace registry
|
│ └── workspaces.json # Workspace registry
|
||||||
│ ├── workspace-labels.json # Shared host-local label catalog
|
|
||||||
│ ├── workspace-labels.transaction.json # Recoverable catalog/assignment compound commit
|
|
||||||
│ └── icons/ # Host-local custom project icon images
|
|
||||||
├── runtime/
|
├── runtime/
|
||||||
│ └── managed-processes/
|
│ └── managed-processes/
|
||||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||||
├── plugins/
|
|
||||||
│ ├── sources.json # Managed kind and Git acquisition remote
|
|
||||||
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
|
|
||||||
└── push-tokens.json # Expo push notification tokens
|
└── push-tokens.json # Expo push notification tokens
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,8 +54,8 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
|||||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
| `title` | `string?` | User-visible title |
|
| `title` | `string?` | User-visible title |
|
||||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||||
@@ -102,7 +65,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
|||||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||||
| `internal` | `boolean?` | Whether this is a system-internal agent |
|
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
### Nested: SerializableConfig
|
### Nested: SerializableConfig
|
||||||
@@ -181,19 +144,6 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
|||||||
|
|
||||||
Single file, validated with `PersistedConfigSchema`.
|
Single file, validated with `PersistedConfigSchema`.
|
||||||
|
|
||||||
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
|
|
||||||
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared
|
|
||||||
`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind
|
|
||||||
one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from
|
|
||||||
Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files.
|
|
||||||
|
|
||||||
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
|
|
||||||
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
|
|
||||||
fields and their removal/default semantics; session handlers and the CLI only relay the structured
|
|
||||||
result. Normal config patches persist only the requested fields, so launch overrides and resolved
|
|
||||||
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
|
|
||||||
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -202,12 +152,9 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
|||||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||||
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
|
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
|
||||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||||
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
|
|
||||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||||
terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES
|
|
||||||
agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none
|
|
||||||
cors: { allowedOrigins: string[] },
|
cors: { allowedOrigins: string[] },
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||||
auth: { password: string } // bcrypt hash, optional
|
auth: { password: string } // bcrypt hash, optional
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
@@ -215,18 +162,17 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
|||||||
},
|
},
|
||||||
worktrees?: {
|
worktrees?: {
|
||||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||||
servicePorts?: { // optional dynamic service port allocation policy
|
|
||||||
range?: string // inclusive range, e.g. "3000-4000"
|
|
||||||
portScript?: string // executable that receives service/workspace context and prints one TCP port
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
providers: {
|
providers: {
|
||||||
|
openai: {
|
||||||
|
apiKey?: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
stt?: { apiKey?: string, baseUrl?: string },
|
||||||
|
tts?: { apiKey?: string, baseUrl?: string }
|
||||||
|
},
|
||||||
local: { modelsDir: string }
|
local: { modelsDir: string }
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
skills?: {
|
|
||||||
selection?: { mode: "all" } | { mode: "custom", skills: string[] }
|
|
||||||
},
|
|
||||||
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
||||||
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
||||||
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
||||||
@@ -235,11 +181,9 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
|||||||
providers: [{ provider, model?, thinkingOptionId? }]
|
providers: [{ provider, model?, thinkingOptionId? }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pluginsEnabled: boolean,
|
|
||||||
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
|
|
||||||
features: {
|
features: {
|
||||||
dictation: { enabled, stt: SpeechSttConfig },
|
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||||
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
|
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
level, format,
|
level, format,
|
||||||
@@ -251,123 +195,44 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
|||||||
|
|
||||||
All fields are optional with sensible defaults.
|
All fields are optional with sensible defaults.
|
||||||
|
|
||||||
Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
|
`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.
|
||||||
state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
|
|
||||||
remote. Installed revision, package/subdirectory identity and ownership root come from retained
|
|
||||||
artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
|
|
||||||
explains their authority. An update prepares a new directory before replacing the active path and
|
|
||||||
removing the previous version. Old record fields are accepted at the store boundary and ignored;
|
|
||||||
there is no startup migration or persistent update policy.
|
|
||||||
|
|
||||||
### Profile lists
|
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.
|
||||||
|
|
||||||
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
|
Set these to select OpenAI instead of local speech:
|
||||||
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
|
|
||||||
and remove. List order is the display order.
|
|
||||||
|
|
||||||
Absent and empty mean different things for terminal profiles — omitting the key falls back to
|
| Env var | Applies to |
|
||||||
`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no
|
| ------------------------------ | ------------------------------- |
|
||||||
defaults, so both mean none.
|
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||||
|
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||||
|
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||||
|
|
||||||
`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write
|
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||||
rather than storing something it cannot describe. That is why the client gates the agent profiles
|
|
||||||
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
|
|
||||||
older daemon.
|
|
||||||
|
|
||||||
### Agent provider Paseo tools
|
|
||||||
|
|
||||||
`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are
|
|
||||||
`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal
|
|
||||||
configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`.
|
|
||||||
|
|
||||||
Each entry may include a Paseo-tool policy:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"providers": {
|
||||||
"providers": {
|
"openai": {
|
||||||
"my-claude": {
|
"stt": {
|
||||||
"extends": "claude",
|
"apiKey": "sk-...",
|
||||||
"label": "My Claude",
|
"baseUrl": "https://stt.example.com/v1"
|
||||||
"paseoTools": {
|
},
|
||||||
"enabled": true,
|
"tts": {
|
||||||
"disabledTools": ["browser_evaluate"]
|
"apiKey": "sk-...",
|
||||||
}
|
"baseUrl": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are
|
`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.
|
||||||
allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool
|
|
||||||
IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool.
|
|
||||||
Browser tools also require `daemon.browserTools.enabled` and a connected browser host.
|
|
||||||
This policy controls the catalog presented to an agent. It is not an authorization boundary for
|
|
||||||
agents that can access the host through a shell.
|
|
||||||
|
|
||||||
`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives
|
Paseo uses these paths under the configured OpenAI base URL:
|
||||||
Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when 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).
|
- dictation STT: `/v1/audio/transcriptions`
|
||||||
|
- voice mode STT: `/v1/audio/transcriptions`
|
||||||
### Git process limits
|
- voice mode TTS: `/v1/audio/speech`
|
||||||
|
|
||||||
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
|
|
||||||
second, and the concurrency limit defaults to `8`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"daemon": {
|
|
||||||
"git": {
|
|
||||||
"maxProcessesPerSecond": 64,
|
|
||||||
"maxProcessConcurrency": 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`maxProcessesPerSecond` limits Git process starts in any one-second interval. The allowance can
|
|
||||||
start as a burst; it does not wait for earlier processes to exit. `maxProcessConcurrency` limits
|
|
||||||
the number of Git processes that have started but not exited. Every Git command uses both limits,
|
|
||||||
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
|
|
||||||
requests.
|
|
||||||
|
|
||||||
Environment variables override `config.json`:
|
|
||||||
|
|
||||||
| Environment variable | Setting |
|
|
||||||
| ------------------------------------ | ------------------------ |
|
|
||||||
| `PASEO_GIT_MAX_PROCESSES_PER_SECOND` | `maxProcessesPerSecond` |
|
|
||||||
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
|
|
||||||
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
|
|
||||||
|
|
||||||
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
|
|
||||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
|
||||||
remains authoritative during reload.
|
|
||||||
|
|
||||||
### Speech configuration
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -396,13 +261,13 @@ One file per schedule. ID is 8 hex characters.
|
|||||||
|
|
||||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||||
|
|
||||||
- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC
|
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||||
- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window
|
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
|
||||||
|
|
||||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||||
|
|
||||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, providerOptions?, featureValues?, systemPrompt?, mcpServers? } }` — create a new agent
|
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||||
|
|
||||||
### Nested: ScheduleRun
|
### Nested: ScheduleRun
|
||||||
|
|
||||||
@@ -419,93 +284,175 @@ One file per schedule. ID is 8 hex characters.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Project Registry
|
## 4. Chat
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||||
|
|
||||||
|
Single file containing all rooms and messages.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rooms": [ ... ],
|
||||||
|
"messages": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChatRoom
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----------- | ------------------- | ----------------------------------- |
|
||||||
|
| `id` | `string` (UUID) | |
|
||||||
|
| `name` | `string` | Unique room name (case-insensitive) |
|
||||||
|
| `purpose` | `string?` | Room description |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||||
|
|
||||||
|
### ChatMessage
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------ | ------------------- | ----------------------------------- |
|
||||||
|
| `id` | `string` (UUID) | |
|
||||||
|
| `roomId` | `string` | FK to ChatRoom.id |
|
||||||
|
| `authorAgentId` | `string` | Agent ID of the author |
|
||||||
|
| `body` | `string` | Message text (supports `@mentions`) |
|
||||||
|
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||||
|
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Loop
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||||
|
|
||||||
|
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| `id` | `string` | 8-char UUID prefix |
|
||||||
|
| `name` | `string?` | Human-readable name |
|
||||||
|
| `prompt` | `string` | Worker prompt |
|
||||||
|
| `cwd` | `string` | Working directory |
|
||||||
|
| `provider` | `string` | Default provider |
|
||||||
|
| `model` | `string?` | Default model |
|
||||||
|
| `modeId` | `string?` | Default mode ID |
|
||||||
|
| `workerProvider` | `string?` | Override provider for workers |
|
||||||
|
| `workerModel` | `string?` | Override model for workers |
|
||||||
|
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||||
|
| `verifierModel` | `string?` | Override model for verifiers |
|
||||||
|
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||||
|
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||||
|
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||||
|
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||||
|
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||||
|
| `maxIterations` | `number?` | Cap on iterations |
|
||||||
|
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
|
| `completedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `iterations` | `LoopIteration[]` | |
|
||||||
|
| `logs` | `LoopLogEntry[]` | |
|
||||||
|
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||||
|
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||||
|
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||||
|
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||||
|
|
||||||
|
### Nested: LoopIteration
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------- | --------------------------------------------------- | ------------------------ |
|
||||||
|
| `index` | `number` | 1-based iteration index |
|
||||||
|
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||||
|
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||||
|
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
|
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||||
|
| `failureReason` | `string?` | |
|
||||||
|
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||||
|
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||||
|
|
||||||
|
### Nested: LoopLogEntry
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
| ----------- | ---------------------------------------------------- |
|
||||||
|
| `seq` | `number` (monotonic) |
|
||||||
|
| `timestamp` | `string` (ISO 8601) |
|
||||||
|
| `iteration` | `number?` |
|
||||||
|
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||||
|
| `level` | `"info" \| "error"` |
|
||||||
|
| `text` | `string` |
|
||||||
|
|
||||||
|
### Nested: LoopVerifyCheckResult
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
| ------------- | ------------------- |
|
||||||
|
| `command` | `string` |
|
||||||
|
| `exitCode` | `number` |
|
||||||
|
| `passed` | `boolean` |
|
||||||
|
| `stdout` | `string` |
|
||||||
|
| `stderr` | `string` |
|
||||||
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
|
### Nested: LoopVerifyPromptResult
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
| ----------------- | ------------------- |
|
||||||
|
| `passed` | `boolean` |
|
||||||
|
| `reason` | `string` |
|
||||||
|
| `verifierAgentId` | `string?` |
|
||||||
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Project Registry
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||||
|
|
||||||
Array of project records.
|
Array of project records.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------- | --------------------------- | ---------------------------------------- |
|
||||||
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
|
| `projectId` | `string` | Primary key |
|
||||||
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
|
| `rootPath` | `string` | Filesystem root of the project |
|
||||||
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
|
| `kind` | `"git" \| "non_git"` | |
|
||||||
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
|
| `displayName` | `string` | |
|
||||||
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. |
|
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
|
||||||
|
|
||||||
Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom
|
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||||
icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never
|
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||||
receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new
|
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||||
`customIconRevision`. Going back to automatic deletes the stored image, as does removing the
|
emptied duplicate.
|
||||||
project.
|
|
||||||
|
|
||||||
Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy
|
|
||||||
remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never
|
|
||||||
merges them, transfers names, archives them, or moves workspace foreign keys. An explicit
|
|
||||||
workspace `projectId` is authoritative when it names an active project, regardless of cwd
|
|
||||||
containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh
|
|
||||||
opaque project is allocated instead. Agent restore is separate and restores the agent's existing
|
|
||||||
workspace together with its owning project.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Workspace Registry
|
## 7. Workspace Registry
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||||
|
|
||||||
Array of workspace records. A workspace is a specific working directory within a project.
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
| `projectId` | `string` | FK to Project.projectId |
|
||||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
| `cwd` | `string` | Filesystem path |
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
|
||||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
|
||||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
|
||||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
|
||||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
|
||||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
|
||||||
|
|
||||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
|
||||||
|
|
||||||
### Workspace label catalog
|
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/projects/workspace-labels.json`
|
|
||||||
|
|
||||||
The catalog is shared by every workspace on one host. A definition contains a display name and one
|
|
||||||
of the ten identity colour names (`WORKSPACE_LABEL_COLORS` in
|
|
||||||
`packages/protocol/src/workspace-labels.ts`); trimmed, collapsed, case-insensitive name identity is
|
|
||||||
unique within that file. Definitions have no portable ID or principal owner and remain after their
|
|
||||||
last assignment. Editing a label takes a new name, a new colour, or both in one commit, so the two
|
|
||||||
fields cannot land half-applied. Workspaces store label names, so a rename and a delete rewrite
|
|
||||||
workspace assignments through one serialized compound commit while a recolour is catalog-only; a
|
|
||||||
rename onto a name the host already has is refused rather than merged. A prepared
|
|
||||||
`workspace-labels.transaction.json` contains both before and after images. The daemon writes the
|
|
||||||
catalog and workspace files, then atomically changes the transaction to committed; that phase
|
|
||||||
change is the durable commit point. Recovery rolls prepared transactions back before either
|
|
||||||
directory is served. A committed marker proves both data files were already written, so recovery
|
|
||||||
only loads the current catalog and retries marker cleanup; it never reapplies stale workspace
|
|
||||||
after-images over later registry mutations. A request rejected before the commit point therefore
|
|
||||||
cannot take effect after restart. If the live daemon cannot determine or restore the durable state,
|
|
||||||
the workspace registry freezes every write and later label mutations fail with
|
|
||||||
`workspace_label_storage_uncertain` until daemon restart performs recovery. Reads remain available,
|
|
||||||
but may reflect the last acknowledged cache until restart. Workspace directory and catalog updates
|
|
||||||
publish only after the commit point; publication failure does not roll durable state back.
|
|
||||||
|
|
||||||
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
|
||||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||||
@@ -514,7 +461,7 @@ than treating it as valid.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Push Token Store
|
## 8. Push Token Store
|
||||||
|
|
||||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||||
|
|
||||||
@@ -528,7 +475,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Daemon meta files
|
## 9. Daemon meta files
|
||||||
|
|
||||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||||
|
|
||||||
@@ -537,7 +484,6 @@ These small files are not validated as full Zod schemas but are persisted under
|
|||||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||||
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
|
||||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -553,29 +499,6 @@ Right-sidebar client state splits on whether it is determined by the directory o
|
|||||||
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
|
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
|
||||||
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
|
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
|
||||||
|
|
||||||
### Replica row store
|
|
||||||
|
|
||||||
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
|
|
||||||
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
|
|
||||||
`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint
|
|
||||||
uses the singleton id.
|
|
||||||
|
|
||||||
The store is a typed persistence boundary. It returns values to directory and timeline owners and
|
|
||||||
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
|
|
||||||
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
|
|
||||||
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
|
|
||||||
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
|
|
||||||
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
|
|
||||||
checkpoint are also applied in one transaction. Reads wait for accepted changes to reach storage,
|
|
||||||
then retry only when a requested row changes during the read. Unrelated agent streams do not
|
|
||||||
invalidate the read. Directory baseline replacements and host removal or renaming still invalidate
|
|
||||||
the affected reads.
|
|
||||||
|
|
||||||
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
|
|
||||||
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
|
|
||||||
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
|
|
||||||
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
|
|
||||||
|
|
||||||
### Draft Store
|
### Draft Store
|
||||||
|
|
||||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
|||||||
|
|
||||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||||
|
|
||||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||||
|
|
||||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||||
|
|
||||||
@@ -28,13 +28,11 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
|||||||
|
|
||||||
## 3. Hierarchy
|
## 3. Hierarchy
|
||||||
|
|
||||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||||
|
|
||||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
|
||||||
|
|
||||||
Weight has three tiers, applied by role:
|
Weight has three tiers, applied by role:
|
||||||
|
|
||||||
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
|
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||||
|
|
||||||
@@ -42,8 +40,6 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
|
|||||||
|
|
||||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||||
|
|
||||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
|
||||||
|
|
||||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||||
|
|
||||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||||
@@ -62,16 +58,10 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
|||||||
|
|
||||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||||
|
|
||||||
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
|
|
||||||
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
|
|
||||||
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
|
|
||||||
|
|
||||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||||
|
|
||||||
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: `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.
|
|
||||||
|
|
||||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -104,7 +94,7 @@ Five primitives. The pick is determined by option count, the need to search, and
|
|||||||
|
|
||||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||||
|
|
||||||
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
||||||
|
|
||||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||||
|
|
||||||
@@ -120,58 +110,32 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
|
|||||||
|
|
||||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||||
|
|
||||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
|
||||||
|
|
||||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||||
|
|
||||||
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 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.
|
The whitespace is the design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Alignment
|
## 8. Responsiveness
|
||||||
|
|
||||||
Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background.
|
|
||||||
|
|
||||||
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
|
|
||||||
|
|
||||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
|
|
||||||
|
|
||||||
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
|
|
||||||
|
|
||||||
One row off the rail makes the whole card look unconsidered.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Responsiveness
|
|
||||||
|
|
||||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
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 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:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
- 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 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 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.
|
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Copy and voice
|
## 9. Copy and voice
|
||||||
|
|
||||||
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
|
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
|
||||||
|
|
||||||
@@ -193,17 +157,17 @@ Terminology:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. States
|
## 10. States
|
||||||
|
|
||||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||||
|
|
||||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||||
|
|
||||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -213,15 +177,13 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner
|
|||||||
|
|
||||||
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
|
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
|
||||||
|
|
||||||
Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. List rows
|
## 11. List rows
|
||||||
|
|
||||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||||
|
|
||||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
|
||||||
|
|
||||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||||
|
|
||||||
@@ -233,45 +195,37 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Status pills and badges
|
## 12. Status pills and badges
|
||||||
|
|
||||||
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
|
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
|
||||||
|
|
||||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
|
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||||
|
|
||||||
Status pills use the status token for text on the shared `surface3` and `border` shell. The neutral shell keeps the signal legible without manufacturing translucent colors outside the theme. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||||
|
|
||||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
|
||||||
|
|
||||||
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
|
|
||||||
|
|
||||||
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Forbidden
|
## 13. Forbidden
|
||||||
|
|
||||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||||
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
|
||||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||||
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
|
|
||||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
|
||||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||||
- Raw DOM APIs without an `isWeb` guard.
|
- Raw DOM APIs without an `isWeb` guard.
|
||||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||||
- Color changes for disabled state. Opacity only.
|
- Color changes for disabled state. Opacity only.
|
||||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Canonical surfaces by pattern
|
## 14. Canonical surfaces by pattern
|
||||||
|
|
||||||
| Pattern | Reference |
|
| Pattern | Reference |
|
||||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -19,29 +19,8 @@ Root checkout dev is intentionally split across terminals:
|
|||||||
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
|
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
|
||||||
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
|
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
|
||||||
|
|
||||||
Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development`,
|
|
||||||
so development-only providers such as Mock Load Test are available. Packaged
|
|
||||||
desktop launches always force the daemon to production mode.
|
|
||||||
|
|
||||||
The web and desktop dev launchers pass the current Git branch to Metro as
|
|
||||||
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
|
|
||||||
the titlebar row. Production builds leave the variable unset and show no label.
|
|
||||||
|
|
||||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||||
|
|
||||||
## Nix desktop package
|
|
||||||
|
|
||||||
The flake exposes `packages.<system>.desktop` on Linux and macOS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nix build .#desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Linux produces the `paseo-desktop` launcher and desktop entry. macOS produces
|
|
||||||
`Applications/Paseo.app` plus the `paseo-desktop` launcher. Both use the nixpkgs
|
|
||||||
Electron runtime and the checkout's built daemon, client, and renderer rather
|
|
||||||
than downloading a published desktop release.
|
|
||||||
|
|
||||||
### PASEO_HOME
|
### PASEO_HOME
|
||||||
|
|
||||||
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
|
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
|
||||||
@@ -80,78 +59,18 @@ startup routing, remembered workspace restore, or active workspace selection.
|
|||||||
|
|
||||||
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
|
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
|
||||||
|
|
||||||
**Prerequisites (macOS only).** The service shells out to the Apple toolchain, so beyond the `npm ci` that worktree setup runs you must install:
|
|
||||||
|
|
||||||
- **Xcode** (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from `developer.apple.com/download` for a specific version. It provides `xcodebuild` and `xcrun simctl`; accept its license and let first-run component installation finish before starting the service.
|
|
||||||
- **An iOS Simulator runtime with at least one iPhone device type**. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets `iPhone 16 Pro` by default (override with `PASEO_IOS_DEVICE_TYPE`) and falls back to any iPhone; it fails with `No iPhone simulator device type is installed` when none exist.
|
|
||||||
- **Homebrew** — CocoaPods itself installs automatically: `expo prebuild` runs `pod install` on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries `gem install cocoapods` first and falls back to Homebrew (`brew install cocoapods`), so having Homebrew available lets that fallback succeed without a manual step.
|
|
||||||
|
|
||||||
`serve-sim`, Expo, and Metro come from `npm ci`, and CocoaPods installs itself on the first prebuild as described above.
|
|
||||||
|
|
||||||
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
|
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
|
||||||
|
|
||||||
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
|
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
|
||||||
|
|
||||||
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive `/.sim` preview: a `serve-sim` stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at `basePath: "/.sim"` (`packages/app/metro.config.cjs`) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the `${PASEO_URL}/.sim` link the service prints — not `serve-sim`'s raw stream port (`:3100`), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below.
|
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
|
||||||
|
|
||||||
**Troubleshooting.** If `xcrun simctl` fails with `unable to find utility "simctl"`, the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, then confirm with `xcrun --find simctl`.
|
|
||||||
|
|
||||||
### Running the iOS app on a local simulator
|
|
||||||
|
|
||||||
For fast, native, interactive iOS dev at the Mac — as opposed to the remote `/.sim` preview above — skip the service and build the dev client directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app
|
|
||||||
```
|
|
||||||
|
|
||||||
`expo run:ios` starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream).
|
|
||||||
|
|
||||||
**Pointing the app at a daemon.** The client resolves its local daemon from `EXPO_PUBLIC_LOCAL_DAEMON` (`packages/app/src/runtime/host-runtime.ts`); when unset it falls back to `localhost:6767`, the production `~/.paseo` daemon. To target a worktree's dev daemon instead, set it on the build command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service
|
|
||||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server`
|
|
||||||
```
|
|
||||||
|
|
||||||
The iOS simulator shares the Mac's loopback, so `localhost:<port>` reaches the host daemon directly.
|
|
||||||
|
|
||||||
**Gotcha — `EXPO_PUBLIC_*` is inlined into the JS bundle at Metro bundle time, not read at runtime.** Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with `cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c` and reload the app.
|
|
||||||
|
|
||||||
### Desktop renderer profiling
|
### Desktop renderer profiling
|
||||||
|
|
||||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled so
|
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||||
renderer CPU profiles can be captured through CDP. By default it passes
|
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||||
`--remote-debugging-port=0`, so Chromium atomically asks the OS for an available
|
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||||
port and prints the selected DevTools endpoint. Set
|
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||||
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when a QA workflow requires a validated,
|
|
||||||
fixed port.
|
|
||||||
|
|
||||||
Desktop dev also scopes Electron `userData` to the current dev root. This prevents
|
|
||||||
desktop-only environment inherited by terminals opened inside Paseo from coupling
|
|
||||||
a new worktree instance to the parent desktop instance's profile or single-instance
|
|
||||||
lock.
|
|
||||||
|
|
||||||
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
|
|
||||||
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
|
|
||||||
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
|
|
||||||
detach Electron: either change leaves an orphan holding the worktree's single-instance
|
|
||||||
lock and broken output pipes.
|
|
||||||
|
|
||||||
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
|
|
||||||
transition, and 751-pixel settings split with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run verify:electron-cdp --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
The verifier reads the same `EXPO_PORT` and
|
|
||||||
`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set an
|
|
||||||
explicit remote-debugging port for verifier runs, and set both when testing an
|
|
||||||
isolated instance on non-default ports.
|
|
||||||
|
|
||||||
When running a dedicated Electron QA instance against a non-default Expo port, set
|
|
||||||
`EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so
|
|
||||||
`PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081.
|
|
||||||
|
|
||||||
### React render profiling
|
### React render profiling
|
||||||
|
|
||||||
@@ -217,98 +136,6 @@ PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
|
|||||||
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
|
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
|
||||||
```
|
```
|
||||||
|
|
||||||
For warm workspace switching, point the benchmark at an app backed by seeded
|
|
||||||
daemon state:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_PROFILE_APP_URL=http://localhost:19010 \
|
|
||||||
npm run profile:workspace-switching --workspace=@getpaseo/app
|
|
||||||
```
|
|
||||||
|
|
||||||
The benchmark first warms `Cmd+1` through `Cmd+7`, then records a rapid seven-workspace
|
|
||||||
burst. It separately warms the three-entry workspace deck LRU and records `Cmd+1` through
|
|
||||||
`Cmd+3` without waits between keys. Both scenarios report keydown-to-activation latency and
|
|
||||||
React commits on the same browser clock. Set `PASEO_PROFILE_WORKSPACE_DIGITS`,
|
|
||||||
`PASEO_PROFILE_WORKSPACE_LRU_SIZE`, or `PASEO_PROFILE_WARM_QUIET_MS` to change the shape. Set
|
|
||||||
`PASEO_PROFILE_DUMP_COMMITS=1` to include the nested component breakdown for every commit.
|
|
||||||
Set `PASEO_PROFILE_RETAINED_REPEATS=5` to repeat the retained-LRU burst for a less noisy sample.
|
|
||||||
Set `PASEO_PROFILE_CPU_PATH=/tmp/workspace-switch.cpuprofile` to run a separate retained-LRU
|
|
||||||
capture after the latency scenarios and write a raw CDP CPU profile without contaminating their
|
|
||||||
timings.
|
|
||||||
Set `PASEO_PROFILE_TRACE_PATH=/tmp/workspace-switch.trace.json` to run another separate retained-LRU
|
|
||||||
capture and write a Chromium Performance trace with User Timing marks and screenshots. Open the
|
|
||||||
trace in the Chrome DevTools Performance panel. Set `PASEO_PROFILE_TRACE_INVALIDATIONS=1` only for a
|
|
||||||
focused invalidation capture; React Native's generated stacks make that mode highly intrusive.
|
|
||||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
|
||||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
|
||||||
|
|
||||||
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
|
||||||
```
|
|
||||||
|
|
||||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
|
|
||||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
|
||||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
|
||||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
|
||||||
`PASEO_PROFILE_CPU_PATH=/tmp/explorer-toggle.cpuprofile` for separate Chromium captures. Override
|
|
||||||
the app URL, daemon port, workspace, or server with the corresponding `PASEO_PROFILE_*` variables.
|
|
||||||
|
|
||||||
For sustained composer typing, run the paired composer-versus-textarea benchmark against a seeded
|
|
||||||
daemon:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_PROFILE_APP_URL=http://localhost:19010 \
|
|
||||||
npm run profile:composer-typing --workspace=@getpaseo/app
|
|
||||||
```
|
|
||||||
|
|
||||||
The benchmark opens the first workspace, preserves its existing draft, and dispatches 300 printable
|
|
||||||
keys at a fixed 16 ms cadence without waiting for each key to paint. It measures renderer
|
|
||||||
`keydown` to the next paint opportunity, verifies that every character survived, alternates the
|
|
||||||
composer and plain-textarea control across three runs, then restores the original draft. The report
|
|
||||||
includes percentiles, frame coalescing, input processing, React work, long tasks, slow samples, and
|
|
||||||
Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TYPING_CADENCE_MS`,
|
|
||||||
`PASEO_PROFILE_TYPING_REPEATS`, or `PASEO_PROFILE_WORKSPACE_DIGIT` to change the scenario. Optional
|
|
||||||
`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
|
|
||||||
boundaries are nested. A printable key after an empty newline should not change either height.
|
|
||||||
|
|
||||||
### Preview Windows and Linux window controls on macOS
|
|
||||||
|
|
||||||
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
|
|
||||||
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
The override is rejected in packaged builds. Restart the desktop process when changing it.
|
|
||||||
|
|
||||||
### Desktop macOS compositor watchdog
|
### Desktop macOS compositor watchdog
|
||||||
|
|
||||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||||
@@ -331,22 +158,6 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
|||||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||||
guards already prevent throttling from causing a false stall.
|
guards already prevent throttling from causing a false stall.
|
||||||
|
|
||||||
### Desktop Linux hidden-window fallback
|
|
||||||
|
|
||||||
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
|
|
||||||
forever without a single compositor frame: the renderer loads and runs, but
|
|
||||||
`ready-to-show` never fires, so the window is never shown and the app looks like
|
|
||||||
it did not start while every process is alive. `showWindowWhenReady`
|
|
||||||
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
|
|
||||||
the document finishes loading if the event has not arrived. The fallback is
|
|
||||||
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
|
|
||||||
|
|
||||||
Run the startup regression on Linux with an isolated display:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Daemon logs
|
### Daemon logs
|
||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||||
@@ -358,77 +169,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
|||||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||||
|
|
||||||
### Git process pressure
|
### Workspace search
|
||||||
|
|
||||||
If Git refreshes consume too much CPU, disk, or antivirus capacity, especially on Windows, reduce
|
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
|
||||||
the daemon-global Git process limits in `$PASEO_HOME/config.json`:
|
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
|
||||||
|
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
|
||||||
```json
|
matcher in Paseo. If ignore semantics need to change, change the backend
|
||||||
{
|
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
|
||||||
"daemon": {
|
|
||||||
"git": {
|
|
||||||
"maxProcessesPerSecond": 5,
|
|
||||||
"maxProcessConcurrency": 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
|
|
||||||
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
|
|
||||||
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
|
|
||||||
semantics, and environment-variable overrides.
|
|
||||||
|
|
||||||
### Agent Tool Catalog Measurement
|
|
||||||
|
|
||||||
Measure the MCP `tools/list` payload that Paseo injects into agents with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run measure:agent-tools --workspace=@getpaseo/server
|
|
||||||
```
|
|
||||||
|
|
||||||
The command reports compact JSON bytes, estimated tokens, field totals, largest
|
|
||||||
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
|
|
||||||
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
|
|
||||||
for machine-readable output.
|
|
||||||
|
|
||||||
## Worktree starting refs
|
|
||||||
|
|
||||||
A new worktree starts from the current branch's upstream, or the local branch when it has no
|
|
||||||
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
|
|
||||||
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
|
|
||||||
|
|
||||||
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
|
|
||||||
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
|
|
||||||
the UI. Merging into base requires a mutable local target: `origin/main` maps to local `main`, while
|
|
||||||
another remote fails closed until the worktree records an explicit local target. Older daemons omit
|
|
||||||
the optional field and retain the previous local-first behavior; older worktree metadata without the
|
|
||||||
exact ref also resolves through its stored branch name.
|
|
||||||
|
|
||||||
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
|
|
||||||
|
|
||||||
## paseo.json service scripts
|
## paseo.json service scripts
|
||||||
|
|
||||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||||
of commands. Both run sequentially.
|
of commands. Both run sequentially.
|
||||||
|
|
||||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
|
||||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
|
||||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
|
||||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
|
||||||
hook is unset. ACP single-string terminal commands use the same non-login Bash
|
|
||||||
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
|
|
||||||
on Windows. Service scripts are separate:
|
|
||||||
they launch in a terminal and receive the service environment described below.
|
|
||||||
|
|
||||||
Because the shell differs per platform, a lifecycle command that must run
|
|
||||||
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
|
|
||||||
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
|
|
||||||
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
|
|
||||||
that reads what it needs from `process.env` and invoke it as
|
|
||||||
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
|
|
||||||
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"worktree": {
|
"worktree": {
|
||||||
@@ -465,32 +218,22 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
|
|
||||||
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
|
|
||||||
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
|
|
||||||
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
|
|
||||||
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
|
|
||||||
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
|
|
||||||
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
|
|
||||||
so the port may already be bound. `portScript` takes precedence when both values are present.
|
|
||||||
|
|
||||||
## Bundled daemon web UI
|
## Bundled daemon web UI
|
||||||
|
|
||||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||||
|
|
||||||
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
|
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
|
||||||
|
|
||||||
Enable it in persistent configuration:
|
Enable it for a running daemon with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo daemon config set features.webUi.enabled true
|
paseo daemon start --web-ui
|
||||||
paseo daemon start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or set the environment variable:
|
Or set the environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PASEO_WEB_UI_ENABLED=true paseo daemon run
|
PASEO_WEB_UI_ENABLED=true paseo daemon start
|
||||||
```
|
```
|
||||||
|
|
||||||
Or persist it in `config.json`:
|
Or persist it in `config.json`:
|
||||||
@@ -542,59 +285,12 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
|
|||||||
|
|
||||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||||
|
|
||||||
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
|
|
||||||
|
|
||||||
For tighter loops, you can rebuild a single workspace:
|
For tighter loops, you can rebuild a single workspace:
|
||||||
|
|
||||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||||
- Changed app build dependencies: `npm run build:app-deps`.
|
- Changed app build dependencies: `npm run build:app-deps`.
|
||||||
|
|
||||||
## Dependency patches
|
|
||||||
|
|
||||||
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
|
||||||
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
|
||||||
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
|
||||||
patch-package cannot apply a patch that changed after an earlier version was applied, so the
|
|
||||||
script removes that package and fails the install; the next `npm install` reinstalls it clean
|
|
||||||
with the current patch. Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
|
||||||
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
|
||||||
(`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
|
## ACP provider catalog versions
|
||||||
|
|
||||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||||
@@ -616,35 +312,20 @@ install.
|
|||||||
|
|
||||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||||
|
|
||||||
Canonical automation uses `paseo project create/ls/rename/delete`, `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run cli -- ls -a -g # List all agents globally
|
npm run cli -- ls -a -g # List all agents globally
|
||||||
npm run cli -- ls -a -g --json # Same, as JSON
|
npm run cli -- ls -a -g --json # Same, as JSON
|
||||||
npm run cli -- inspect <id> # Show detailed agent info
|
npm run cli -- inspect <id> # Show detailed agent info
|
||||||
npm run cli -- logs <id> # View agent timeline
|
npm run cli -- logs <id> # View agent timeline
|
||||||
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
|
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the global `--host` option to point the CLI at a different daemon:
|
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run cli -- --host localhost:7777 ls -a
|
npm run cli -- --host localhost:7777 ls -a
|
||||||
npm run cli -- --host ssh://user@host ls -a
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
|
||||||
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
|
|
||||||
|
|
||||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
|
||||||
|
|
||||||
Desktop integrations can focus an existing agent without creating one or
|
|
||||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
|
||||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
|
||||||
default; pass `--server <server-id>` when targeting another server.
|
|
||||||
|
|
||||||
## Agent state
|
## Agent state
|
||||||
|
|
||||||
Agent data lives at:
|
Agent data lives at:
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ The image source lives in [`docker/`](../docker/).
|
|||||||
|
|
||||||
The official image:
|
The official image:
|
||||||
|
|
||||||
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
|
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
|
||||||
|
or from source-built workspace tarballs for beta images
|
||||||
- runs the daemon as the non-root `paseo` user
|
- runs the daemon as the non-root `paseo` user
|
||||||
- listens on `0.0.0.0:6767` inside the container
|
- listens on `0.0.0.0:6767` inside the container
|
||||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||||
@@ -22,8 +23,6 @@ the web UI. The served app receives a same-origin connection hint and connects
|
|||||||
back to that daemon. Static UI files load without daemon auth; API and
|
back to that daemon. Static UI files load without daemon auth; API and
|
||||||
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
|
||||||
|
|
||||||
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -178,9 +177,8 @@ IPs and `localhost` are allowed by default.
|
|||||||
|
|
||||||
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
- Set `PASEO_PASSWORD` for any published port or network-reachable deployment.
|
||||||
- Prefer HTTPS at the reverse proxy for direct browser access.
|
- Prefer HTTPS at the reverse proxy for direct browser access.
|
||||||
- Use the [official Paseo relay](https://github.com/getpaseo/paseo-relay) for
|
- Use the Paseo relay for untrusted networks or mobile access when you do not
|
||||||
untrusted networks or mobile access when you do not want to expose the daemon
|
want to expose the daemon port directly.
|
||||||
port directly.
|
|
||||||
- The container is the isolation boundary for agents. Agents can read and write
|
- The container is the isolation boundary for agents. Agents can read and write
|
||||||
whatever you mount into `/workspace` and whatever credentials you place in
|
whatever you mount into `/workspace` and whatever credentials you place in
|
||||||
`/home/paseo`.
|
`/home/paseo`.
|
||||||
@@ -192,17 +190,16 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
|
|||||||
## Building Locally
|
## Building Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f docker/base/Dockerfile -t paseo:local .
|
docker build -t paseo:local docker/base
|
||||||
```
|
```
|
||||||
|
|
||||||
To assert the source tree version while building:
|
To bake a specific published npm version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg PASEO_VERSION=0.1.102 \
|
--build-arg PASEO_VERSION=0.1.102 \
|
||||||
-t paseo:0.1.102 \
|
-t paseo:0.1.102 \
|
||||||
-f docker/base/Dockerfile \
|
docker/base
|
||||||
.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker workflow builds the image on pull requests and on `main` as a
|
The Docker workflow builds the image on pull requests and on `main` as a
|
||||||
@@ -219,12 +216,13 @@ pushing a `v*` release tag:
|
|||||||
gh workflow run docker.yml \
|
gh workflow run docker.yml \
|
||||||
--ref main \
|
--ref main \
|
||||||
-f paseo_version=0.1.102-beta.1 \
|
-f paseo_version=0.1.102-beta.1 \
|
||||||
-f publish=true
|
-f publish=true \
|
||||||
|
-f source_build=auto
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
|
Manual Docker publishes require an explicit `paseo_version`. Prerelease
|
||||||
from the checked-out source tree and publishes only the exact prerelease image
|
versions build from the checked-out source tree by default and publish only the
|
||||||
tag for prerelease versions.
|
exact prerelease image tag.
|
||||||
|
|
||||||
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
# Explorer sidebar and side pane
|
|
||||||
|
|
||||||
The Explorer sidebar and the side pane share panel implementations, but they have different shell
|
|
||||||
contracts.
|
|
||||||
|
|
||||||
| Surface | Purpose | Lifecycle |
|
|
||||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
|
||||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
|
||||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
|
||||||
| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
|
|
||||||
|
|
||||||
## Panel host contract
|
|
||||||
|
|
||||||
Every 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.
|
|
||||||
|
|
||||||
Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons,
|
|
||||||
so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Sheets
|
|
||||||
|
|
||||||
On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher
|
|
||||||
keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
|
|
||||||
(`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.
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
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
|
|
||||||
mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
|
|
||||||
the sheet's tab into a pane, and edits the autosave has not started writing are lost.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
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
|
|
||||||
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,
|
|
||||||
even for a different PR; moving or reordering it remains the user's choice.
|
|
||||||
Callers request the content and never choose the shell.
|
|
||||||
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
|
|
||||||
routes later presses to the working diff through the shared diff preference.
|
|
||||||
|
|
||||||
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
|
||||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
|
||||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
|
||||||
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Side pane
|
|
||||||
|
|
||||||
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
|
||||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
|
||||||
right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
|
|
||||||
mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
|
|
||||||
pane, so callers never check whether splits render.
|
|
||||||
|
|
||||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
|
||||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
|
||||||
not automatically create an agent draft tab; choosing Agent opens one. Explorer cannot replace the
|
|
||||||
workspace canvas, even when visible. Restoring a saved layout enforces the same rule while preserving Explorer and saved
|
|
||||||
tab content. There is no hidden side-pane lifecycle.
|
|
||||||
|
|
||||||
Placement intent still controls existing tabs:
|
|
||||||
|
|
||||||
| Mode | New target | Existing target |
|
|
||||||
| --------- | --------------------------- | --------------------------------- |
|
|
||||||
| `pane` | opens in the requested pane | moves to the requested pane |
|
|
||||||
| `prefer` | opens in the requested pane | stays where the user placed it |
|
|
||||||
| `focused` | opens in the focused pane | focuses it where it already lives |
|
|
||||||
| `ambient` | opens in a compatible pane | focuses it where it already lives |
|
|
||||||
|
|
||||||
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
|
|
||||||
new target and never yanks an existing tab out of a user-selected pane.
|
|
||||||
|
|
||||||
## Routing preferences
|
|
||||||
|
|
||||||
Desktop **Settings → Layout → Open location** has independent Main panel or On the side choices for
|
|
||||||
Explorer Files, diffs, chat files, files opened from diffs, and subagents. They default to Main
|
|
||||||
panel. Mobile ignores them.
|
|
||||||
|
|
||||||
Pull requests have a three-way open location: Main panel, On the side, or Explorer sidebar. Explorer
|
|
||||||
sidebar is the default. Compact layouts always open pull requests in Explorer regardless of this
|
|
||||||
desktop preference.
|
|
||||||
|
|
||||||
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
|
||||||
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
|
||||||
specific shell inside a panel.
|
|
||||||
@@ -17,9 +17,7 @@ Each layout owns only the routes directly inside its directory.
|
|||||||
`h/[serverId]/index`.
|
`h/[serverId]/index`.
|
||||||
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
|
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
|
||||||
relative screen names: `index`, `workspace/[workspaceId]/index`,
|
relative screen names: `index`, `workspace/[workspaceId]/index`,
|
||||||
`agent/[agentId]`, `sessions`, `open-project`, `settings`,
|
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
|
||||||
`plugin/[pluginId]/[surfaceId]`, and
|
|
||||||
`plugin/[pluginId]/[contributionKind]/[contributionId]`.
|
|
||||||
|
|
||||||
Expo Router warns with `[Layout children]: No route named ...` when a layout
|
Expo Router warns with `[Layout children]: No route named ...` when a layout
|
||||||
registers grandchildren. Treat that warning as a route-tree bug. On native, that
|
registers grandchildren. Treat that warning as a route-tree bug. On native, that
|
||||||
@@ -47,81 +45,19 @@ state.
|
|||||||
This split is deliberate. The host layout must mount first so native local
|
This split is deliberate. The host layout must mount first so native local
|
||||||
dynamic params exist before any nested workspace leaf is selected.
|
dynamic params exist before any nested workspace leaf is selected.
|
||||||
|
|
||||||
## Error Recovery
|
|
||||||
|
|
||||||
The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload
|
|
||||||
remounts the router at `/open-project`, preserving saved workspace layouts and
|
|
||||||
the remembered selection. Do not move the boundary inside the root layout:
|
|
||||||
catching a render error there unmounts the navigator, so the recovery button
|
|
||||||
cannot reliably dispatch a navigation reset. Starting recovery at `/` would
|
|
||||||
restore the same workspace and could repeat the crash.
|
|
||||||
|
|
||||||
## App-Wide Route Hops
|
## App-Wide Route Hops
|
||||||
|
|
||||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||||
branch on its current route.
|
leaf workspace URL.
|
||||||
|
|
||||||
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
|
|
||||||
When the action names a specific tab, pass it as `target`; that explicit choice
|
|
||||||
is authoritative. Callers should not choose between separate route and tab
|
|
||||||
navigation APIs.
|
|
||||||
|
|
||||||
The root stack owns `h/[serverId]`; the host stack owns
|
The root stack owns `h/[serverId]`; the host stack owns
|
||||||
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
|
||||||
root host route and pass the nested workspace screen when a host route is
|
root host route and pass the nested workspace screen, or Expo Router can append
|
||||||
already mounted, or Expo Router can append extra hidden workspace deck entries.
|
extra hidden workspace deck entries.
|
||||||
The workspace navigation helper inspects the mounted navigation state to make
|
|
||||||
that decision; if no host route is mounted yet, it falls back to ordinary route
|
|
||||||
navigation. Both paths wait for the root navigation container to be ready. The
|
|
||||||
workspace navigation owner keeps the latest pending intent across ref
|
|
||||||
re-registration and applies it on the container's `ready` event; routes must not
|
|
||||||
add their own readiness retries.
|
|
||||||
|
|
||||||
Those hidden entries are not harmless: composer floating panels can measure
|
Those hidden entries are not harmless: composer floating panels can measure
|
||||||
against the wrong deck and disappear offscreen. Follow the
|
against the wrong deck and disappear offscreen.
|
||||||
[retained panel measurement rules](coding-standards.md#retained-panel-measurements)
|
|
||||||
for components that intentionally remain mounted while hidden.
|
|
||||||
|
|
||||||
Hidden host routes may keep their local params while an app-wide route is
|
|
||||||
foregrounded. Active-workspace observers must prefer the current pathname and
|
|
||||||
only use local param fallback during cold mount (`/` or empty pathname), or a
|
|
||||||
hidden workspace can overwrite the remembered workspace before Settings or
|
|
||||||
History returns.
|
|
||||||
|
|
||||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
|
||||||
Back returns to that host's Plugins page.
|
|
||||||
|
|
||||||
Settings detail routes are separate siblings on purpose. Keep
|
|
||||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
|
||||||
distinct route names. `router.dismissTo()` ultimately matches stack entries by
|
|
||||||
route name. A single catch-all Settings route would make project detail and the
|
|
||||||
projects index the same route; Back would update params in place and leave a
|
|
||||||
phantom detail entry underneath. The host routes also stay outside the
|
|
||||||
store-ready protected group so a cold host deep link can survive daemon startup.
|
|
||||||
Do not collapse this topology with a catch-all, `getId`, or
|
|
||||||
`dangerouslySingular` workaround.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Params
|
## Params
|
||||||
|
|
||||||
@@ -151,28 +87,6 @@ Keep workspace identity and retention outside native-stack `getId` and
|
|||||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||||
reordering an already-mounted workspace screen.
|
reordering an already-mounted workspace screen.
|
||||||
|
|
||||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
|
||||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
|
||||||
with its light default background. A screen-level wrapper can hide that surface
|
|
||||||
while settled, but Android may expose it for one frame when navigation crosses
|
|
||||||
from a nested stack to its parent stack. This is especially visible when an
|
|
||||||
app-wide route such as `/new` opens from a dark workspace.
|
|
||||||
|
|
||||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
|
||||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
|
||||||
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
|
|
||||||
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
|
|
||||||
root stack's `h/[serverId]`) is passed in `nestedNavigatorScreens` so the nested
|
|
||||||
stack owns its own screens. See [unistyles.md](unistyles.md).
|
|
||||||
|
|
||||||
## Regression Shape
|
## Regression Shape
|
||||||
|
|
||||||
Pure helper tests are useful but not enough. The failure mode here is native
|
Pure helper tests are useful but not enough. The failure mode here is native
|
||||||
@@ -198,8 +112,8 @@ Before landing route changes:
|
|||||||
|
|
||||||
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
- [ ] Did you change `packages/app/src/app`? Re-read this file.
|
||||||
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
|
||||||
- [ ] Did a route return to a workspace? Use `navigateToWorkspace()` and pass a
|
- [ ] Did an app-wide route return to a workspace? Use
|
||||||
`target` when the action names a specific tab.
|
`navigateToHostWorkspaceRoute()`.
|
||||||
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
- [ ] Did you add a route? Register it in the layout that directly owns it.
|
||||||
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
|
||||||
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
- [ ] Did native show a blank screen without a crash? Suspect route ownership
|
||||||
|
|||||||
@@ -2,32 +2,20 @@
|
|||||||
|
|
||||||
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
||||||
|
|
||||||
|
Icons are inlined as SVG strings in:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/app/src/components/material-file-icons.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is auto-generated. Do not edit it by hand.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Two files:
|
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||||
|
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||||
- `packages/app/src/components/material-file-icons.ts` — the vendor table. `SVG_ICONS` maps icon
|
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||||
names to SVG strings copied verbatim from the theme, `EXTENSION_TO_ICON` maps extensions to icon
|
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
|
||||||
names, and `getRawFileIconSvg(fileName)` looks one up with a generic fallback. Keep it a faithful
|
|
||||||
copy so re-copying an icon stays a mechanical edit.
|
|
||||||
- `packages/app/src/components/file-icon-svg.ts` — `getFileIconSvg(fileName)`, the only thing
|
|
||||||
anyone should import. It desaturates the vendor colours and caches the result.
|
|
||||||
|
|
||||||
Render with `<MaterialFileIcon fileName size />` rather than reaching for `SvgXml` yourself.
|
|
||||||
|
|
||||||
## Why the colours are toned down
|
|
||||||
|
|
||||||
material-icon-theme picks its palette for VS Code's file tree, where the icon is the only colour on
|
|
||||||
the row. Ours share rows with status dots, diff stats, and check badges, and a column of them at
|
|
||||||
full chroma is the loudest thing in the panel.
|
|
||||||
|
|
||||||
Every hex in the SVG is pulled toward neutral at the same perceived lightness (`desaturateHexColor`
|
|
||||||
in `packages/app/src/utils/color.ts`, OKLab — scaling HSL saturation instead would darken the
|
|
||||||
yellows and barely touch the blues). Hue survives, which is the only part of an icon's colour that
|
|
||||||
carries meaning at 16pt.
|
|
||||||
|
|
||||||
`ICON_CHROMA` in `file-icon-svg.ts` is the single knob. Move it; do not add per-icon overrides, or
|
|
||||||
we are back to hand-picking 53 colours.
|
|
||||||
|
|
||||||
## Adding a new icon
|
## Adding a new icon
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# File observation
|
|
||||||
|
|
||||||
Use `packages/server/src/server/file-observer` for recursive filesystem observation. Create one observer service at the owning service boundary, subscribe roots through it, and close it during owner shutdown. Keep platform watchers, directory discovery, exclusion reconciliation, event batching, failure cleanup, diagnostics, and teardown inside the module. A consumer supplies a root and excluded subtrees; it must not branch on the platform or manage child watchers.
|
|
||||||
|
|
||||||
Do not replace the Linux implementation with `fs.watch({ recursive: true })`. Node 22 implements Linux recursion in JavaScript by walking the tree and watching every file and directory. Paseo's directory-only Linux watcher already exhausted resources before ignored-tree pruning and a watcher cap were added in [#794](https://github.com/getpaseo/paseo/pull/794).
|
|
||||||
|
|
||||||
Closing an observation must remain safe after its root is renamed or removed. Workspace archive removes owned worktrees while releasing observation references, so teardown cannot assume the watched path still exists.
|
|
||||||
|
|
||||||
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
|
|
||||||
|
|
||||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
|
||||||
|
|
||||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
|
||||||
|
|
||||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
|
||||||
|
|
||||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
|
||||||
|
|
||||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
|
||||||
|
|
||||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
|
||||||
@@ -11,7 +11,7 @@ canonical files and are trying to add or change one.
|
|||||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||||
|
|
||||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||||
@@ -20,29 +20,6 @@ input focused while its scrollable list lives in a Portal. There is no shared
|
|||||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||||
until then prefer copying the closest file and trimming.
|
until then prefer copying the closest file and trimming.
|
||||||
|
|
||||||
## Popover width contract
|
|
||||||
|
|
||||||
Combobox desktop popovers are never narrower than their trigger, and they grow
|
|
||||||
with content up to a ceiling that is never below the trigger:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
|
|
||||||
const frameStyle = { minWidth: floor, maxWidth: Math.max(400, floor) };
|
|
||||||
```
|
|
||||||
|
|
||||||
`desktopMinWidth` is an explicit floor-raiser. It does not cap width, and the
|
|
||||||
trigger still wins when it is wider. Changing this default requires re-verifying
|
|
||||||
every consumer listed here.
|
|
||||||
|
|
||||||
Consumers: `composer/agent-controls/mode-control.tsx`,
|
|
||||||
`composer/agent-controls/index.tsx`, `composer/index.tsx`,
|
|
||||||
`components/combined-model-selector.tsx`, `components/hosts/host-picker.tsx`
|
|
||||||
(including `components/hosts/host-filter.tsx`), `components/branch-switcher.tsx`,
|
|
||||||
`components/left-sidebar.tsx`, `components/ui/select-field.tsx` (schedule form),
|
|
||||||
`screens/new-workspace-screen.tsx` plus `screens/new-workspace/project-picker.ts`,
|
|
||||||
`components/import-session-sheet.tsx`, `screens/workspace/workspace-screen.tsx`,
|
|
||||||
`screens/settings-screen.tsx`, and `screens/project-settings-screen.tsx`.
|
|
||||||
|
|
||||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||||
|
|
||||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||||
@@ -57,11 +34,8 @@ Android touches sailed straight through to the chat scroll view behind it.
|
|||||||
|
|
||||||
Two escape hatches in the codebase:
|
Two escape hatches in the codebase:
|
||||||
|
|
||||||
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||||
hit-testing starts fresh in that window. If its children use gesture-handler,
|
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||||
the Modal owner must wrap its content in a full-height `GestureHandlerRootView`:
|
|
||||||
the app's root does not span Android windows. React context alone does not
|
|
||||||
establish a native gesture root. Side effect: a Modal opening on
|
|
||||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||||
(the composer's input must stay focused so the user keeps typing).
|
(the composer's input must stay focused so the user keeps typing).
|
||||||
@@ -73,36 +47,7 @@ Two escape hatches in the codebase:
|
|||||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||||
cover them.
|
cover them.
|
||||||
|
|
||||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||||
|
|
||||||
On web, dropdown menus render into the shared `overlay-root`, not React Native
|
|
||||||
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
|
|
||||||
ordinary portals regardless of `z-index`, which would hide app toasts and
|
|
||||||
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
|
||||||
lets tooltip portals paint above both.
|
|
||||||
|
|
||||||
The shared overlay scale is relative for interactive surfaces: a base floating
|
|
||||||
panel is below a base modal, while a floating panel rendered from inside a modal
|
|
||||||
inherits that modal's layer and paints above it. Wrap portal content in
|
|
||||||
`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web
|
|
||||||
comboboxes must use `overlay-root` too. Rendering them through React Native
|
|
||||||
Web's `<Modal>` puts them in the browser top layer, where no ordinary modal
|
|
||||||
portal can cover them.
|
|
||||||
|
|
||||||
Painting and keyboard ownership use the same relative layer model. Register
|
|
||||||
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
|
|
||||||
highest painted scope alone receives overlay keys, traps focus, and restores
|
|
||||||
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
|
|
||||||
stacked overlays would both close on one keypress.
|
|
||||||
|
|
||||||
If an overlay is rendered by a global host rather than beneath its opener in
|
|
||||||
the React tree, carry the opener's current layer through the host store and
|
|
||||||
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
|
|
||||||
ownership silently reset at the app root. When the opener is a global keyboard
|
|
||||||
action and has no component context to carry, resolve the host layer with
|
|
||||||
`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
|
|
||||||
current top registered layer before the new host joins the stack; do not give a
|
|
||||||
global dialog a fixed root-derived modal layer.
|
|
||||||
|
|
||||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||||
|
|
||||||
@@ -115,10 +60,11 @@ quietly relying on:
|
|||||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||||
- **Transforms.** The composer moves with the keyboard through a
|
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
|
||||||
`KeyboardTranslateView` transform. A portal'd popover is outside that view, so
|
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
|
||||||
it does not move with the composer unless it renders its own
|
translate/padding styles. The composer and chat content must both read that
|
||||||
`KeyboardTranslateView` with the same translation.
|
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
|
- **Layering.** The default root host renders after app content, so it sits
|
||||||
above compact sidebars. Content overlays that must sit below sidebars should
|
above compact sidebars. Content overlays that must sit below sidebars should
|
||||||
use the current `FloatingPanelPortalHost`.
|
use the current `FloatingPanelPortalHost`.
|
||||||
@@ -126,170 +72,43 @@ quietly relying on:
|
|||||||
renders inside its host, not necessarily at window origin. Position anchored
|
renders inside its host, not necessarily at window origin. Position anchored
|
||||||
content relative to the host: `anchorRect - hostRect`. This is what
|
content relative to the host: `anchorRect - hostRect`. This is what
|
||||||
`measureFloatingPanelPortalHost()` is for.
|
`measureFloatingPanelPortalHost()` is for.
|
||||||
- **React context.** `@gorhom/portal` is not a React portal — a real one keeps
|
|
||||||
context, this one does not. It stores the element and the host renders it, so
|
|
||||||
context resolves at the _host's_ position. Everything provided between
|
|
||||||
`PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it.
|
|
||||||
This is why app-wide providers wrap `PortalProvider` rather than the reverse.
|
|
||||||
|
|
||||||
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
The fix for transforms is Gotcha 3.
|
||||||
|
|
||||||
## Gotcha 3 — Keyboard layout and portal anchors
|
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||||
|
|
||||||
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
|
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||||
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
|
includes Reanimated-applied transforms (Reanimated updates native view
|
||||||
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
|
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||||
The optional third child is an overlay that moves with the surface. New workspace
|
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||||
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
|
with Reanimated worklets the result is not always stable.
|
||||||
space or translate the composer themselves. Put the dismiss surface behind content
|
|
||||||
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
|
|
||||||
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
|
|
||||||
MOVE events before its native scroll view reaches touch slop. Streaming can hide
|
|
||||||
this failure by delaying the responder grant. Native scroll views retain their own
|
|
||||||
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
|
|
||||||
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
|
|
||||||
need `none`); otherwise their empty padding blocks hit testing of that sibling.
|
|
||||||
The shared screen header uses the same dismiss surface. Keep the
|
|
||||||
header escape available when a capped draft clips all content above it, since
|
|
||||||
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
|
|
||||||
and the stream keeps its existing flick-to-dismiss gesture.
|
|
||||||
|
|
||||||
The dock's private viewport and capacity model are extracted from chat. The
|
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
the keyboard by re-measuring on every frame. Instead,
|
||||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
|
||||||
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
|
the composer uses**:
|
||||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
|
||||||
at its root mount point outside the portal host when changing dock hosts.
|
|
||||||
|
|
||||||
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||||
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
|
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||||
signal because the stock iOS Reanimated value changes at move start, while
|
to the popover wrapper.
|
||||||
writing a replacement Reanimated value every frame interrupts UIKit's hide
|
|
||||||
animation. Keep this platform choice inside the keyboard module's private platform-specific translate files.
|
|
||||||
|
|
||||||
`ComposerDock` always keeps the surface at full height and wraps it in
|
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
the measured position. When the keyboard moves afterward, the delta translates
|
||||||
slide under the header on open and out from under it on close. Do not swap the
|
the popover by exactly the amount the composer translates. They move in
|
||||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
lockstep, no re-measurement needed. Do not call
|
||||||
band or a paused transition when the inverted list updates its viewport.
|
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
|
||||||
|
can briefly report a stale nonzero height with closed progress, and the shared
|
||||||
|
provider is where that is normalized.
|
||||||
|
|
||||||
Do not animate a layout prop through the keyboard transition. On Android
|
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
opened.
|
||||||
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
|
|
||||||
layout. Preserve the translated dock's history scroll range with a far-end
|
|
||||||
content inset on the inverted stream list. Update that inset only when keyboard
|
|
||||||
motion settles; never drive it per frame.
|
|
||||||
|
|
||||||
The translated dock's height is not the composer's available height. Bound the
|
|
||||||
composer against the stationary space below the header inside
|
|
||||||
`composer/dock`, including its controls and attachments. Reserve the
|
|
||||||
keyboard destination at move start on the UI thread. Waiting for a settled JS
|
|
||||||
update lets a long draft disappear behind the header during opening. This is a
|
|
||||||
boundary-time layout change, not an animated layout prop. Keep the input's
|
|
||||||
native intrinsic sizing and internal scrolling. Keep the editing input's
|
|
||||||
`defaultValue` current on every edit: Fabric re-measures the Android input from
|
|
||||||
the JS text whenever its frame changed, so a stale prop inflates or collapses
|
|
||||||
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
|
|
||||||
|
|
||||||
Closing the keyboard moves the composer; it does not enlarge its capacity. The
|
|
||||||
viewport keeps the last keyboard reservation until the next opening replaces
|
|
||||||
it, so a capped draft keeps one height through every keyboard transition
|
|
||||||
instead of expanding on close and shrinking again on open.
|
|
||||||
|
|
||||||
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
|
|
||||||
fullscreen` fills the stationary viewport with the composer slot over the
|
|
||||||
still-mounted content, paints its own surface under the safe areas, and turns
|
|
||||||
the surface translation off. The composer's single tree keeps the editing
|
|
||||||
surface at the same position in both modes, so the mounted editor, its native
|
|
||||||
buffer, selection, and focus survive the toggle and Send's clear always reaches
|
|
||||||
the live input. A viewport crossing the compact breakpoint still remounts it:
|
|
||||||
the app shell swaps its gesture wrapper and the workspace screen
|
|
||||||
mounts tabs in different trees, so every screen below remounts. The draft owner
|
|
||||||
restores the text and the editing session restores the caret. Focus restoration
|
|
||||||
is limited to mouse-driven UIs, using the same fine-pointer policy as autofocus.
|
|
||||||
New workspace keys its sessions by draft in the app-level store for the same
|
|
||||||
reason. The centered form honors fullscreen by style alone: no centering, no
|
|
||||||
translation, setup fields hidden, slot filling the stationary viewport. The
|
|
||||||
global exit action (Escape, hardware back) is registered only for the active
|
|
||||||
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
|
|
||||||
sheet suspends pane focus, and the composer gives up input from the moment the
|
|
||||||
user asks for the compact agent list or explorer, before the panel's motion
|
|
||||||
settles. Pane focus itself stays put under those panels: the agent panel reads
|
|
||||||
it as "the user is in this chat" for attention clearing, and opening the agent
|
|
||||||
list must not clear the attention it shows. On mouse-driven UIs, focus restoration
|
|
||||||
follows pane focus, not the active composer. Keying it on the active composer
|
|
||||||
made sidebar dismissal look like a hidden-tab blur and refocused on close.
|
|
||||||
Native and touch-web navigation must not replay old editor focus, including
|
|
||||||
when blur arrives after pane deactivation or the editor unmounts before blur.
|
|
||||||
Fullscreen toggles keep the editor mounted and need no focus restoration.
|
|
||||||
The band beside the inline editor where the fullscreen glyph sits is the input's
|
|
||||||
own right padding, so a tap anywhere in it focuses the editor whether or not the
|
|
||||||
glyph shows. On web the slot is a fixed box over the visible viewport, which is
|
|
||||||
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
|
|
||||||
height as a bottom margin at move start and end (boundary-time, like the
|
|
||||||
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
|
|
||||||
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
|
|
||||||
and without animating the editor's height. The screen that owns the chrome
|
|
||||||
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
|
|
||||||
active tab and New workspace reads the mounted composer's session. Hosts that
|
|
||||||
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
|
|
||||||
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
|
|
||||||
registration, focus untouched) and the Android back button leave fullscreen.
|
|
||||||
|
|
||||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
|
||||||
setup fields as the first dock child so they move up under the header as the draft
|
|
||||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
|
||||||
Yoga split the shortage between them: rows vanished behind the composer while
|
|
||||||
the input lost lines at the same time. Keep that separation inside the dock,
|
|
||||||
shared by every phone host. The centered tablet form caps the complete block
|
|
||||||
for header clearance, but reserves the composer's own capped height before
|
|
||||||
shrinking the setup scroll view. The form must not take editing space from the
|
|
||||||
composer.
|
|
||||||
|
|
||||||
Move the stream and composer together through `ComposerDock`. Do not translate
|
|
||||||
them independently.
|
|
||||||
|
|
||||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
interactive move, but not at the terminal event, so JS contention can otherwise
|
|
||||||
leave the last height/progress pair stuck in either the open or closed state.
|
|
||||||
Keep that terminal reconciliation on the UI thread; a later focus or blur must
|
|
||||||
not be required to repair the offset.
|
|
||||||
|
|
||||||
## Gotcha 4 — Host-relative positioning before platform offsets
|
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||||
|
|
||||||
The generic anchored-overlay rule is:
|
The generic anchored-overlay rule is:
|
||||||
|
|
||||||
1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it
|
1. Measure the anchor with `measureInWindow`.
|
||||||
moves with the keyboard (Gotcha 3).
|
|
||||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||||
3. Position with anchor coordinates relative to the host:
|
3. Position with anchor coordinates relative to the host:
|
||||||
|
|
||||||
@@ -298,12 +117,10 @@ left = anchorRect.x - hostRect.x;
|
|||||||
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
||||||
```
|
```
|
||||||
|
|
||||||
Do this before adding any platform offset. The app draws edge-to-edge on Android, so
|
Do this before adding any platform offset. If anchor and host are both measured
|
||||||
`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate
|
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
|
||||||
space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor:
|
Only add a status-bar offset when the render surface is not measured in the same
|
||||||
React Native 0.86 stopped subtracting the status bar from window coordinates under
|
coordinate system. See `tooltip.tsx` for that separate case.
|
||||||
edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu
|
|
||||||
one status bar below its trigger.
|
|
||||||
|
|
||||||
## Gotcha 5 — The two-measurement flash
|
## Gotcha 5 — The two-measurement flash
|
||||||
|
|
||||||
@@ -360,69 +177,6 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
|||||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||||
track phase.
|
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
|
|
||||||
what `patches/@gorhom+bottom-sheet+*.patch` adds.
|
|
||||||
|
|
||||||
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
|
|
||||||
close path then parks the sheet at index -1 with its content mounted; the owner hears
|
|
||||||
`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the
|
|
||||||
sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and
|
|
||||||
unmounts it, so its backdrop and Back press call `close()`.
|
|
||||||
|
|
||||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
|
||||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
|
||||||
and restoration then silently do nothing. Callers may use callback refs on the wrapper, which
|
|
||||||
forwards commands to the current Gorhom handle.
|
|
||||||
|
|
||||||
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
|
|
||||||
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
|
|
||||||
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
|
|
||||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal
|
|
||||||
(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the
|
|
||||||
authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the
|
|
||||||
same push/replace presentation semantics.
|
|
||||||
|
|
||||||
## Gotcha 7 — A sheet cannot read context from its call site
|
|
||||||
|
|
||||||
React cannot copy contexts reflectively, so the only way across the teleport in
|
|
||||||
Gotcha 2 is to render the providers a second time, with values captured on the
|
|
||||||
near side where they are still readable. `IsolatedBottomSheetModal` takes a
|
|
||||||
`contextBridge` for exactly that:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const contextBridge = useCallback<ContextBridge>(
|
|
||||||
(content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
|
|
||||||
[thing],
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
The prop is **required**, and `null` is a real answer. A sheet whose content
|
|
||||||
needs nothing local should have to say so, because the failure mode is silent
|
|
||||||
until someone adds a `useContext` deep inside and it throws on device only —
|
|
||||||
never on web, where the desktop path uses a real portal. `menu-surface.tsx`
|
|
||||||
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
|
## Recipe for a new anchored panel
|
||||||
|
|
||||||
Before you write a new one, ask:
|
Before you write a new one, ask:
|
||||||
@@ -434,8 +188,8 @@ Before you write a new one, ask:
|
|||||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||||
raw window coordinates as local Portal coordinates.
|
raw window coordinates as local Portal coordinates.
|
||||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||||
yes, anchor it with `useKeyboardAnchor()` and wrap it in a
|
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||||
`KeyboardTranslateView` with the anchor's translation (Gotcha 3).
|
If no, you can probably skip the transform entirely.
|
||||||
5. **Will the panel's content height vary?** If yes, you need both
|
5. **Will the panel's content height vary?** If yes, you need both
|
||||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||||
@@ -445,6 +199,3 @@ Before you write a new one, ask:
|
|||||||
bounded**. Verify before you commit.
|
bounded**. Verify before you commit.
|
||||||
|
|
||||||
Then copy the closest canonical file and trim.
|
Then copy the closest canonical file and trim.
|
||||||
|
|
||||||
Building a **menu** rather than a bare panel? Don't. Use the menu engine — it already solves
|
|
||||||
everything above, plus submenus, sheets, and hover intent. See [menus.md](menus.md).
|
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
# Adding a Git Forge to Paseo
|
|
||||||
|
|
||||||
Paseo's forge layer is a registry/manifest system. A forge is a runtime concern:
|
|
||||||
shared protocol messages carry neutral/open facts, the server adapter owns
|
|
||||||
behavior, and the app owns bundled presentation/runtime interpretation.
|
|
||||||
|
|
||||||
The maintainer litmus test is the rule of thumb:
|
|
||||||
|
|
||||||
> Adding a new forge means adding files in a new directory/module that implement
|
|
||||||
> an interface, plus one entry in the centralized registry/manifest for that
|
|
||||||
> package.
|
|
||||||
|
|
||||||
## The Three Registrations
|
|
||||||
|
|
||||||
For forge `acme`, the expected end state is:
|
|
||||||
|
|
||||||
1. **Protocol manifest** - optional, only when the forge should be presented by
|
|
||||||
shared manifest data. Add one `ForgeDefinition` to
|
|
||||||
`packages/protocol/src/forge-manifest.ts`.
|
|
||||||
|
|
||||||
2. **Server adapter** - add `packages/server/src/services/acme-service.ts`
|
|
||||||
implementing `ForgeService`, any adapter-owned fact types/guards/constants
|
|
||||||
beside it, and one `defaultForgeRegistry` entry in
|
|
||||||
`packages/server/src/services/forge-registry.ts`.
|
|
||||||
|
|
||||||
3. **App modules** - a forge splits into a pure logic half and a view half so
|
|
||||||
logic consumers (URL builders, merge-capability, native checks, and the
|
|
||||||
Node-based e2e harness) never pull the client rendering stack:
|
|
||||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`
|
|
||||||
(source links and pasted issue/change-request reference paths), optional
|
|
||||||
`facts` (schema, merge-capability, native-check fallbacks). No
|
|
||||||
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
|
||||||
`packages/app/src/git/forges/index.ts`.
|
|
||||||
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
|
||||||
under `packages/app/src/components/icons/`), optional `brandColor`, optional
|
|
||||||
`paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in
|
|
||||||
`packages/app/src/git/forges/view.ts`.
|
|
||||||
|
|
||||||
There should be no protocol typed-union arm, no central app icon/color/url/facts
|
|
||||||
map, and no central server union of known forge facts.
|
|
||||||
|
|
||||||
## Protocol
|
|
||||||
|
|
||||||
`forgeSpecific` on PR status is an open envelope:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
z.object({ forge: z.string() }).passthrough();
|
|
||||||
```
|
|
||||||
|
|
||||||
The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace
|
|
||||||
brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge ===
|
|
||||||
"gitea"` when they share the same facts shape, while top-level `status.forge`
|
|
||||||
keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`).
|
|
||||||
|
|
||||||
Protocol does not validate per-forge fact fields. Consumers that understand a
|
|
||||||
facts family validate at runtime with their own schema/guard. Unknown or
|
|
||||||
schema-mismatched facts render neutrally instead of failing the whole message
|
|
||||||
parse. This is the version-skew win: an old client can receive facts from a
|
|
||||||
newer forge and still show the PR/MR in a neutral state.
|
|
||||||
|
|
||||||
Check results keep a stable normalized `status` for aggregation and an optional
|
|
||||||
open `traits: string[]` for composable forge-neutral refinements such as
|
|
||||||
`manual`, `action_required`, and `warning`. Consumers ignore unknown traits and
|
|
||||||
fall back to `status`; `rawStatus` preserves provider-native detail. Do not add
|
|
||||||
provider vocabulary to the normalized status union. Producers and the
|
|
||||||
presentation layer share the trait constants in
|
|
||||||
`packages/protocol/src/check-traits.ts` — use those instead of string literals
|
|
||||||
so classification and emission cannot drift apart.
|
|
||||||
|
|
||||||
Shipped GitHub compatibility stays separate:
|
|
||||||
|
|
||||||
- The forge compatibility layer first shipped in `v0.2.0-beta.1`. Its initial
|
|
||||||
six-month cleanup target is `2027-01-17`, after supported client and daemon
|
|
||||||
floors reach stable `v0.2.0`.
|
|
||||||
- `status.github` remains accepted for released peers.
|
|
||||||
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
|
||||||
into `status.github` for older clients.
|
|
||||||
- Do not add a compatibility shim unless a released peer (<= 0.1.102) can
|
|
||||||
actually produce the state.
|
|
||||||
|
|
||||||
## Server
|
|
||||||
|
|
||||||
The server-wide status type only promises:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Adapter-owned files define the typed shapes and guards, for example
|
|
||||||
`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep
|
|
||||||
strong internal types for construction and command guards, but shared server
|
|
||||||
code must not grow a central list of forge fact arms.
|
|
||||||
|
|
||||||
Register the adapter in `defaultForgeRegistry` with:
|
|
||||||
|
|
||||||
- `createService`
|
|
||||||
- `matchesHost` from manifest `cloudHosts`
|
|
||||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
|
||||||
|
|
||||||
Current change-request lookup uses two identities deliberately:
|
|
||||||
|
|
||||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
|
||||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
|
||||||
behind, or contain commits that have not been pushed yet.
|
|
||||||
- A merged or closed PR/MR belongs to the checkout only when its recorded head
|
|
||||||
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
|
|
||||||
selecting the newest terminal request by branch alone can silently attach an
|
|
||||||
old promotion or feature request to new work.
|
|
||||||
|
|
||||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
|
||||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
|
||||||
previous commit's cached terminal status until the cache expires.
|
|
||||||
|
|
||||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
|
||||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
|
||||||
host that is either a known cloud host or one the CLI is already authenticated
|
|
||||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
|
||||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
|
||||||
|
|
||||||
## App
|
|
||||||
|
|
||||||
Each app forge splits into two modules so pure logic never imports the client
|
|
||||||
rendering stack:
|
|
||||||
|
|
||||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
|
|
||||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
|
||||||
|
|
||||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `icon`
|
|
||||||
- `brandColor` (`null` for neutral; GitHub intentionally uses `null`)
|
|
||||||
- optional `paneContributions`
|
|
||||||
|
|
||||||
Two registries live under `packages/app/src/git/forges/`:
|
|
||||||
`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability
|
|
||||||
derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`)
|
|
||||||
drives icon/color lookup and PR-pane contributions. Logic consumers must import
|
|
||||||
the logic registry only — importing the view registry (or a `.view.tsx` module)
|
|
||||||
from a logic path pulls react-native and breaks the Node-based e2e harness.
|
|
||||||
|
|
||||||
Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the
|
|
||||||
Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon
|
|
||||||
call sites use `withUnistyles` and a `uniProps` mapping such as:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
|
|
||||||
```
|
|
||||||
|
|
||||||
Facts modules use one source of truth: a Zod schema. Helpers like
|
|
||||||
`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution`
|
|
||||||
derive guards from `schema.safeParse` and re-parse before invoking typed
|
|
||||||
derivers/renderers. That keeps typed derivers away from the open wire envelope.
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
To add `acme`:
|
|
||||||
|
|
||||||
1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its
|
|
||||||
label, nouns, icon kind, sign-in CLI, or cloud hosts.
|
|
||||||
2. Add `acme-service.ts` implementing `ForgeService`.
|
|
||||||
3. Add `acme-facts.ts` beside the adapter if it reports native facts.
|
|
||||||
4. Add one `defaultForgeRegistry` entry.
|
|
||||||
5. Add `packages/app/src/git/forges/acme.ts` (logic) and
|
|
||||||
`packages/app/src/git/forges/acme.view.tsx` (view).
|
|
||||||
6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one
|
|
||||||
`CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`).
|
|
||||||
7. Add/update the icon component only if the client bundle should show a brand
|
|
||||||
mark.
|
|
||||||
8. If the forge's CI/data model does not fit an existing required
|
|
||||||
`ForgeService` field, widen the shared interface (plus the protocol schema
|
|
||||||
and its guards) instead of faking a value — e.g. Gitea Actions runs carry no
|
|
||||||
check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with
|
|
||||||
`workflowRunId` as the alternative address. Expect this step to touch
|
|
||||||
`forge-service.ts`, `messages.ts`, and the call-site guards of the other
|
|
||||||
adapters. Widening a shared field is not forge-local: it also affects the
|
|
||||||
already-shipped forges/GitHub call sites and the capability-gated RPC (e.g.
|
|
||||||
`forgeCheckDetails`), so verify every consumer rather than assuming the change
|
|
||||||
only reaches the new adapter.
|
|
||||||
9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol
|
|
||||||
checkout PR schema, app forge URL/presentation tests, app merge capability,
|
|
||||||
and any PR-pane native data tests touched.
|
|
||||||
|
|
||||||
Run `npm run typecheck` after each implementation slice. If protocol or client
|
|
||||||
declarations are stale, run `npm run build:client`; if server/CLI declarations
|
|
||||||
are stale, run `npm run build:server`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- GitHub is a normal registry entry plus released compatibility shims. Keep all
|
|
||||||
real shims tagged with `COMPAT(name)`.
|
|
||||||
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
|
||||||
top-level brand is Forgejo or Codeberg.
|
|
||||||
- Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a
|
|
||||||
page size below the requested limit, and omits rerun-attempt numbers. Walk a
|
|
||||||
bounded number of pages using `total_count` and filter locally. Preserve
|
|
||||||
ambiguous same-run tasks: display names are not stable job identities, so
|
|
||||||
name-based deduplication can hide a real failure.
|
|
||||||
- A blocked Gitea Actions task is not necessarily manual: only add the
|
|
||||||
`action_required` trait when the matching run metadata reports
|
|
||||||
`need_approval`.
|
|
||||||
- Brand icons are bundled React components, so they cannot come from protocol
|
|
||||||
manifest data.
|
|
||||||
- Source URL grammars are app-side because blob/tree path syntax is
|
|
||||||
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
|
||||||
rather than constructing a wrong URL.
|
|
||||||
- Pasted issue/change-request URL recognition is also app-side and registry
|
|
||||||
driven. Put the forge's route infixes in `urlGrammar.referencePaths`; the
|
|
||||||
neutral extractor matches the pasted host and full repository path against
|
|
||||||
the current remote before it invokes cwd-routed forge search. Do not add a
|
|
||||||
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
|
||||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
|
||||||
not protocol.
|
|
||||||
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
|
||||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
|
||||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
|
||||||
until GitLab reports a terminal status.
|
|
||||||
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
|
|
||||||
targets per host, batch their reads, and stop until GitHub's reset time when
|
|
||||||
the reserve is exhausted. Never add a per-target GitHub request to the poll
|
|
||||||
path. Resolve fork PRs through their parent repository without abandoning the
|
|
||||||
shared batch.
|
|
||||||