Compare commits

...

1028 Commits

Author SHA1 Message Date
Mohamed Boudra
266eb375ff chore(release): cut 0.1.53 2026-04-12 13:37:21 +07:00
Mohamed Boudra
a9dcef80ed Add 0.1.53 changelog 2026-04-12 13:36:00 +07:00
Mohamed Boudra
ae03e8283a Fix routeTree.gen.ts formatting churn by matching Biome's quote style 2026-04-12 12:59:57 +07:00
Mohamed Boudra
5aa98f13f2 fix: update workspace test stubs for emission dedup gating
Add missing lastEmittedByWorkspaceId to test subscription stubs,
update activityAt assertion to null, and adjust dedup test to expect
deduplicated emissions.
2026-04-12 12:53:54 +07:00
Mohamed Boudra
698a18e88f Add GitHub star counter to website header 2026-04-12 12:53:21 +07:00
Mohamed Boudra
44cc7a3771 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-12 12:43:15 +07:00
Mohamed Boudra
acb294b9eb Isolate branch switcher query from WorkspaceScreenContent to prevent 20ms re-renders
Move useBranchSwitcher into BranchSwitcher component so query state
changes only re-render the small header widget instead of the entire
workspace screen. Also memo-wrap AgentStatusBar to skip re-renders
on keystroke.
2026-04-12 12:38:46 +07:00
Mohamed Boudra
cde10485d4 Memoize agent object in AgentPanelBody to prevent WebStreamViewport re-renders on keystroke 2026-04-12 12:18:51 +07:00
github-actions[bot]
40fc81bbbc fix: update lockfile signatures and Nix hash 2026-04-12 05:11:46 +00:00
Mohamed Boudra
0d340959d7 Gate workspace_update emissions to prevent unnecessary sidebar re-renders
Server: remove activityAt computation from workspace descriptors (always
sends null for backwards compat) and deduplicate emissions in
emitWorkspaceUpdatesForWorkspaceIds using fast-deep-equal — workspace
updates are now only sent when the descriptor actually changed.

Client: remove activityAt from WorkspaceDescriptor, sidebar entry types,
and baseline sort comparators. Workspace/project ordering is fully
handled by the persisted sidebar order store.
2026-04-12 12:10:14 +07:00
Mohamed Boudra
b9269df3e4 Add hidden agent tab intent to prevent reconciliation from re-adding closed tabs
When bulk-closing tabs (e.g. "Close to the left"), the reconciliation
useEffect would re-add tabs for still-active agents before the server
archived them, causing tabs to flicker back then disappear one by one.

Adds hiddenAgentIdsByWorkspace as the inverse of pinnedAgentIds: a
local, in-memory-only override that prevents reconciliation from
re-opening tabs for agents the user just closed. Hidden intent is
automatically cleared when explicitly opening, retargeting, or pinning
an agent tab.
2026-04-12 12:01:14 +07:00
Mohamed Boudra
dbc525c2ad Delay shortcut badge display by 150ms to avoid sidebar re-renders on quick modifier taps 2026-04-12 12:00:45 +07:00
Mohamed Boudra
56512d852b Run Biome formatter and add formatting guidance to CLAUDE.md 2026-04-12 10:40:14 +07:00
Mohamed Boudra
40e27f5a00 Centralize git/GitHub runtime state into WorkspaceGitService
Consolidate workspace git watch lifecycle, background fetch scheduling,
and GitHub PR polling from session.ts and BackgroundGitFetchManager into
a single daemon-scoped WorkspaceGitService. Sessions now subscribe to
the service for push-driven updates instead of coordinating watchers and
fetch intervals themselves. Workspace payloads gain optional gitRuntime
and githubRuntime fields for richer client-side display.
2026-04-12 10:38:50 +07:00
Mohamed Boudra
f35b0b8b7d Split streaming markdown into memoized blocks for mobile perf (#260)
Instead of re-parsing the entire markdown string on every streaming
token, split at paragraph boundaries (double newlines) while keeping
code fences intact. Each block renders as its own React.memo'd
<Markdown> component so only the last active block re-renders.
2026-04-12 10:23:49 +07:00
Mohamed Boudra
fb5e2608e0 Add paseo agent reload CLI command (#258)
Exposes the existing refresh/reload functionality as a CLI command so
users can automate agent reloads (e.g. after Codex account rotation).
For Codex agents this kills the old app-server process and spawns a
fresh one that picks up new preferences from disk.
2026-04-12 10:08:47 +07:00
Mohamed Boudra
6d4cc53a0a ci: fix all tests to green (#236)
* ci: add CI status tracker for test fix iteration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cli: honor daemon connect timeout

* app: fix e2e helper server path

* e2e: fix helper imports and ws cleanup

* cli: align daemon status tests

* style: autoformat with biome to fix 195 formatting errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* server: fix 4 stale test expectations and setup bug

- logger.test.ts: update expected default level from trace to debug
  matching intentional product change
- session.workspaces.test.ts: only opened worktree reconciles, not
  siblings; add explicit reconcileWorkspaceRecord before owner-change
  assertion
- worktree.test.ts: add explicit git checkout -B main origin/main
  for deterministic CI branch state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* server: align daemon-client test expectations with ignoreWhitespace field

normalizeCheckoutDiffCompare() now always emits ignoreWhitespace: false,
so update the two checkout-diff subscribe test assertions to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: format daemon-client test to satisfy biome

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cli: update provider test for new providers and model lineup

Update 15-provider test expectations to match current product state:
- Provider count: 3 → 5 (added copilot, pi)
- Claude models: 3 → 4 (added claude-opus-4-6[1m])
- Codex models: replace retired gpt-5.1-* with gpt-5.4/gpt-5.4-mini

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* app: fix 18 failing test files with vitest setup and stale expectations

Add vitest.setup.ts to define __DEV__, shim Expo globals, mock
react-native-unistyles/expo-linking, and stub @xterm/addon-ligatures.
Update stale test expectations across combined-model-selector,
use-settings, tool-call-display, sidebar-project-row-model,
sidebar-shortcuts, keyboard-shortcuts, host-runtime,
use-agent-form-state, desktop-permissions, and voice-runtime
to match current source behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add missing highlight build step to app-tests job

The app-tests CI job was missing the `npm run build --workspace=@getpaseo/highlight`
step that all other jobs have. This caused diff-highlighter.test.ts to fail with
"Failed to resolve entry for package @getpaseo/highlight" because the dist/ directory
did not exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: install codex and opencode CLIs in cli-tests job

The 15-provider test expects `provider models codex` and
`provider models opencode` to succeed, which requires the
actual CLI binaries to be on PATH.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* app: fix Playwright e2e helpers using import.meta.url in CJS context

Revert dynamic import path resolution from `new URL(..., import.meta.url)`
to `path.resolve(__dirname, ...)` + `pathToFileURL(...)` in three e2e
helpers. Playwright's TS loader emits CommonJS, where import.meta.url
is undefined, causing "exports is not defined in ES module scope" and
blocking all e2e test discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add missing server build step to playwright job

The Playwright e2e helpers dynamically import from
packages/server/dist/server/server/exports.js, but the CI job
only built highlight and relay dependencies. Add the server build
step after relay so the dist artifacts exist when tests run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(cli): make codex model assertions resilient to catalog changes

The 15-provider test hard-coded exact model IDs (gpt-5.3-codex-spark,
etc.) which depend on the external codex CLI's model/list endpoint.
Replace with structural checks: all IDs in gpt- family, at least one
codex-optimized model, and all models have required fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(cli): make opencode model assertions resilient to catalog changes

The 15-provider test hard-coded exact model IDs (opencode/gpt-5-nano,
openrouter/openai/gpt-5.3-codex) which break when the external opencode
CLI updates its model catalog. Replace with structural checks: at least
one first-party opencode model, at least one OpenAI-backed model, at
least one codex-optimized model, and all models have required fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* app: fix Playwright E2E WebSocket errors on Node 20 + fix format

E2E helpers used DaemonClient without a webSocketFactory, which relies
on globalThis.WebSocket — unavailable in Node 20 (CI). Add a shared
node-ws-factory helper using the ws package (matching the CLI pattern)
and inject it in all three E2E connection helpers. Also fix a biome
formatting issue in the cli provider test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update lockfile signatures and Nix hash

* test(cli): replace OpenAI-specific assertions with generic third-party check

The opencode provider test asserted models with "openai/" or
"openrouter/openai/" prefixes, but these are environment-dependent —
opencode returns whatever providers are connected, and CI may not have
OpenAI configured. Replace with a generic check that at least one
non-opencode/ namespaced model exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: advance stale schedule nextRunAt on daemon restart

On restart, persisted nextRunAt could be in the past, showing stale
dates in `schedule ls`. Now recoverInterruptedRuns() advances any
past-due nextRunAt forward to the next future tick.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: install agent CLIs in playwright job + remove env-dependent opencode test assertions

The Playwright E2E tests (archive-tab, terminal-performance) fail because
the CI job doesn't install Codex/OpenCode binaries that the tests need to
create agents. Add the same install step already used in cli-tests.

The 15-provider CLI test still asserts third-party providers are connected
in OpenCode, which is environment-dependent. Remove that assertion and
lower the minimum model count to 1, keeping only deterministic structural
checks (namespacing, required fields, first-party models).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: apply biome formatting to schedule service files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): parse localDaemon field in daemon supervisor test assertions

The daemon status JSON uses `localDaemon` not `status`, so the polling
condition was always null and timed out after 120s on CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: apply biome formatting to daemon supervisor test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): spawn supervisor directly via node --import tsx instead of npx wrapper

The daemon supervisor tests (22, 23, 25) spawned the supervisor through
`npx tsx` which creates a wrapper process. When SIGINT was sent, the npx
wrapper died with signal=SIGINT instead of forwarding it to the actual
supervisor which handles graceful shutdown. Now matches production startup
pattern using process.execPath with --import tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(server): acquire pid lock for unsupervised daemon workers

The direct worker path (non-supervisor) was not writing paseo.pid,
so `paseo daemon status` could not detect the running daemon. This
caused test 26-daemon-restart-unsupervised to time out in CI waiting
for the status to become "running".

Acquire the pid lock before daemon creation, update it with the
listen address after start, and release on shutdown/error. Supervision
detection now requires both PASEO_SUPERVISED=1 and an active IPC
channel to avoid misclassification when the env var is inherited.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): correct terminal tab testid and navigation URL in Playwright helper

navigateToTerminal() used the bare workspace route instead of the URL
with ?open=terminal:<id> intent, and looked for testid
"workspace-tab-terminal:<id>" (colon) when the real tab key is
"terminal_<id>" (underscore). Both bugs prevented the terminal surface
from ever appearing, causing terminal-performance tests to timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): wait for open-intent redirect before interacting with terminal surface

The navigateToTerminal helper was racing the workspace layout's ?open= redirect,
which returns null during the useEffect cycle. Now waits for the clean workspace
URL before looking for the terminal tab, and removes the fallback that created
a different terminal via the "New terminal tab" button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: apply biome formatting to terminal-perf.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(app): ungate host routes from storeReady to preserve deep links

Host routes (workspace, agent, sessions, etc.) were inside
Stack.Protected guard={storeReady}, causing deep links to be rejected
during initial storage hydration and redirected to /welcome. Move them
outside the guard so they can render their own loading state while
stores hydrate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(app): gate open-intent consumption on navigation readiness

After moving host routes outside Stack.Protected, the workspace layout
could mount before the root navigator was ready. router.replace() would
silently fail, but consumedIntentRef was already set, preventing retries.
Gate the effect on rootNavigationState.key so the intent is only consumed
once Expo Router can actually process the replace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(app): use history.replaceState to strip ?open since Expo Router skips query-only changes

Expo Router's findDivergentState ignores search params, so
router.replace with the same pathname but without ?open is a no-op.
Use window.history.replaceState on web to directly strip the query
param, and track intent consumption in component state so
WorkspaceScreen renders once the tab is prepared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update stale codex model from gpt-5.1-codex-mini to gpt-5.4-mini

The Codex CLI no longer supports gpt-5.1-codex-mini. Update all
references to gpt-5.4-mini, which is available in the current CLI.
This fixes archive-tab Playwright tests that create codex agents
which were erroring due to the unsupported model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): pin codex CLI to 0.105.0 and improve turn_failed diagnostics

Playwright archive-tab tests fail because CI installs @openai/codex@latest
(0.120.0) which has breaking protocol changes vs the known-working 0.105.0.
Pin the version and add diagnostics for future debugging: elevate turn_failed
log from TRACE to WARN, and include error details in test assertions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): switch archive-tab tests from codex to opencode provider

Codex CLI requires OAuth auth (~/.codex/auth.json) that CI lacks —
OPENAI_API_KEY alone gets 401. These tests verify archive tab behavior,
not any specific provider. Switch to opencode/gpt-5-nano which
authenticates via standard OpenAI API that the CI key supports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retrigger CI for PR #236

* ci: trigger CI for opencode provider switch

Previous commits (3cbd7976, bdf768d6) were pushed with a token
that did not trigger GitHub Actions workflows. This empty commit
forces a fresh pull_request event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add workflow_dispatch trigger to CI workflow

Enable manual triggering of the CI workflow. Previous pushes to
ci/fix-tests-green failed to trigger pull_request events for the
CI workflow, so this allows manual dispatch as a fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): unblock remaining test lanes

* test(server): allow slower CI Claude model listing

* fix(cli): stabilize unsupervised restart regression

* test(ci): harden restart and archive e2e timing

* test(server): align workspace reconciliation expectation

* test(server): tolerate platform-specific workspace ids

* fix(app): gate host route logic on bootstrap readiness

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-12 09:56:05 +08:00
Mohamed Boudra
66bb8c6f04 Add daemon MCP injection toggle to settings 2026-04-11 16:39:13 +07:00
Mohamed Boudra
fa552b0faa Add mutable daemon config RPC support 2026-04-11 16:39:09 +07:00
Mohamed Boudra
51e96fa303 Notify caller when child agents finish 2026-04-11 16:39:05 +07:00
Mohamed Boudra
9fd31bfa5a Strip pairing URL from bootstrap log (re-apply after linter) 2026-04-11 16:34:01 +07:00
Mohamed Boudra
fdc8d3c8db Use sentence case for MCP tool titles 2026-04-11 16:09:39 +07:00
Mohamed Boudra
6897a400e5 Show Paseo logo and nice names for Paseo MCP tool calls
Detect Paseo MCP tool names across provider formats (Claude's
mcp__paseo__*, Codex's paseo.*) and display humanized leaf names
with the Paseo SVG logo instead of raw namespaced strings.
2026-04-11 13:02:22 +07:00
Mohamed Boudra
cff41d78ab Consolidate MCP server: remove voice bridge, add schedule/terminal/worktree tools
Merge agent-management and agent MCP into a unified server with shared
utilities. Remove the voice-mcp-bridge in favor of direct MCP tool
registration. Add schedule, terminal, and worktree management tools to
the MCP server for CLI parity. Include e2e parity tests.
2026-04-11 12:46:42 +07:00
Mohamed Boudra
4de2766114 Strip relay and pairing URLs from daemon logs
The pairing URL and relay WebSocket URLs contain secrets (serverId,
daemon public key, relay endpoint) that should never leak into log
files. Access to pairing info should only happen via explicit
`paseo daemon pair` commands.

Removed `url` from all relay-transport and bootstrap log entries
while keeping connectionId and log messages for debuggability.
2026-04-11 12:36:49 +07:00
Mohamed Boudra
30ffda6cae docs: add README badges 2026-04-10 22:49:25 +07:00
Mohamed Boudra
7a1e12f0c0 Replace git action disables with unavailability toasts 2026-04-10 20:57:51 +07:00
Mohamed Boudra
70985088f6 Make pull abort safely on conflicts 2026-04-10 20:29:26 +07:00
Mohamed Boudra
a525b0e22e Merge branch 'main' of github.com:getpaseo/paseo 2026-04-10 20:14:48 +07:00
Mohamed Boudra
497159ada8 Add pull action and restructure git actions policy 2026-04-10 20:13:59 +07:00
Mohamed Boudra
e22fb51975 Improve git action toast feedback 2026-04-10 19:53:45 +07:00
Mohamed Boudra
a315021c75 Fix desktop badge workspace parity 2026-04-10 19:14:31 +07:00
Mohamed Boudra
ba1ecedca3 feat: middle-click to close tabs on desktop (#232)
Add useMiddleClickClose hook that listens for auxclick (button === 1)
events on web, enabling middle-click-to-close behavior on tab chips.
2026-04-10 19:51:46 +08:00
Mohamed Boudra
01af138939 feat(app): add keyboard shortcut to cycle themes 2026-04-10 18:50:50 +07:00
Mohamed Boudra
faf85e038c Fix host switcher status syncing 2026-04-10 18:48:36 +07:00
github-actions[bot]
d3cd46a810 fix: update lockfile signatures and Nix hash 2026-04-10 10:34:42 +00:00
Mohamed Boudra
05734e8b1b chore(release): cut 0.1.52 2026-04-10 17:33:21 +07:00
Mohamed Boudra
c2f3bb73a6 docs(changelog): add 0.1.52 release notes 2026-04-10 17:32:32 +07:00
Mohamed Boudra
1ea5e5a769 fix: remove dead backpressure code left over from terminal stream fix 2026-04-10 17:19:42 +07:00
Mohamed Boudra
49e67363b2 feat(app): add themeable diff stat colors and soften tooltip border
Add diffAddition/diffDeletion semantic tokens with separate light
(muted green-700/red-700) and dark (bright green-400/red-500) sets,
spread into themes so individual variants can override. Update all
diff stat consumers to use the new tokens.

Soften tooltip border to match dropdown menu styling (thinner border,
accent color, medium shadow).
2026-04-10 17:06:41 +07:00
Mohamed Boudra
b9a8ba054d fix(app): keep diff rows aligned for blank lines 2026-04-10 16:55:04 +07:00
Mohamed Boudra
2f77674c55 feat: add theme selector with multiple dark themes
Add theme dropdown in settings with Light, Dark, System plus
Zinc, Midnight, Claude, and Ghostty dark variants. Each theme
has its own accent color and surface tints. Desaturate terminal
ANSI colors across all dark themes. Add surfaceSidebarHover
semantic token so hover states go the right direction in both
light and dark modes. Self-heal if a stored theme no longer exists.
2026-04-10 16:21:05 +07:00
Mohamed Boudra
120c1b46a4 fix: remove terminal stream backpressure that caused 14MB snapshot thrashing
The high water mark (256KB) was triggering snapshot mode during normal
output bursts, replacing incremental output with full terminal state
snapshots (~14MB). This made the terminal feel laggy even on localhost.

Snapshots still fire on initial attach and tab switch where they're
semantically needed.
2026-04-10 15:12:32 +07:00
Mohamed Boudra
033c4bcbf2 fix: let xterm.js handle keyboard input natively for scroll-on-input
Only intercept keys when mobile virtual modifier buttons are active.
Previously all special keys (Enter, Ctrl+C, arrows) were intercepted
and manually re-encoded, bypassing xterm's built-in scrollOnUserInput.
2026-04-10 15:11:14 +07:00
Mohamed Boudra
0f005172ce chore: use build:daemon in worktree setup 2026-04-10 14:42:39 +07:00
Mohamed Boudra
0e7dfcaf2a docs: clean up CONTRIBUTING.md wording 2026-04-10 13:59:54 +07:00
Mohamed Boudra
ebfad945e5 docs: add CONTRIBUTING.md with BDFL expectations and PR requirements 2026-04-10 13:47:36 +07:00
Mohamed Boudra
b5a0ee9954 feat: branch switching with stash-and-switch (#231)
* feat: add branch switching and stash management to workspace header

Adds the ability to switch git branches from the workspace header by
clicking the branch name. Includes full stash support: when switching
away from a dirty branch, users are prompted to stash changes; when
switching to a branch with a Paseo stash, they're prompted to restore.
An amber stash indicator in the header provides anytime restore/discard.

New WebSocket messages: checkout_switch_branch, stash_save, stash_pop,
stash_drop, stash_list. Stashes are tracked via git stash messages
with a "paseo-auto-stash:" prefix convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: await query invalidation after branch switch and stash

The branch switcher became non-clickable after switching because
checkout query invalidation was fire-and-forget. Now we await
invalidation so the UI has fresh checkout status (including the new
branch name) before rendering. Also invalidate after stash-save
so the checkout query sees clean state before the switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: emit workspace update immediately after branch switch

The sidebar and header were slow to reflect the new branch name because
they relied on the daemon's background git watcher to detect changes.
Now the switch handler calls emitWorkspaceUpdateForCwd immediately after
checkout, pushing the updated workspace descriptor to connected clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: stash detection and dirty-tree branch switch handling

- Fix stash list parser: git prepends "On <branch>: " to stash
  messages, so use indexOf instead of startsWith to find the
  paseo-auto-stash prefix.
- Remove switchBranchMutation in favor of inline async flow in
  handleBranchSelect. Now tries the switch first; if the server
  returns an uncommitted-changes error, offers the stash dialog
  automatically — no more red error toast on first attempt.
- Move post-switch stash restore prompt into handleBranchSelect
  so the full flow (switch → check stashes → prompt) is sequential.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: toast.success → toast.show and await stash invalidation

- Replace toast.success() with toast.show() — the ToastApi only
  exposes show/copied/error, not success.
- Await invalidateStashAndCheckout in stashPop and stashDrop mutation
  onSuccess handlers so the stash indicator disappears immediately
  after restore/discard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add stash diff preview and multi-stash management

- Add stash_show message pair: server runs `git stash show -p` and
  returns ParsedDiffFile[] through the existing diff parser.
- Stash indicator dropdown now lists ALL Paseo stashes (not just
  current branch) with per-stash Preview, Restore, and Discard actions.
- Preview opens a full diff modal showing file-by-file changes with
  syntax-colored add/remove lines, file stats, and Restore/Discard
  action buttons.
- Clean up: remove unused handleRestoreStash/handleDropStash callbacks,
  add Eye/Trash2 icons, import Modal/ScrollView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use Fonts.mono instead of theme.fontFamily.mono

The theme object doesn't have a fontFamily property. The codebase uses
the Fonts constant from @/constants/theme for font family values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: filter stashes to current branch and handle restore conflicts

- Stash indicator only shows when the current branch has stashes,
  not all branches. Switching branches hides/shows it correctly.
- When restoring a stash conflicts with uncommitted changes, offers
  to stash current changes first then restore the target stash
  (auto-adjusts stash index after the new save).
- Multiple stashes on the same branch show as "Stash 1", "Stash 2"
  instead of repeating the branch name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve branch switcher with recency sort, sticky search, and branch icons

- Sort branches by committer date (most recent first) instead of alphabetical
- Use full refname to fix normalization of branches with slashes
- Filter out bare "origin" symref leaking as a branch name
- Remove server-side search query; fetch all branches once and filter client-side
- Port combobox sticky search design from dev (borderless bar above scroll area)
- Add GitBranch icons to branch switcher items
- Prioritize prefix matches in combobox search results

* fix: move useIsCompactFormFactor hook above early return in ToastViewport

The hook was called after `if (!toast) return null`, violating React's
rules of hooks and causing "Rendered more hooks than during the previous
render" crash.

* fix: include untracked files in stash-and-switch flow

git stash push without --include-untracked left untracked files behind,
causing the clean-tree check to fail on the subsequent checkout.

* refactor: strip stash management UI, keep stash-and-switch flow

Remove the stash indicator icon, dropdown menu, preview modal,
and stash_drop/stash_show RPCs. Keep the auto-stash on branch switch
and the restore prompt on arrival — that's the useful surface.

* refactor: extract filterAndRankComboboxOptions with tests

* refactor: extract BranchSwitcher component and useBranchSwitcher hook

---------

Co-authored-by: heyimsteve <8645831+heyimsteve@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:26:04 +08:00
Mohamed Boudra
8a9d738438 Fix sync loader spinner showing for initializing (non-running) agents
Initializing agents were mapped to the "running" bucket in both server
and client, causing the SyncedLoader spinner to appear in tabs and
sidebar workspace rows when opening an agent for the first time.
2026-04-10 11:13:14 +07:00
Mohamed Boudra
1d41a50e1f Background revalidate provider models when model selector opens
On fresh startup the initial provider snapshot request can return
before providers finish loading, leaving the model picker empty.
The server broadcasts updates once providers are ready, but if the
user opens the picker in the interim they see "No models match".

Invalidate the React Query cache (background refetch, no loading
flash) every time the model selector popup opens so stale data is
silently refreshed.
2026-04-10 11:01:01 +07:00
Mohamed Boudra
32fe4b3beb Support wildcard CORS in WebSocket verifyClient and dev.sh 2026-04-10 10:45:36 +07:00
Mohamed Boudra
a692c616cb Fix error screen not scrollable on Electron 2026-04-10 10:45:10 +07:00
Mohamed Boudra
edd5503fe8 Support wildcard CORS origin via PASEO_CORS_ORIGINS=* 2026-04-10 10:35:48 +07:00
Mohamed Boudra
34bd8dfd1b Stable PASEO_HOME for worktrees and shared speech models in dev.sh
Worktrees get a persistent ~/.paseo-<name> home instead of a temp dir.
Speech models point at ~/.paseo/models/local-speech to avoid re-downloads.
2026-04-10 10:29:34 +07:00
Mohamed Boudra
201eb6a671 Self-contained desktop dev script with worktree isolation
dev:desktop now launches its own Metro on a random port + Electron,
no dependency on a shared :8081. In worktrees, Electron gets a unique
userData path and dock badge so multiple instances run side-by-side.
2026-04-10 10:11:55 +07:00
Mohamed Boudra
9f09a19a09 Gate Claude SDK stream trace logs behind PASEO_CLAUDE_DEBUG
Per-token trace logs from the query pump fire twice per streaming event
and contribute to memory growth under heavy use. Gate them behind an
opt-in env var so they're available for debugging but silent by default.
2026-04-10 10:05:13 +07:00
Mohamed Boudra
edfd2564a3 Change default file log level from trace to debug
Trace-level logging emits two entries per streaming token per session,
causing significant memory growth and GC pressure under multi-workspace
use. Fixes #227.
2026-04-10 10:01:39 +07:00
Mohamed Boudra
c1ebb8c915 Refactor form factor check to reactive useIsCompactFormFactor 2026-04-10 09:53:38 +07:00
Mohamed Boudra
29af07e383 Fix live agent refresh without persistence 2026-04-10 09:53:38 +07:00
Mohamed Boudra
17bd036359 Persist draft agent feature preferences 2026-04-10 09:53:38 +07:00
Mohamed Boudra
2683dae5f9 Auto-download desktop updates before prompting 2026-04-10 09:53:38 +07:00
Mohamed Boudra
914a5dc6ae ci: add comprehensive CI workflow 2026-04-10 00:20:42 +07:00
github-actions[bot]
30842398e7 fix: update lockfile signatures and Nix hash 2026-04-09 07:00:28 +00:00
Mohamed Boudra
b27c1b0729 chore(release): cut 0.1.51 2026-04-09 13:58:59 +07:00
Mohamed Boudra
fcf2c14485 docs(changelog): add 0.1.51 release notes 2026-04-09 13:57:44 +07:00
Mohamed Boudra
57eafda6ef fix(app): fix BottomSheetTextInput crash on iPad model selector
On iPad, Combobox uses the desktop Modal path (no BottomSheet context)
since the breakpoint is "md"+, but ProviderSearchInput was checking
Platform.OS === "web" to choose the input component. This caused
BottomSheetTextInput to render outside a BottomSheet on iPad.

Align the check with Combobox's own isMobile breakpoint logic.
2026-04-09 11:18:49 +07:00
Mohamed Boudra
4925b94743 Use advertised hostnames during pairing 2026-04-09 10:32:01 +07:00
Mohamed Boudra
f0b09d90fd fix(app): clean up QR scan screen — remove instruction text, use accent color for crosshairs 2026-04-09 10:31:33 +07:00
Mohamed Boudra
bf91ea2cc9 feat(server): support image attachments in OpenCode agent prompts
Adds logic to process file parts and convert image attachments into data URLs for the OpenCode provider, replacing the previous behavior that stripped non-text parts.
2026-04-09 10:23:24 +07:00
Mohamed Boudra
f83a4d08da Add configurable send behavior 2026-04-09 09:56:04 +07:00
Mohamed Boudra
83f540f10a fix(app): wrap daemon settings section in React Query with loading state
Prevents the daemon section from flashing "not running" / "PID —" while
IPC data loads. The whole card now shows a spinner until the first fetch
completes, and mutations (restart/stop/start) optimistically update the
query cache with the returned status.
2026-04-09 09:45:51 +07:00
Illia Panasenko
602b548745 feat(app): Add WebStorm editor target (#220)
* Add WebStorm editor target

* Fix rebase type errors and add TODO date tag

- Await listAvailableEditorTargets() which became async on main
- Wrap sendAgentMessage callback to match Promise<void> return type
- Add TODO(2026-07) date to legacy editor target ids comment

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-09 10:26:53 +08:00
Benjamin Kitt
d98d5457ed Fix commands not loading for Pi agent (#225) 2026-04-09 10:10:11 +08:00
Mohamed Boudra
6bf8da8087 Commit current desktop and CLI diff 2026-04-09 00:27:20 +07:00
Mohamed Boudra
d39414064e Fix opencode terminal states and model refresh 2026-04-09 00:25:30 +07:00
Mohamed Boudra
ffcc35485d Fix agent follow-up turns and wait lifecycle 2026-04-09 00:24:58 +07:00
Mohamed Boudra
8ff94dc03e fix(app): improve pair device screen with read-only input for link
Replace truncating text with a read-only TextInput + copy button pattern
so the pairing URL doesn't get cut off. Increase QR code size from
200x200 to 320x320.
2026-04-08 23:36:07 +07:00
Mohamed Boudra
dace4f862f refactor: make executable resolution async and centralize spawn
- Rename findExecutable → findExecutableSync, add async findExecutable
  that uses promisified execFile instead of execFileSync
- Simplify Windows PATH resolution: rely on inherited env from Explorer
  instead of shelling out to PowerShell for registry PATH entries
- Add spawnProcess() helper that centralizes Windows shell/quoting concerns
- Update all agent providers and consumers to use async executable resolution
- Add windowsHide: true across all Windows child process calls
2026-04-08 23:24:43 +07:00
Mohamed Boudra
fc667d6312 fix(claude): filter <local-command-stdout> messages from timeline
Claude Code writes local CLI command outputs (model switches, /context,
/compact, goodbye messages, etc.) as user entries in the JSONL history.
These were leaking through as user_message timeline items in Paseo.
2026-04-08 22:03:30 +07:00
Mohamed Boudra
aae4d9f8dd docs(website): update mobile app availability status
Apps are now available on App Store and Play Store.
2026-04-08 21:34:37 +07:00
Mohamed Boudra
5eb8b300a3 fix(ci): prevent duplicate GitHub releases in desktop workflow
Add a create-release job that runs first and creates the GitHub release
before any platform build jobs start. This prevents the race condition
where parallel electron-builder jobs each create their own release.

Platform jobs now depend on create-release and just upload assets to
the existing release. Single-platform retry tags (desktop-macos-v*,
desktop-linux-v*, desktop-windows-v*) skip the create-release job
since the release already exists from the original build.
2026-04-08 19:37:30 +07:00
github-actions[bot]
6c9a832906 fix: update lockfile signatures and Nix hash 2026-04-08 12:20:15 +00:00
Mohamed Boudra
35430dab52 chore(release): cut 0.1.51-rc.1 2026-04-08 19:19:07 +07:00
Mohamed Boudra
0eac4bc4b3 fix(desktop): enable electron-log console transport for stdout output
Ensures renderer console.log output (including layout-debug) is
printed to stdout in packaged builds, not just the log file.
Also removes unnecessary webContents event listeners.
2026-04-08 19:11:06 +07:00
Mohamed Boudra
635de3d76a debug(desktop): add renderer console-message and lifecycle event logging
Pipes all renderer console.log output to the main process stdout via
electron-log, and logs did-finish-load/did-fail-load/render-process-gone
events to help diagnose layout and loading issues on Linux.
2026-04-08 19:03:45 +07:00
Mohamed Boudra
931d3ba81f fix: log bootstrap errors to daemon.log and add layout debug logging
- Log fatal errors during daemon bootstrap and startup to pino (daemon.log)
  instead of only writing to stderr, which is invisible on Windows desktop
- Delay process.exit to let pino async streams flush
- Make voice MCP bridge script resolution non-fatal (warn + disable instead
  of crashing the daemon)
- Add PASEO_ELECTRON_FLAGS env var for passing Chromium flags on Linux
- Add layout debug logging to AppContainer for diagnosing sidebar issues
2026-04-08 18:55:56 +07:00
Illia Panasenko
6a4f439541 refactor(app): improve route types, reduce amount of as any (#217) 2026-04-08 12:52:37 +08:00
Mohamed Boudra
ac5e6df6c9 feat(website): add Plausible analytics to all pages 2026-04-08 11:18:03 +07:00
Mohamed Boudra
bf7d3c2775 copy: update hero headline to focus on orchestration value prop 2026-04-08 11:05:42 +07:00
Mohamed Boudra
5c93fbc955 feat: emit usage_updated events for real-time token usage updates 2026-04-08 08:59:02 +07:00
Mohamed Boudra
5ac7b3f7c7 docs(release): add changelog voice guidelines 2026-04-07 20:26:01 +07:00
Mohamed Boudra
c742f17080 docs(changelog): remove internal-only fixes from 0.1.50 notes 2026-04-07 20:24:02 +07:00
Mohamed Boudra
b4d6a5d6b8 docs(changelog): rewrite release notes for end users 2026-04-07 20:22:04 +07:00
github-actions[bot]
43f01600ce fix: update lockfile signatures and Nix hash 2026-04-07 13:05:48 +00:00
Mohamed Boudra
e7cf9ee69d chore(release): cut 0.1.50 2026-04-07 20:04:13 +07:00
Mohamed Boudra
9c8b0c3aca docs(changelog): add 0.1.50 release notes 2026-04-07 20:03:22 +07:00
Mohamed Boudra
8088c39fd6 Fix OpenCode usage regression and update workspace tests
Re-add usage stamping on turn_completed events for OpenCode agents —
the extractAndResetUsage callsite was removed during context window
meter work, causing token counts and cost to be lost after each turn.

Update workspace tests to account for the new async reconciliation
that fires after workspace update fanout.
2026-04-07 20:02:48 +07:00
Mohamed Boudra
1279f1d556 Fix split diff horizontal scroll and pin line number gutters
Split diff now renders two independent scrollable columns (left/right)
instead of one scroll wrapping both sides. Line number gutters are
pinned outside the scroll area in both unified and split views.
2026-04-07 20:00:46 +07:00
Mohamed Boudra
0defbc1dc3 Normalize Codex paseo_voice.speak MCP calls and wrap spoken input
Broaden the Codex speak tool name matcher to recognize paseo_voice.speak
MCP calls alongside the existing paseo.speak convention. Wrap voice
transcription input in <spoken-input> tags so agents can distinguish
spoken from typed messages.
2026-04-07 18:57:03 +07:00
Mohamed Boudra
76c6253ae0 Persist context window usage across turns from last task_progress
Stop resetting lastContextWindowUsedTokens between turns. The
result.usage fallback contains accumulated session totals which grow
incorrectly across turns. Once a task_progress arrives, its value is
the accurate context fill level and should be retained until the next
task_progress supersedes it.
2026-04-07 18:56:58 +07:00
Mohamed Boudra
5ec25687cd Skip provider refresh when one is already in-flight 2026-04-07 18:56:53 +07:00
Mohamed Boudra
33a1557aed Show provider error details in settings screen 2026-04-07 18:56:51 +07:00
Mohamed Boudra
cbc2ce06e9 Fix WorkingIndicator remounting on every stream update on native
Passing an arrow function to FlatList's ListHeaderComponent caused React
to treat each re-render as a new component type, unmounting and
remounting the entire header (including the bouncing dots animation).
Pass the element directly instead so React can reconcile properly.
2026-04-07 18:56:47 +07:00
Mohamed Boudra
82466aaa9f Reset Silero VAD between voice turns to prevent LSTM drift
The Silero VAD session ran for the entire voice mode lifetime without
resetting its LSTM hidden states. Over time the internal state drifted,
causing phantom speech detection on silence and getting permanently
stuck in the "speaking" phase.

Add reset() to TurnDetectionSession and call it after each completed
utterance to clear the LSTM hidden states between turns.
2026-04-07 18:41:46 +07:00
Mohamed Boudra
d3e3a83a0d Render speak tool calls inline as spoken messages
Instead of showing speak tool calls as expandable tool call badges,
render them inline with a mic icon header matching assistant message
styling.
2026-04-07 17:15:18 +07:00
Mohamed Boudra
ee611d65b6 Surface wait_for_finish agent errors 2026-04-07 16:50:17 +07:00
Mohamed Boudra
5ce4562eed Preserve workspace diff stats across rehydration 2026-04-07 16:48:51 +07:00
Mohamed Boudra
21c7761403 Defer workspace diff stats until post-bootstrap updates 2026-04-07 16:09:47 +07:00
Mohamed Boudra
682fc54778 feat: normalize plan approval across providers 2026-04-07 15:43:14 +07:00
Mohamed Boudra
e06b691d5d feat: show remaining context window for Claude Code, Codex, and OpenCode
Track and display context window usage across all three agent providers,
letting users see how much context remains before hitting limits.
2026-04-07 15:03:29 +07:00
Mohamed Boudra
022eb33234 Fix OpenCode context window meter after first turn 2026-04-07 14:53:00 +07:00
Mohamed Boudra
161b2c2378 fix(app): port optimistic workspace tab closing 2026-04-07 14:30:32 +07:00
Mohamed Boudra
52dfdb1913 fix(app): use shared provider snapshot hook in settings and add refresh button
Settings providers section now uses useProvidersSnapshot instead of a
one-shot fetch, so it shares the same real-time data source as the
status bar and draft form. Adds a Refresh button to re-detect installed
provider CLIs without restarting the daemon.
2026-04-07 14:17:52 +07:00
Mohamed Boudra
bdaa6b65aa fix(app): fix garbled overlapping text in plan card markdown
PlanCard list_item rule wrapped children in <Text> instead of <View>,
causing layout corruption when children contained <View> elements
(nested lists, paragraphs). Also added missing paragraph rule to match
AssistantMessage renderer.
2026-04-07 13:22:58 +07:00
Mohamed Boudra
1900f43049 fix(app): move reload agent away from close action 2026-04-07 12:20:15 +07:00
Mohamed Boudra
29b6f2a86f fix(server): prefer origin/{branch} over local branch for worktrees
When creating a worktree, resolve the base branch by checking
origin/{branch} first, falling back to the local branch only when
the remote ref doesn't exist. This ensures worktrees start from the
latest upstream state rather than a potentially stale local branch.
2026-04-07 11:40:59 +07:00
Mohamed Boudra
638c208609 feat(server): add background git fetch manager
Periodically fetch from remotes in the background so workspace git
watch targets pick up upstream changes without user intervention.
The manager deduplicates subscriptions per repo and triggers a
workspace refresh callback after each successful fetch.
2026-04-07 11:40:43 +07:00
Mohamed Boudra
7b1144dafe feat(app): persist expanded state in file explorer and diff panes
Remember which directories and diff entries are expanded across sidebar
close/reopen cycles. State is stored per-workspace in the panel store
(zustand + AsyncStorage) with no local React state copy, following the
proven explorerTabByCheckout pattern. On desktop remount, expanded
subdirectory listings are re-fetched so the tree renders fully.
2026-04-07 11:40:37 +07:00
Mohamed Boudra
940bc6243b fix(app): polish diff toolbar toggle buttons
- Lower active button surface from surface3 to surface2 for dark mode
- Add gap between toolbar button groups
- Remove outer border from unified/split group, use per-button radius
2026-04-07 09:50:52 +07:00
Mohamed Boudra
51b83768c7 fix(server): push workspace updates instantly, reconcile in background
emitWorkspaceUpdateForCwd and emitWorkspaceUpdatesForCwds blocked on
reconcileActiveWorkspaceRecords() (stat + reconcile on every active
workspace) before pushing the target workspace update to clients. This
caused new worktrees to appear in the sidebar with a noticeable delay
after the agent was already visible.

Emit the target workspace immediately from the registry snapshot, then
run reconciliation in the background via reconcileAndEmitWorkspaceUpdates.
Ensure the dedupeGitState early-return still triggers background
reconciliation so metadata changes (branch renames, display names)
are not silently dropped.
2026-04-07 09:50:39 +07:00
Samuel K
cdbaa8d29c fix(server): serve workspace list instantly on fetch, reconcile in background (#204)
* fix(server): serve workspace list instantly on fetch, reconcile in background

Previously fetch_workspaces_request awaited reconcileActiveWorkspaceRecords()
before responding — this ran git operations (getCheckoutStatusLite) on every
active workspace, causing projects to appear empty for several seconds after
daemon restart or reconnect.

Now the registry snapshot is returned immediately and reconciliation runs in
the background. Any changed workspaces (e.g. stale worktrees being archived)
are pushed as workspace_update events through the existing subscription.

* fix(server): make workspace snapshot instant by removing per-workspace git calls

describeWorkspaceRecord was running two git operations per workspace:
  - buildProjectPlacement (to refresh display name)
  - getCheckoutShortstat (for diff indicator)

With 13 workspaces this caused fetch_workspaces_request to take 7-15s
(observed as ws_slow_request in logs).

Split into two methods:
  - describeWorkspaceRecord: uses only persisted data, returns instantly
  - describeWorkspaceRecordWithGitData: runs git ops, used for open_project
    and create_worktree responses where fresh data is needed immediately

The snapshot path (initial load + background reconciliation) now uses the
fast variant. diffStat is null on initial load; it will be pushed later via
workspace_update once a git-aware path triggers.
2026-04-07 09:35:07 +07:00
Samuel K
b2229a28b9 fix(server): reset session ID on query restart to prevent overwrite crash (#201)
* fix(server): reset session ID on query restart to prevent overwrite crash

When ensureQuery() restarts a query, the old claudeSessionId was retained.
If the new query landed on a different Claude session (e.g. after a hook or
reconnect), captureSessionIdFromMessage() would detect the mismatch and
throw a fatal "session ID overwrite" error, killing the agent mid-turn.

Reset claudeSessionId and persistence during query restart so the new
session can establish its own identity cleanly.

Closes #200

* fix(server): recover from session ID overwrite and load projects instantly

- Reset claudeSessionId unconditionally before every new query creation,
  not just on explicit restarts. Fixes unrecoverable crash loop where a
  pump failure left stale session ID, causing every subsequent query to
  immediately throw the overwrite error again.

- Decouple fetch_workspaces_request from reconciliation: serve registry
  snapshot immediately instead of awaiting git operations on all
  workspaces. Background reconciliation runs after response is sent and
  pushes workspace_update events for any changed workspaces (e.g. stale
  worktrees getting archived).

* fix(server): auto-resume Claude session after overwrite error to preserve history

When the query pump dies (e.g. after a session ID overwrite error), preserve
claudeSessionId so the next query automatically passes resume: sessionId to
the Claude SDK. Claude responds with the same session ID, the overwrite check
passes, and the conversation history is intact.

Previously claudeSessionId was reset unconditionally before every new query,
causing the next query to start a fresh session and lose the conversation.
Now it is only reset for explicit restarts (queryRestartNeeded), where a
fresh session is the intended behavior.

* fix(server): reset session ID before throwing overwrite error to break retry loop

When the overwrite error fired, claudeSessionId was left set to the old value.
Our auto-resume fix then passed resume: oldSessionId on every retry, Claude
returned yet another new session ID each time, and the overwrite error looped
indefinitely (same "Existing" UUID across all failures in the log).

Fix: reset claudeSessionId = null in both captureSessionIdFromMessage and
handleSystemMessage before throwing, so the next query starts a fresh session
instead of looping on a dead resume target.

Auto-resume still works for legitimate pump failures (network drops etc.)
since those exit without throwing, leaving claudeSessionId intact.

* fix(server): accept session ID change mid-stream instead of failing the turn

Hooks can cause Claude to restart with a new session ID mid-turn. Previously
both captureSessionIdFromMessage and handleSystemMessage threw a CRITICAL error
when the session ID changed, failing the turn and leaving the user with an
error message on what should have been a transparent hook execution.

Now both paths log a warning and accept the new session ID, allowing the turn
to continue uninterrupted through hook-triggered subprocess restarts.
2026-04-07 10:28:46 +08:00
thatdaveguy1
d888c8f126 fix(server): bypass Copilot ACP prompts in autopilot (#206)
* fix(server): bypass Copilot ACP prompts in autopilot

* Delete lessons.md

---------

Co-authored-by: Paseo Bot <paseo-bot@users.noreply.github.com>
2026-04-07 10:28:12 +08:00
Huss Martinez
102ef06c30 fix: show direct connection and pairing modal content on tablets (#211)
On tablet-sized layouts the welcome-screen modals for direct connection and pair link could collapse so only the header row remained visible.

Adjust the desktop modal card and scroll container flex behavior so the modal body can shrink within the available height and remain visible instead of being clipped out.
2026-04-07 10:26:53 +08:00
Illia Panasenko
5cb424b2e6 feat(app): add open in editor toolbar action (#209)
* feat(app): add open in editor toolbar action

* fix(app): normalize editor icon asset permissions

* chore(website): drop unrelated generated route tree change
2026-04-07 10:25:25 +08:00
Illia Panasenko
fd9dfb0cc8 feat: add side-by-side diff layout and whitespace toggle (#208) 2026-04-07 10:22:20 +08:00
Mohamed Boudra
03380cfad0 docs(changelog): make 0.1.49 release notes user-friendly 2026-04-07 00:23:42 +07:00
Mohamed Boudra
6ce0e1e91f docs: clarify stable release and compatibility guidance 2026-04-07 00:19:13 +07:00
github-actions[bot]
64c2515b94 fix: update lockfile signatures and Nix hash 2026-04-06 17:17:36 +00:00
Mohamed Boudra
e90241c445 chore(release): cut 0.1.49 2026-04-07 00:16:12 +07:00
Mohamed Boudra
06fbeb413b docs(changelog): prepare 0.1.49 release notes 2026-04-07 00:15:33 +07:00
Mohamed Boudra
390a3402ab Fix provider snapshot session hydration and agent scoping 2026-04-07 00:11:43 +07:00
Mohamed Boudra
27ddc95862 Remove agent status bar provider model fallback 2026-04-06 23:44:53 +07:00
Mohamed Boudra
c63240b18c feat(app): unified provider snapshot, resilient model selector, and UX polish
- Model selector is no longer disabled while providers load; opens
  immediately and streams available providers as they arrive
- Selecting a non-default provider on mobile now works correctly
- Provider icon added to model trigger in mobile preferences sheet
- Thinking icon (brain) added to thinking trigger in mobile preferences
- Model descriptions for OpenCode/Pi shown inline next to model name
  instead of in a tooltip
- Running agents and draft screen share a single provider/model cache,
  eliminating duplicate network requests
- Provider data is prefetched when entering a workspace so the model
  selector is instant on first open
2026-04-06 23:37:22 +07:00
Mohamed Boudra
a5aca2312b feat(website): add Copilot and Pi icons to hero supports section 2026-04-06 23:05:21 +07:00
Mohamed Boudra
e01a0abdf2 fix(app): wire atomic provider+model selection on mobile draft screen
The mobile preferences modal split CombinedModelSelector's onSelect into
separate onSelectProvider and onSelectModel calls, but onSelectProvider
was never passed from DraftAgentStatusBar — so selecting a non-cloud
provider was silently ignored while only the model ID updated.

Add onSelectProviderAndModel prop to ControlledStatusBar and use it on
the mobile path, matching the web path's atomic behavior.
2026-04-06 22:50:58 +07:00
github-actions[bot]
3397e6c589 fix: update lockfile signatures and Nix hash 2026-04-05 08:42:35 +00:00
Mohamed Boudra
c4cccf5bd2 chore(release): cut 0.1.48 2026-04-05 15:40:54 +07:00
Mohamed Boudra
0130a637c8 docs: add 0.1.48 changelog 2026-04-05 15:39:55 +07:00
Mohamed Boudra
8c2ea33da8 fix(app): restore input focus for running agents and align mobile model selector
- Add onDropdownClose callback to AgentStatusBar and wire through ControlledStatusBar
  to CombinedModelSelector so running agents focus input after any dropdown closes
- Fix mobile misalignment in model selector by adding marginHorizontal: spacing[1]
  to sectionHeading, drillDownRow, backButton, and providerSearchContainer to match
  ComboboxItem's mobile margins
2026-04-05 15:38:22 +07:00
Mohamed Boudra
0d88ce2270 feat(app): provider system overhaul with model selector, diagnostics, and UX polish
- Add CombinedModelSelector with two-level drill-down (providers → models),
  favorites section, search, and single-provider auto-drill mode
- Add provider snapshot system (ProviderSnapshotManager) for cached provider/model state
- Add provider diagnostics with resolved binary paths and versions across all providers
- Add StatusBadge shared component for consistent status visual language
- Add ProviderDiagnosticSheet for detailed provider health inspection
- Add desktop auto-focus on input after any status bar interaction (model, mode,
  thinking, features) using focusWithRetries
- Fix Combobox flicker on height change by switching to bottom-based CSS positioning
  once initial floating-ui position resolves
- Add dynamic dropdown height: Level 1 fits content, Level 2 calculates from model count
- Remove "Other providers" section from model selector (only show available providers)
2026-04-05 15:32:13 +07:00
Mohamed Boudra
6907f6e71d fix(desktop): resolve login shell environment at Electron startup
On macOS, apps launched from Finder/Dock inherit a minimal environment
(PATH is just /usr/bin:/bin:/usr/sbin:/sbin). This caused two problems:
1. Agent binaries like codex were not detected (findExecutable failed)
2. Terminals spawned by Paseo had no access to user-installed tools
   (node, bun, direnv — all "command not found")

Fix: at Electron startup, spawn the user's login shell and capture its
full environment via JSON.stringify(process.env) using UUID markers.
This is the same battle-tested approach VS Code uses. The resolved
environment is merged into process.env before the daemon starts, so
all child processes — agents, terminals, git operations — inherit the
correct environment automatically.

This replaces the previous approach of invoking resolveShellEnv() (via
the shell-env npm package) at multiple scattered call sites. Now there
is a single source of truth: process.env is enriched once at startup.

Changes:
- New: packages/desktop/src/login-shell-env.ts (VS Code approach)
- Removed: resolveShellEnv(), shell-env dependency, $SHELL -lic probes
- Simplified: applyProviderEnv() and findExecutable() now trust process.env
2026-04-05 15:11:18 +07:00
Mohamed Boudra
84638ecd9c feat(app): submit question card answer on Enter key press 2026-04-05 14:54:03 +07:00
Mohamed Boudra
809e5fbbdc feat(app): Enter key confirms and sends dictation
When dictating and focused on an agent, pressing Enter now confirms
the dictation and sends the message, matching the behavior of clicking
the submit button.
2026-04-05 14:53:10 +07:00
Mohamed Boudra
a264058a30 Remove noisy agent toasts and debug logging
Remove the "Refreshing" and "Failed to refresh agent" toasts from the
agent panel — they fire frequently but are meaningless since sync
recovers transparently. The "Reconnecting..." toast for host disconnect
is preserved.

Strip debug console.log calls across the app: render tracking in
message/stream views, dependency change tracking in workspace screen,
terminal tab slot logging, and verbose audio engine/voice runtime
bridge stats logging. Legitimate console.error/warn in catch blocks
are kept.
2026-04-05 11:10:49 +07:00
Mohamed Boudra
702e2e7db9 Handle Codex request_user_input app-server questions
Fixes #195
2026-04-05 09:21:45 +07:00
Mohamed Boudra
c32bc847bf feat(app): restore reload action for agent tabs 2026-04-05 09:21:45 +07:00
github-actions[bot]
7a2d976081 fix: update lockfile signatures and Nix hash 2026-04-04 17:59:55 +00:00
Mohamed Boudra
2d9ca6983a chore(release): cut 0.1.47 2026-04-05 00:58:22 +07:00
Mohamed Boudra
e44e495481 Update CHANGELOG.md for 0.1.47 stable release 2026-04-05 00:57:03 +07:00
Mohamed Boudra
bedf792616 fix(voice): harden Electron speak TTS path 2026-04-05 00:53:50 +07:00
Mohamed Boudra
5db9942125 fix(desktop): restore daemon QR pairing output 2026-04-05 00:53:46 +07:00
Mohamed Boudra
a889dbcc28 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-05 00:14:41 +07:00
Mohamed Boudra
5c1e869bc5 fix: copy sherpa TTS samples to avoid external buffer crash
Same fix as the Silero VAD — sherpa-onnx returns Float32Arrays backed
by native memory which Node.js rejects as "External buffers are not
allowed". Copy into JS-managed memory before passing to audio pipeline.
2026-04-04 23:45:28 +07:00
Mohamed Boudra
a693ff560c fix: remove per-host "Add connection" button that blocked multi-host setups
Users connecting multiple daemons (e.g. local + remote via Tailscale) hit
a "belongs to X, not Y" error because the edit-host modal's Add Connection
button scoped new connections to the current host's server ID. Remove that
button and its supporting state so all connections go through the top-level
flow which has no server ID constraint.
2026-04-04 23:45:03 +07:00
github-actions[bot]
89baf7ff38 fix: update lockfile signatures and Nix hash 2026-04-04 16:00:01 +00:00
Mohamed Boudra
c65a851205 chore(release): cut 0.1.46 2026-04-04 22:57:53 +07:00
Mohamed Boudra
85acdbb05e Update CHANGELOG.md for 0.1.46 stable release 2026-04-04 22:57:45 +07:00
Mohamed Boudra
4e32f9b7bd fix: copy Silero VAD model out of asar so native code can read it
The bundled silero_vad.onnx lives inside Electron's app.asar which
native C++ (sherpa-onnx) cannot open via OS file I/O. On first voice
activation, copy the model to ~/.paseo/models/local-speech/silero-vad/
using Node.js fs (asar-aware) and point the native code there.
2026-04-04 22:11:54 +07:00
Mohamed Boudra
744ca7a2bc fix: send appVersion in probe client hello and update it on session resume
buildClientConfig in test-daemon-connection.ts never set appVersion, so
probe clients (which become the live client) sent hello without it. The
daemon's version gate then hid Pi/Copilot from all these sessions.

Also update appVersion on the Session when a client reconnects with a
newer version, so stale sessions don't stay gated forever.
2026-04-04 21:12:34 +07:00
Mohamed Boudra
3110bae209 fix(website): stack header vertically on mobile to prevent overflow 2026-04-04 21:06:35 +07:00
Mohamed Boudra
16efdb2c95 feat(website): add Copilot and Pi to homepage provider grid 2026-04-04 20:29:44 +07:00
Mohamed Boudra
1e9b7f1157 fix: make worktreeRoot backward-compatible for old clients/daemons
worktreeRoot was added as a required field in 9154f8fc, which breaks
old clients parsing new daemon responses and new clients parsing old
daemon responses. Made it .optional() with .transform() fallbacks.

Also added a critical rule to CLAUDE.md: schema changes must always
be backward-compatible in both directions.
2026-04-04 20:29:44 +07:00
Mohamed Boudra
018ebd5f29 Fix punycode deprecation warning in CLI and daemon entrypoints 2026-04-04 20:29:44 +07:00
github-actions[bot]
4706d9e3bd fix: update lockfile signatures and Nix hash 2026-04-04 12:22:20 +00:00
Mohamed Boudra
ba0c4a3fff chore(release): cut 0.1.45 2026-04-04 19:21:12 +07:00
Mohamed Boudra
2a8da5d4c1 Update CHANGELOG.md for 0.1.45 stable release 2026-04-04 19:21:12 +07:00
Mohamed Boudra
43542ac858 Fix CLI routing: single classifier, open-project for cold & hot start
- Add classify.ts as the single source of truth for CLI invocation
  routing (discriminated union, derives known commands from Commander)
- Remove all hardcoded command lists and duplicate path detection logic
- Simplify shell wrappers to dumb pipes (zero classification)
- Fix hot-start: use `open -n -g -a` (VS Code pattern) so second-instance
  event fires when app is already running
- Fix cold-start race: pull-based IPC (getPendingOpenProject) so renderer
  fetches the pending path after React mounts, instead of push event that
  arrived before the listener existed
- Fix asar read corruption: unpack node-entrypoint-runner.js from asar
  (Node.js v24 in Electron 41 can't parse package.json inside asar)
- Strip ELECTRON_RUN_AS_NODE from env when spawning desktop app from CLI
2026-04-04 19:21:12 +07:00
Mohamed Boudra
fefe260f0a Fix Silero VAD crash: disable external buffers in CircularBuffer.get()
Newer sherpa-onnx-node rejects external buffers by default, causing
"External buffers are not allowed" on every voice audio chunk.
2026-04-04 19:21:12 +07:00
Mohamed Boudra
a17f7d2d20 Fix forward-compatible provider handling for old app clients
AgentProviderSchema was z.enum() which caused old clients to reject
session messages containing unknown providers (pi, copilot), breaking
the entire session. Changed to z.string() for future clients.

For currently deployed clients (<0.1.45), the daemon now filters out
unknown providers based on the appVersion sent in the WebSocket hello
message. Clients that don't send appVersion only see claude/codex/opencode.
2026-04-04 19:21:12 +07:00
Mohamed Boudra
c5a69a1ad9 Update skill CLI examples to use --provider provider/model format
Always specify the model in paseo run examples (e.g. --provider codex/gpt-5.4
instead of --provider codex) to prevent agents from launching with wrong defaults.
2026-04-04 19:21:12 +07:00
github-actions[bot]
155c88254b fix: update lockfile signatures and Nix hash 2026-04-04 09:37:40 +00:00
Mohamed Boudra
22c83118d0 chore(release): cut 0.1.45-rc.4 2026-04-04 16:36:26 +07:00
Mohamed Boudra
7e99529bde Add Codex plan mode draft features 2026-04-04 16:35:31 +07:00
Mohamed Boudra
a6f6c169c8 Fix Pi thinking mode state mapping 2026-04-04 16:35:31 +07:00
Mohamed Boudra
d69fcb861d Fix CLI arg routing and desktop install links 2026-04-04 16:35:31 +07:00
github-actions[bot]
c3313ff82a fix: update lockfile signatures and Nix hash 2026-04-04 07:57:09 +00:00
Mohamed Boudra
1182092b94 chore(release): cut 0.1.45-rc.3 2026-04-04 14:55:59 +07:00
Mohamed Boudra
8d1fb45f73 fix(server): correct isCommandAvailable import path in pi-acp-agent 2026-04-04 14:50:40 +07:00
Mohamed Boudra
8f7de021f4 fix(app): move OpenProjectListener inside ToastProvider
OpenProjectListener uses useOpenProject which calls useToast, but it was
rendered in ProvidersWrapper above ToastProvider in the component tree.
2026-04-04 14:48:47 +07:00
Mohamed Boudra
65573499af feat(server): add Pi agent provider and re-enable Copilot (#191)
* fix(app): reorder settings sections for better grouping

* feat(app): add setup hint and paseo.sh link on mobile welcome screen

New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.

* docs(release): add pre-release sanity check and clarify changelog scope

Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.

* feat(server): add Pi agent provider and re-enable Copilot

Add Pi (pi.dev) as a new ACP-based agent provider with bundled pi-acp
adapter. Pi-acp reports thinking levels as ACP modes and bash tool calls
with kind "other", so the Pi provider uses generic ACP extension hooks
to remap these to the correct Paseo concepts:

- sessionResponseTransformer: remaps ACP modes → configOptions[thought_level]
- thinkingOptionWriter: writes thinking via setSessionMode instead of
  set_config_option (which pi-acp doesn't support)
- toolSnapshotTransformer: remaps bash tool kind from "other" to "execute"
- modelTransformer: cleans slash-prefixed model labels

Also re-enables the Copilot provider (disabled since 44da0c67) and
removes the claude-acp provider.

* fix: update lockfile signatures and Nix hash

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-04 15:42:24 +08:00
Mohamed Boudra
9154f8fc4d fix(server): deduplicate workspaces by git worktree root (#190)
* fix(server): deduplicate workspaces by resolving to git worktree root

Agents running in subdirectories of the same git repo were creating
separate workspace entries in the sidebar. Now workspace IDs resolve
to the git worktree root (via the already-computed `worktreeRoot` from
`inspectCheckoutContext`), so all agents in the same checkout share a
single workspace. Stale subdirectory workspace records are archived
during reconciliation.

* fix(server): fix 3 broken test files in server unit suite

- relay-reconnect: move speech mock to correct constructor position and
  rename getSpeechReadiness→getReadiness after SpeechService refactor
- commands-poc: add credential/CLI availability guards matching
  claude-agent.integration.test.ts pattern
- claude-sdk-behavior: pass pathToClaudeCodeExecutable via findExecutable
  and add credential guards matching real provider configuration

* fix(server): fix race condition in loop-service test mock

Replace setTimeout(..., 0) with queueMicrotask() in ScriptedAgentSession
so turn_completed events fire before the verify check runs. Fixes flaky
CI failure where the file write hadn't completed before verification.

* fix(server): use /bin/sh for loop verify checks instead of /bin/zsh

More portable across CI environments. Also use queueMicrotask in test
mock to fix race condition between event emission and waiter setup.

* fix(server): correct import paths for isCommandAvailable and findExecutable

Import from utils/executable.js (where they're exported) instead of
provider-launch-config.js (which only imports them internally).
2026-04-04 15:35:41 +08:00
Mohamed Boudra
3d3e327378 feat(cli): support paseo . to open desktop app with project (#189)
* fix(app): reorder settings sections for better grouping

* feat(app): add setup hint and paseo.sh link on mobile welcome screen

New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.

* docs(release): add pre-release sanity check and clarify changelog scope

Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.

* feat(cli): support `paseo .` to open desktop app with project directory

Similar to VS Code's `code .`, users can now type `paseo .` or
`paseo <path>` to open the Paseo desktop app with that directory as
the active project.

- Desktop shims detect path-like first args and launch Electron in GUI
  mode with --open-project instead of CLI passthrough mode
- Electron main process parses --open-project, sends IPC event to
  renderer, and forwards via second-instance for the already-running case
- Renderer OpenProjectListener reuses existing openProject() RPC flow
- Standalone CLI discovers the desktop app per platform and spawns it
2026-04-04 15:30:25 +08:00
Mohamed Boudra
42cfed514c feat: add provider-declared features system with Codex fast mode (#186)
Introduce a generic feature system where providers declare dynamic
features (toggles/selects) and the app renders controls automatically.
One message pair (set_agent_feature_request/response) handles all
feature mutations. Feature values persist and restore on agent resume.

First consumer: Codex fast mode (service_tier) — gated to supported
model families, with proper cleanup on model switch.
2026-04-04 15:25:20 +08:00
Mohamed Boudra
99114ddd11 fix(server): resolve gh executable via login shell for desktop users
Electron apps on macOS inherit a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin)
that doesn't include Homebrew or other user-installed tools. This caused
GitHub features (PR status, Create PR) to show as unavailable in the
desktop app even when gh CLI was installed and authenticated.

Extract findExecutable, resolveShellEnv, and related utilities from
provider-launch-config.ts into a shared utils/executable.ts module.
Use findExecutable("gh") with lazy caching so the login-shell resolution
happens once per daemon lifetime. Pass resolveShellEnv() as the process
env for all gh subprocess calls.

Update all consumers to import directly from utils/executable.ts —
no re-exports through provider-launch-config.ts.
2026-04-04 14:24:06 +07:00
Mohamed Boudra
e73c332e4f feat(desktop): add Integrations settings to install CLI and orchestration skills
Add a new Integrations section in desktop settings that lets users
install the Paseo CLI and orchestration skills directly from the app.

CLI install symlinks the bundled shim to ~/.local/bin and updates the
user's shell rc file if needed. Skills install copies SKILL.md files to
~/.agents/skills/, symlinks into ~/.claude/skills/ for Claude Code, and
copies to ~/.codex/skills/ for Codex. Both use a single status check
code path for install verification and on-load detection.

Also adds a /docs/skills page documenting all six orchestration skills,
extracts shared settings styles (section headers, rows) used across
the daemon, integrations, and shortcuts sections, and reorders the
settings sidebar to group integrations and daemon together.
2026-04-04 13:21:32 +07:00
Mohamed Boudra
1594e41602 fix(app): vertically center open-project screen content
Add paddingBottom to the content area equal to the header height so the
visual center accounts for the header above, keeping TitlebarDragRegion
scoped to the content area for Electron window dragging.
2026-04-04 13:21:31 +07:00
Mohamed Boudra
4896cfe970 fix(app): fix sidebar crash when switching iOS theme
Unistyles' StyleSheet.create((theme) => ...) wraps styled components in
<UnistylesComponent> and patches native view properties via C++. When
these dynamic styles are applied to Reanimated's Animated.View, a theme
change causes both systems to fight over the same native node, crashing
with "Unable to find node on an unmounted component."

Fix: use plain React Native StyleSheet for static positioning on
Animated.View, and pass theme-dependent values (backgroundColor) as
inline styles from useUnistyles() instead of Unistyles dynamic sheets.

Also:
- Add gestureAnimatingRef to prevent double withTiming during gesture opens
- Remove all debug instrumentation added during investigation
- Add Maestro flow and bash script for automated theme-toggle verification
- Add docs/MOBILE_TESTING.md covering Maestro patterns, self-verification
  loops, the Unistyles+Reanimated pitfall, and simulator commands
2026-04-04 13:21:31 +07:00
Mohamed Boudra
dcffe46f90 fix(server): increase agent creation timeout to 60s
Under contention, agent creation can be slow enough to exceed the
previous 15s timeout even though the agent is successfully created.
2026-04-04 13:21:31 +07:00
Mohamed Boudra
7b4b42068c docs(release): add pre-release sanity check and clarify changelog scope
Add a Codex 5.4 review step before cutting releases to catch breaking
changes and backward-compatibility issues (mobile apps lag behind
desktop/daemon updates). Clarify that the changelog always covers the
delta from the previous stable release, not from the last RC.
2026-04-04 13:21:31 +07:00
Mohamed Boudra
100502ae1e feat(app): add setup hint and paseo.sh link on mobile welcome screen
New app store users land on the welcome screen with no context. Show a
brief explanation that the desktop app or server is needed, plus a link
to paseo.sh — only on mobile (iOS/Android), hidden on web/desktop.
2026-04-04 13:21:30 +07:00
Mohamed Boudra
178b4cd618 fix(app): reorder settings sections for better grouping 2026-04-04 13:21:30 +07:00
github-actions[bot]
9a6a8ce497 fix: update lockfile signatures and Nix hash 2026-04-03 16:50:58 +00:00
Mohamed Boudra
325ab500b3 chore(release): cut 0.1.45-rc.2 2026-04-03 23:49:49 +07:00
Mohamed Boudra
60bbfdde25 fix(opencode): prevent event stream starvation for slash commands 2026-04-03 23:47:37 +07:00
Mohamed Boudra
71ce95de90 fix(desktop): race existing connections against bootstrap for faster startup
The Electron startup blocked on a ~2.6s synchronous CLI status check before
showing the app. Now the host runtime controllers (which probe persisted
connections) race against the bootstrap path — if the daemon is already
running, the UI comes online in milliseconds.

Also converts the desktop CLI spawn calls from spawnSync to async spawn
so the Electron main thread is no longer blocked during daemon status checks.
2026-04-03 23:20:56 +07:00
Mohamed Boudra
255c2665cc feat(opencode): support custom agents and slash commands
Remove the hardcoded mode whitelist so user-defined agents (from
opencode.json or global config) appear in the mode picker. Add a
listProviderModes API (client → session → provider) so the draft
agent form fetches dynamic modes instead of relying on the static
manifest. Fix slash command dispatch to accept optional arguments.

Closes #185
2026-04-03 22:15:44 +07:00
Mohamed Boudra
176942bbf1 fix(sidebar): persist projects/workspaces and add remove project menu
Stop auto-archiving workspaces when all their agents are archived —
only archive when the directory no longer exists on disk. This prevents
projects from disappearing when users archive their last agent.

Add a kebab dropdown menu to all project rows (desktop) with a
"Remove project" action that archives all workspaces in the project.
2026-04-03 21:33:05 +07:00
Mohamed Boudra
1ae2f9280e fix(website): fetch release version at runtime with asset validation
Move release version resolution from build-time (vite.config.ts) to
runtime (server function on Cloudflare Workers). The server function
fetches GitHub releases, filters out prereleases/drafts, and only
returns a version that has all required assets (Mac DMG, Linux AppImage,
Windows exe). Uses Cloudflare's cf.cacheEverything with a 60s TTL to
avoid hitting GitHub on every request.

This fixes a race condition where the website would deploy and show
download links for a new release before the desktop build assets were
actually uploaded, resulting in 404s for users.
2026-04-03 20:41:26 +07:00
github-actions[bot]
49402b854f fix: update lockfile signatures and Nix hash 2026-04-03 13:00:59 +00:00
Mohamed Boudra
9476b3fc1d chore(release): cut 0.1.45-rc.1 2026-04-03 19:59:54 +07:00
Mohamed Boudra
29037abd54 feat(desktop): auto-restart desktop-managed daemon on version mismatch
When the desktop app starts and finds a running daemon with a different
version, it now restarts it automatically. Only applies to daemons the
desktop app itself started, identified by a PASEO_DESKTOP_MANAGED flag
written into the PID lock file.

Also rewrites resolveStatus() to use the CLI daemon status command
instead of manually parsing the PID file, and surfaces daemonVersion
in the CLI status output from the WebSocket hello message.
2026-04-03 19:43:56 +07:00
Mohamed Boudra
23ffec5072 fix(app): prevent tab pruning from closing pinned archived agents
The tab sync effect pruned archived agent tabs without checking if they
were pinned, so clicking an archived agent from the sessions page would
open and immediately close the tab.
2026-04-03 19:02:18 +07:00
Mohamed Boudra
1e64dd5509 fix(app): add Ctrl+C/V clipboard support for Windows/Linux terminal
On non-Mac platforms, Ctrl+C now copies selected text to clipboard
(falling through to SIGINT when nothing is selected) and Ctrl+V
pastes from clipboard into the terminal.
2026-04-03 18:58:10 +07:00
Mohamed Boudra
cacbbf4053 fix(app): remove input event listener from scrollbar hook that raced with RNW controlled input
The scrollbar hook's raw DOM `input` listener on the textarea called
setState before React Native Web's delegated handler could process the
same event, causing a re-render with the stale controlled value and
swallowing the edit. Scroll and ResizeObserver already cover all
scrollbar metric updates.
2026-04-03 18:49:59 +07:00
Mohamed Boudra
4ca04fc661 feat(desktop): add daemon status dialog to built-in daemon settings
Add a "Full status" button in the desktop daemon section that runs
`paseo daemon status` via Electron IPC and displays the raw output
in a modal.

Also fixes Commander.js argv parsing when the CLI is invoked via
Electron's ELECTRON_RUN_AS_NODE — Commander auto-detects the Electron
environment and skips only 1 argv element instead of 2, causing
"unknown command" errors. Fixed by explicitly passing { from: "node" }.
2026-04-03 18:49:59 +07:00
Mohamed Boudra
f924d33076 Fix bulk close archiving for stored agents 2026-04-03 18:49:59 +07:00
github-actions[bot]
763cfa9333 fix: update lockfile signatures and Nix hash 2026-04-03 09:56:55 +00:00
Mohamed Boudra
fd894dc3d7 chore(release): cut 0.1.44 2026-04-03 16:55:14 +07:00
Mohamed Boudra
9ea181a072 docs: add 0.1.44 changelog entry 2026-04-03 16:55:05 +07:00
Mohamed Boudra
a96f2d7652 fix(desktop): stop daemon before auto-update restart
The daemon is a detached process that survives Electron restarts,
so after an auto-update the old daemon version would keep running.
Now we stop it before quitAndInstall so the new app instance
starts a fresh daemon with the updated binary.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
44da0c67b2 fix(server): disable claude-acp and copilot providers from registry
These providers cause old mobile clients (<=0.1.40) to fail parsing
the list_available_providers_response because their AgentProviderSchema
enum rejects unknown provider IDs, dropping the entire message and
leaving the model picker empty.

The provider implementations remain in the codebase — only the manifest
entries and factory registrations are removed until the updated mobile
app (0.1.43) is live in the App Store.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
55c4e58aa3 fix(desktop): disable npmRebuild in electron-builder 2026-04-03 16:53:14 +07:00
Mohamed Boudra
a2b1498c3f fix(app): broaden keyboard focus scope resolution to check multiple candidates
Check target, parentElement, and document.activeElement as focus
candidates instead of relying solely on the direct event target.
Fixes scope misdetection when the keyboard event target is a text
node or non-element.
2026-04-03 16:53:14 +07:00
github-actions[bot]
df617a4c8f fix: update lockfile signatures and Nix hash 2026-04-03 07:39:11 +00:00
Mohamed Boudra
a64292f2b0 fix: use cross-env for cross-platform NODE_ENV in server scripts 2026-04-03 14:38:07 +07:00
Mohamed Boudra
7ff5933b08 chore: update og-image.png 2026-04-03 11:16:57 +07:00
Mohamed Boudra
bb9ef76017 Fix OpenCode interrupt tool-call terminal state parity 2026-04-03 11:15:02 +07:00
Mohamed Boudra
d6413404e0 ci(desktop): add checkout step before running release tag script 2026-04-03 09:46:57 +07:00
Mohamed Boudra
8a585e60f2 fix(security): shell injection, symlink escape, remove /pairing endpoint, harden defaults
- Replace all execAsync shell-interpolated git calls with execFileAsync + array args in session.ts
- Add symlink resolution in file-explorer resolveScopedPath to prevent workspace escape
- Remove /pairing HTTP endpoint from server; desktop now uses `paseo daemon pair --json` via CLI
- Disable MCP HTTP endpoint by default (opt-in via config)
- Correct SECURITY.md: fix cipher name (XSalsa20-Poly1305), accurate replay resistance claims, add local daemon trust boundary docs
2026-04-02 23:58:38 +07:00
github-actions[bot]
1d795f6c32 fix: update lockfile signatures and Nix hash 2026-04-02 16:14:40 +00:00
Mohamed Boudra
9bd5f852e7 chore(release): cut 0.1.43 2026-04-02 23:13:07 +07:00
Mohamed Boudra
48516f0b9c docs(changelog): add 0.1.43 release notes 2026-04-02 23:12:26 +07:00
Mohamed Boudra
0bf8e8b5b2 Refine model selector UX and mobile sheet behavior
Closes #173
2026-04-02 22:58:59 +07:00
Mohamed Boudra
994ee488b9 Increase workspace status emphasis and use amber alert for needs input 2026-04-02 22:53:52 +07:00
Mohamed Boudra
a854096c35 feat: ACP base provider, Copilot integration, eliminate hardcoded provider unions (#170)
* feat(server): add ACP base provider with Claude ACP integration

Implement a generic Agent Client Protocol (ACP) base provider that any
ACP-compatible agent can extend with minimal code. Includes a concrete
Claude ACP implementation via @agentclientprotocol/claude-agent-acp
with full parity to the existing Claude Code provider.

The base handles subprocess lifecycle, streaming translation, permission
bridging, terminal/fs callbacks, listModels, loadSession/resume, and
mode/model management. The concrete class only specifies the command,
modes, and availability check.

* fix: update lockfile signatures and Nix hash

* feat: add Copilot ACP provider, eliminate hardcoded provider unions, fix ACP streaming bugs

Add Copilot as an ACP provider (copilot --acp), with real modes and models
discovered from the ACP server. Fix two ACP base bugs: duplicate assistant
text (emit deltas not cumulative) and idle→running→stuck (fire-and-return
startTurn). Replace all hardcoded provider string unions with string/manifest-
derived values so adding a provider only requires: impl class, manifest entry,
registry factory, icon, and E2E config. Add provider docs and Copilot icon.

* fix: update lockfile signatures and Nix hash

* feat(app): add OpenCode provider icon

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-02 23:20:01 +08:00
Mohamed Boudra
5d89f9444a Merge branch 'main' of github.com:getpaseo/paseo 2026-04-02 21:49:21 +07:00
thatdaveguy1
63905950cc feat(app): add searchable model favorites (#172)
Improve draft and live model selectors with search, favorites, and clearer provider context. Keep live agents honest by showing other provider catalogs as browse-only until provider switching exists.

Co-authored-by: David Longman <dlongman@tokentradegames.com>
2026-04-02 22:35:07 +08:00
Mohamed Boudra
ffd07ec17c fix(server): hardcode Claude models with 1M context support
The SDK's supportedModels() API hides the 1M context variant behind a
"default" alias and doesn't expose it as a selectable model. Replace
dynamic SDK discovery with a hardcoded catalog that exposes both
claude-opus-4-6[1m] (1M context) and claude-opus-4-6 (200k) as
distinct models. Delete sdk-model-resolver which parsed SDK descriptions.
2026-04-02 18:02:57 +07:00
Mohamed Boudra
2d63bc3893 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-02 14:39:25 +07:00
Mohamed Boudra
55acb8a539 fix(terminal): handle Ctrl+C/V for copy & paste on Windows/Linux
On non-Mac platforms, let xterm.js ClipboardAddon handle Ctrl+C (copy
when text is selected) and Ctrl+V (paste) instead of sending control
codes to the PTY.

Closes #175
2026-04-02 14:09:52 +07:00
Mohamed Boudra
4c52f272fd fix(server): implement slash command support for OpenCode harness (#169)
Add listCommands() to OpenCodeAgentSession via the OpenCode SDK's
command.list() API, and route recognized /commands through
session.command() instead of promptAsync(). This fixes issue #168
where slash commands didn't load when OpenCode was the selected harness.
2026-04-02 12:05:05 +08:00
Mohamed Boudra
a91f79053c fix: themed scrollbar on message input and reusable scrollbar hooks
Add useWebElementScrollbar (for DOM elements) and useWebScrollViewScrollbar
(for RN ScrollView/FlatList) hooks that return renderable overlays, replacing
manual WebDesktopScrollbarOverlay wiring across all consumers. Apply the
themed scrollbar to the message input textarea. Tint the dark-mode scrollbar
handle to match the teal-tinted dark theme.

Closes #174
2026-04-02 10:52:14 +07:00
github-actions[bot]
7b4db04a81 fix: update lockfile signatures and Nix hash 2026-04-02 02:45:23 +00:00
Mohamed Boudra
ac9c2c5642 chore(release): cut 0.1.43-rc.1 2026-04-02 09:44:03 +07:00
Mohamed Boudra
99200eabba fix(windows): quote shell args with spaces 2026-04-02 09:44:03 +07:00
github-actions[bot]
5f2bb87a17 fix: update lockfile signatures and Nix hash 2026-04-01 16:59:45 +00:00
Mohamed Boudra
897c18dd5f chore(release): cut 0.1.42 2026-04-01 23:58:00 +07:00
Mohamed Boudra
51a865cd24 docs(changelog): add 0.1.42 release notes 2026-04-01 23:58:00 +07:00
Mohamed Boudra
9dc3d116b4 fix(windows): quote command paths with spaces when spawning with shell
shell: true passes commands to cmd.exe /d /s /c which strips outer
quotes, causing paths like C:\Program Files\... to split at the space.
2026-04-01 23:58:00 +07:00
Mohamed Boudra
a4326ec5c0 Fix Claude bypass mode after query restarts
Closes #127
2026-04-01 23:58:00 +07:00
github-actions[bot]
cc09b61b19 fix: update lockfile signatures and Nix hash 2026-04-01 12:25:53 +00:00
Mohamed Boudra
26d69e2006 chore(release): cut 0.1.41 2026-04-01 19:23:40 +07:00
Mohamed Boudra
4b7c623592 docs(changelog): add 0.1.41 release notes 2026-04-01 19:21:48 +07:00
Mohamed Boudra
b91884bc54 fix(desktop): enable default context menu for copy/paste across the app 2026-04-01 19:19:40 +07:00
Mohamed Boudra
963c79265a fix(desktop): eliminate white flash on window resize in dark mode
Set native window backgroundColor to match the app's surface0 color so
the backing layer is dark during resize repaints. Extend the existing
updateWindowControls IPC to also call setBackgroundColor on all platforms
(including macOS), keeping the renderer as the single source of truth
for theme resolution. Add a prefers-color-scheme CSS rule in index.html
to cover the HTML-to-React mount gap.
2026-04-01 18:33:17 +07:00
Mohamed Boudra
ade1e338ea fix: show modifier keys and missing keys during shortcut rebinding
Add Tab, F1-F12, Delete, Home, End, PageUp, PageDown, Insert to the
key map so they can be captured during rebinding. Show held modifier
keys (Ctrl, Alt, Shift, Cmd) as live feedback matching VS Code's
keydown-only approach — modifiers persist after release until the
next keypress. Cancel capture when navigating away from settings.
2026-04-01 18:32:31 +07:00
Mohamed Boudra
c13972c835 fix: replace Unix path assumptions with cross-platform isAbsolutePath
Windows daemon paths like C:\Users\... don't start with "/", breaking
the explorer sidebar, checkout status, terminals, and file attachments
when connected to a Windows daemon. Consolidate three private
isAbsolutePath implementations into a shared utility and use it
everywhere, with correct file URI conversion for Windows and UNC paths.
2026-04-01 18:20:02 +07:00
Mohamed Boudra
d8d04c545e docs(release): clarify retry tag rebuilds 2026-04-01 17:40:23 +07:00
Mohamed Boudra
613450bac8 fix: remove 40-item cap on activity curator timeline output
`paseo logs` was only showing the last 40 collapsed timeline items
due to DEFAULT_MAX_ITEMS. Setting to 0 disables the cap so the full
timeline is shown by default. --tail still works for limiting output.
2026-04-01 17:38:32 +07:00
Mohamed Boudra
cafff08a30 fix: rewrite titlebar drag to match VS Code's static pattern
Replace the stateful TitlebarDragRegion (hooks, ResizeObserver, IPC,
fullscreen tracking) with a pure static component — matching VS Code's
titlebar-drag-region exactly: position absolute, full size, no z-index,
no pointer-events, no state, no event listeners.

Remove TitlebarNoDragContent entirely — VS Code doesn't wrap content in
no-drag; interactive elements get no-drag from the global CSS backstop
in index.html.

Add drag regions to all header surfaces:
- ScreenHeader (sessions, workspace header)
- Left sidebar (traffic light area + header)
- Split container pane tabs
- Explorer sidebar header (Changes/Files tabs)

Fix workspace header empty space not draggable by changing
headerTitleContainer from flex: 1 to flexShrink: 1.
2026-04-01 17:36:26 +07:00
Mohamed Boudra
cb60f2a596 Fix Windows default shell for terminal creation 2026-04-01 17:25:41 +07:00
Mohamed Boudra
58d72bea87 refactor: migrate to VS Code titlebar drag region pattern 2026-04-01 16:00:33 +07:00
Mohamed Boudra
953f7898e7 docs(release): clarify workflow dispatch retries 2026-04-01 15:43:24 +07:00
Mohamed Boudra
fd06e109be fix(release): use bash for release env steps 2026-04-01 15:40:34 +07:00
Mohamed Boudra
ba1bb1646e docs(release): clarify stable vs rc flow 2026-04-01 15:37:07 +07:00
github-actions[bot]
3ee11efcd0 fix: update lockfile signatures and Nix hash 2026-04-01 08:36:00 +00:00
Mohamed Boudra
1930a8a2f2 chore(release): cut 0.1.41-rc.1 2026-04-01 15:33:41 +07:00
Mohamed Boudra
f7fd41a5f8 chore(release): add rc prerelease flow 2026-04-01 15:33:27 +07:00
Mohamed Boudra
3af5b0f031 Merge remote-tracking branch 'origin/main' into codex/rc-release-0.1.41 2026-04-01 15:21:26 +07:00
Mohamed Boudra
cce8dee21c fix(server): use shell on Windows for all provider spawns
Windows npm shims (e.g. C:\nvm4w\nodejs\codex) fail with ENOENT when
spawned without shell. Use `shell: true` on win32 for all provider
launches instead of the overly complex shouldUseWindowsShell function.
2026-04-01 15:19:28 +07:00
Mohamed Boudra
2d02db6ae0 fix(app): improve light mode theming
- Make PaseoLogo theme-aware (uses foreground color instead of hardcoded white)
- Add shadow tokens (sm/md/lg) to theme with per-scheme opacity, radius, and offset
- Replace all 16 hardcoded shadow instances with spreadable theme.shadow tokens
- Fix button icon color for default variant (use accentForeground, not foreground)
- Fix dark background flash on startup (root layout used hardcoded darkTheme)
- Add theme.colorScheme to replace fragile hex-string dark mode detection
- Add scrollbarHandle and surfaceWorkspace tokens to eliminate isDark branching
2026-04-01 14:24:17 +07:00
github-actions[bot]
66732a2f48 fix: update lockfile signatures and Nix hash 2026-04-01 06:43:36 +00:00
Mohamed Boudra
30ebd4b777 chore(release): cut 0.1.40 2026-04-01 13:42:02 +07:00
Mohamed Boudra
e95554d782 docs: add 0.1.40 changelog entry 2026-04-01 13:40:59 +07:00
Mohamed Boudra
5cf8b7549d fix(opencode): prevent reasoning content from duplicating as assistant text
OpenCode's message.part.delta events use field="text" for all parts
including reasoning, because "text" is the property name being updated.
Track part types from message.part.updated events and use them to
correctly classify deltas for known reasoning parts.

Also set PASEO_SUPERVISED=0 in vitest setup to prevent process.send()
conflicts with vitest's fork pool.
2026-04-01 13:37:51 +07:00
Mohamed Boudra
9c4dee5364 fix(server): handle codex spawn errors to prevent daemon crash
Spawning a missing/broken codex binary emits an async "error" event on
the child process. Without a listener, Node.js crashes the daemon with
no log entry. Add child.on("error") in CodexAppServerClient and global
uncaughtException/unhandledRejection handlers that log via pino before
exiting.
2026-04-01 13:37:51 +07:00
Mohamed Boudra
c635eabff3 Cache provider models by server and provider 2026-04-01 13:37:51 +07:00
Mohamed Boudra
51411182fe fix(app): support ipad desktop layout safely 2026-04-01 13:37:51 +07:00
Mohamed Boudra
f53c770a71 [codex] add batch close rpc for workspace tabs (#165)
* style: add subtle teal tint to dark mode surfaces

Replace neutral gray surfaces with a teal-tinted palette across
the app and website, giving Paseo a warmer, more recognizable feel.
App uses a restrained tint (G-R ~3), website is slightly stronger
(G-R ~6) as a brand showcase.

* fix(opencode): handle message.part.delta events for assistant text streaming

OpenCode v2 streams assistant text via `message.part.delta` events (with
field "text" or "reasoning"), but the translator only handled
`message.part.updated`. This caused assistant messages to be silently
dropped during live streaming.

* feat: show agent short ID in tab context menu and tooltip (#161)

Add agent short ID to the "Copy agent id" menu item as trailing hint
text and to the tab tooltip next to the title. Add leading icons to
all workspace tab context menu items.

* add batch close rpc for workspace tabs
2026-04-01 14:31:38 +08:00
Mohamed Boudra
8d55764313 fix: correct checkout-diff-manager test file contents
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 04:49:03 +00:00
Mohamed Boudra
4b12ebd5c0 fix: Linux checkout diff watchers using non-recursive directory watching
Use non-recursive directory watchers on Linux since recursive fs.watch
is not supported. Dynamically discover and watch subdirectories, refreshing
the watcher tree on changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 04:46:07 +00:00
Mohamed Boudra
27c8cfbd4b Revert "fix: Linux checkout diff watchers and rewind command ordering"
This reverts commit 07b077f1a2.
2026-04-01 04:44:29 +00:00
Mohamed Boudra
07b077f1a2 fix: Linux checkout diff watchers and rewind command ordering
Use non-recursive directory watchers on Linux since recursive fs.watch
is not supported. Dynamically discover and watch subdirectories, refreshing
the watcher tree on changes. Also pin rewind command first in the slash
command list and remove stale .gitignore entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 04:38:50 +00:00
Mohamed Boudra
ee50d3b8d0 WIP: fix archive tab reconciliation (#158)
* WIP: fix archive tab reconciliation

* fix: converge archive into AgentManager for cross-session propagation

CLI archive left agent tabs visible in passive app clients because
Session.archiveAgentState only notified the archiving session's own
subscription. LoopService had the same gap via AgentManager.archiveAgent.

Converge on AgentManager.archiveAgent as the single canonical archive
path: persist updatedAt, call notifyAgentState before closeAgent so all
sessions receive the archived snapshot. Delete Session.archiveAgentState
and make handleArchiveAgentRequest a thin wrapper.
2026-03-31 16:03:36 +07:00
Mohamed Boudra
4b93f990d2 fix(ci): merge macOS update manifests from parallel arch builds
electron-builder overwrites latest-mac.yml when parallel arm64/x64
builds publish independently — whichever finishes last wins, leaving
the other architecture's users downloading the wrong binary.

Add a finalize-mac-manifest job that runs after both macOS builds
complete, merges their per-arch manifest artifacts into a single
latest-mac.yml containing all files, and uploads it to the release.
2026-03-30 20:30:10 +07:00
github-actions[bot]
87f297e755 fix: update lockfile signatures and Nix hash 2026-03-30 10:29:37 +00:00
Mohamed Boudra
b50b065bcc chore(release): cut 0.1.39 2026-03-30 17:27:44 +07:00
Mohamed Boudra
4e8e2589bf docs: add 0.1.39 changelog entry 2026-03-30 17:27:34 +07:00
github-actions[bot]
8eddb2ee18 fix: update lockfile signatures and Nix hash 2026-03-30 10:06:33 +00:00
Mohamed Boudra
0ba2f6b194 feat(cli): add paseo terminal command group for managing workspace terminals
New CLI commands: ls, create, kill, capture (tmux capture-pane style with
line range slicing and ANSI stripping), and send-keys (with special token
interpretation). Server-side adds capture_terminal_request RPC and
list-all-terminals support (optional CWD). Includes e2e tests and skill docs.
2026-03-30 17:05:14 +07:00
Mohamed Boudra
5b22aedaff feat(app): polish file explorer tree with material icons and visual refinements
Replace lucide file icons with colored SVGs from material-icon-theme covering
53 language/filetype-specific icons. Add chevron expand indicators for
directories, indent guide lines, rounded rows, and restyle the header toolbar
to match the git diff pane pattern.
2026-03-30 17:05:14 +07:00
Mohamed Boudra
e3b6e2dcfa Create FUNDING.yml 2026-03-30 17:29:31 +08:00
Mohamed Boudra
0d9bda12e6 fix(app): centralize window controls padding into useWindowControlsPadding hook
Replaces useTrafficLightPadding with a role-based useWindowControlsPadding
hook that absorbs all layout-conditional logic (sidebar state, focus mode,
explorer state). Consumers no longer make platform or layout decisions —
they just apply the resolved padding values.

Fixes Windows title bar overlay buttons overlapping source control icons
when the left sidebar is open, and right padding not shifting to the
explorer sidebar header when it's open.

Also fixes borderless header using borderBottomWidth:0 (layout shift)
instead of transparent, and double-click-to-maximize bounce on the
open project screen caused by nested drag handlers.
2026-03-30 15:17:27 +07:00
Mohamed Boudra
a94bc0720c refactor(server): rename worktree destroy → teardown, add port to env, extract session code
- Rename `worktree.destroy` config field to `worktree.teardown` (breaking)
- Rename getWorktreeDestroyCommands/runWorktreeDestroyCommands → teardown
- Add PASEO_WORKTREE_PORT to teardown env from persisted metadata (omit if missing)
- Extract worktree orchestration from session.ts into worktree-session.ts
- Document teardown lifecycle hooks in worktree docs
2026-03-30 14:53:27 +07:00
Mohamed Boudra
7504d20b67 refactor(server): extract SpeechService and defer init until after listen
Speech runtime was leaking 8+ individual resolvers into bootstrap and
blocking server startup for ~3s with synchronous Sherpa native model
loading. Refactor into a self-contained SpeechService that owns its
full lifecycle (init, download, monitor, cleanup) and defer start()
until after httpServer.listen() so native loading doesn't block
accepting connections. Server startup drops from ~3.2s to ~0.5s.

Also removes unused client-triggered speech model download/list path
(CLI commands, session handlers, message types) — the service manages
its own models.
2026-03-30 14:07:55 +07:00
Mohamed Boudra
db39417be9 fix(app): remove redundant overflow hidden causing iOS sidebar scroll flicker
The inner sidebarContent had overflow: "hidden" while the parent
mobileSidebar already clips. On iOS, nested overflow clipping on
Animated.View with transforms and scroll containers causes rendering
artifacts during scroll.
2026-03-30 13:45:26 +07:00
Mohamed Boudra
e0e4f228eb fix(server): guard paseo:ready IPC behind PASEO_SUPERVISED env var
The process.send check alone was insufficient because vitest also runs
workers with IPC channels. Now the supervisor sets PASEO_SUPERVISED=1
on the worker env, and bootstrap checks for it before sending.
2026-03-30 12:44:05 +07:00
github-actions[bot]
f6630e16ff fix: update lockfile signatures and Nix hash 2026-03-30 05:40:49 +00:00
Mohamed Boudra
82a235dada chore(release): cut 0.1.38 2026-03-30 12:39:23 +07:00
Mohamed Boudra
3784357b7e docs: add 0.1.38 changelog entry 2026-03-30 12:39:12 +07:00
Mohamed Boudra
8d0a9f9224 fix(server): fix daemon startup race and reduce log bloat
The desktop app could time out connecting to the daemon on first launch
because the PID file advertised a listen address before the HTTP server
was actually listening. The supervisor now writes the PID lock with
listen: null and updates it only after the worker sends a paseo:ready
IPC message confirming the server is listening.

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

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

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

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

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

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

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

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

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

* test: stabilize loop schedule chat rollout
2026-03-28 00:25:04 +07:00
github-actions[bot]
8f11902cb7 fix: update lockfile signatures and Nix hash 2026-03-27 14:13:21 +00:00
Mohamed Boudra
e2b2b7c701 chore(release): cut 0.1.37 2026-03-27 21:11:53 +07:00
Mohamed Boudra
af35cccb5f fix(release): skip release notes sync when no changelog entry exists
Draft releases don't have changelog entries, so the script should log
and continue rather than throwing.
2026-03-27 21:10:03 +07:00
github-actions[bot]
6342057a6f fix: update lockfile signatures and Nix hash 2026-03-27 14:00:20 +00:00
Mohamed Boudra
89923b5f76 fix(ci): default to draft release when no GitHub release exists
The fallback was "release", so pushing a tag without pre-creating a
draft release on GitHub caused electron-builder to create a published
release instead of a draft.
2026-03-27 20:59:07 +07:00
Mohamed Boudra
54b5a22688 fix(windows): broken PATH propagation, ps usage, and claude binary resolution
- sherpa-runtime-env: use case-insensitive key lookup when modifying PATH
  on plain env objects. On Windows, `{...process.env}` stores PATH as
  `Path` but `applySherpaLoaderEnv` used hardcoded `"PATH"`, creating a
  duplicate key that could shadow the real system PATH in child processes.

- runtime-toolchain: use `wmic` on Windows instead of Unix-only `ps -o`
  to resolve the node executable path from a PID.

- claude-agent: pass `findExecutable("claude")` as
  `pathToClaudeCodeExecutable` so the SDK uses the user's installed
  binary when available. Update spawn hook to only replace bare
  "node"/"bun" with process.execPath, preserving native binary paths.

- desktop/paseo.cmd: use ELECTRON_RUN_AS_NODE with node-entrypoint-runner
  for the Windows CLI wrapper.
2026-03-27 20:50:18 +07:00
Mohamed Boudra
ab3443fd52 feat(desktop): hide native titlebar on Windows/Linux with overlay window controls
Use titleBarStyle: 'hidden' on all platforms. On Windows/Linux, enable
titleBarOverlay with dark theme colors for native min/max/close buttons.
Extend useTrafficLightPadding to return side ('left'|'right'|null) so
consumers apply padding on the correct side per platform.
2026-03-27 19:59:17 +07:00
Mohamed Boudra
95ad879f82 fix: show toast on dictation errors instead of silent console log 2026-03-27 19:27:29 +07:00
Mohamed Boudra
80e153f861 feat(desktop): add file logging with electron-log
Logs from both main and renderer processes now persist to
~/Library/Logs/Paseo/main.log (macOS) with automatic rotation.
Removes the unused webview_log IPC handler.
2026-03-27 19:22:40 +07:00
Mohamed Boudra
dfa376f5ca docs: add 0.1.36 changelog entry 2026-03-27 18:48:06 +07:00
Mohamed Boudra
76c357c88c fix: run release:check before version bump to prevent dirty state on failure 2026-03-27 18:44:18 +07:00
github-actions[bot]
5adde123e3 fix: update lockfile signatures and Nix hash 2026-03-27 11:32:43 +00:00
Mohamed Boudra
57291b1fd9 chore(release): cut 0.1.36 2026-03-27 18:30:49 +07:00
Mohamed Boudra
89f285ffd0 docs: clarify draft release flow and changelog dependency 2026-03-27 18:29:53 +07:00
Mohamed Boudra
e6c06e2263 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-27 18:18:56 +07:00
Mohamed Boudra
ca443b3ecf fix: handle Windows drive-letter paths across the codebase (#148)
* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
2026-03-27 18:17:48 +07:00
Mohamed Boudra
d8e9298222 Add metrics collection and terminal performance tests 2026-03-26 23:38:32 +07:00
Mohamed Boudra
c667aca3e0 chore: remove stale agent-event-stream-redesign doc 2026-03-26 20:56:03 +07:00
Mohamed Boudra
7892b3cbe1 fix(nix): update stale npmDepsHash and auto-fix on all lockfile changes
Hash mismatch was hidden by continue-on-error. Now the Nix Build
workflow fails visibly, and fix-nix-hash runs on every push/PR that
touches the lockfile (not just Dependabot).
2026-03-26 20:19:12 +07:00
Mohamed Boudra
f2000ff789 chore(release): cut 0.1.35 2026-03-26 18:14:00 +07:00
Mohamed Boudra
759815cfac docs: add v0.1.35 changelog 2026-03-26 18:13:33 +07:00
Mohamed Boudra
68df304867 refactor(app): move startup routing logic into welcome screen and simplify index route 2026-03-26 18:10:21 +07:00
Mohamed Boudra
a2aac3797f fix(server): translate Codex file deletions as removed lines instead of added lines
Codex delete operations were displayed as edits with green (added) lines
because the translation pipeline didn't distinguish deletes from edits.
Now detects kind="delete" and *** Delete File directives, producing proper
unified diffs with removed lines and +++ /dev/null headers.
2026-03-26 17:53:57 +07:00
Mohamed Boudra
f13178a9ad Replace mapfile with portable while-read loop in chat script
mapfile is a bash 4+ builtin and fails on macOS default bash 3.2
with "command not found".
2026-03-26 17:51:56 +07:00
Mohamed Boudra
c1d71dfedc Fix queued prompt dispatch after idle transition 2026-03-26 15:14:05 +07:00
Mohamed Boudra
2e7bc49c4c Make mobile mockup horizontally scrollable on small screens
Prevents the 4-phone composite image from shrinking to illegibility
on mobile by setting a min-width and enabling horizontal scroll.
2026-03-26 15:12:53 +07:00
Mohamed Boudra
55dd3a9b1a Add Homebrew Cask install to downloads page, fix APK URL
- Extract CommandDialog shared component from landing page's ServerInstallButton
- Add Homebrew pill to macOS section on download page that opens install dialog
- Add ESC key support to close the command dialog
- Fix APK download URL (paseo-v${version}-android.apk)
2026-03-26 15:08:16 +07:00
Mohamed Boudra
5b3aea8bfe fix(server): surface opencode questions in permission UI 2026-03-26 14:49:52 +07:00
Mohamed Boudra
e47eb64de0 Add privacy-first to README, tweak sponsor copy 2026-03-26 11:49:10 +07:00
Mohamed Boudra
eada5eec53 Update landing page: mobile section, sponsor copy, muted color consistency 2026-03-26 11:46:16 +07:00
Mohamed Boudra
1ee7cace36 Update orchestration skills description 2026-03-26 11:20:41 +07:00
Mohamed Boudra
612731a5b5 Add syntax highlighting to skill examples 2026-03-26 11:18:36 +07:00
Mohamed Boudra
f50252c3b1 Add descriptions to skill examples in README 2026-03-26 11:17:54 +07:00
Mohamed Boudra
3cd1552ddc Add CLI and orchestration skills sections to README 2026-03-26 11:13:59 +07:00
Mohamed Boudra
caab3f9686 Update subtitle and mobile mockup 2026-03-26 11:05:53 +07:00
Mohamed Boudra
bf8ba4da8f Update mockups and add mobile section to landing page 2026-03-26 10:59:59 +07:00
Mohamed Boudra
3fcc1ecedb Clarify getting started with two paths: desktop app and headless 2026-03-26 09:57:42 +07:00
Mohamed Boudra
46d7512cc4 fix(website): correct linux release asset links 2026-03-26 09:17:49 +07:00
Mohamed Boudra
00e172798b Update website download CLI command 2026-03-25 23:50:27 +07:00
José Albornoz
bb560809c7 Add support for Nix and NixOS (#130)
* fix: add missing resolved/integrity fields to package-lock.json

npm omits resolved URLs and integrity hashes for workspace-local
node_modules overrides. This breaks offline installers like Nix's
npm ci. Add the missing fields for 25 workspace-hoisted packages.

* feat: add Nix flake with package and NixOS module

Add a Nix flake that builds the Paseo daemon (server + CLI) and
provides a NixOS module for declarative deployment.

Package (nix/package.nix):
- Builds relay, server, and CLI workspaces
- Skips onnxruntime-node install script (sandbox-incompatible)
- Rebuilds only node-pty for native terminal support
- Source filter excludes app/website/desktop workspaces

NixOS module (nix/module.nix):
- Systemd service with configurable user, port, listen address
- allowedHosts for DNS rebinding protection
- relay.enable to toggle remote access via app.paseo.sh
- inheritUserEnvironment to expose user tools (git, ssh) to agents
- openFirewall and extra environment variables

ci: add Nix hash maintenance scripts and workflows

scripts/fix-lockfile.mjs:
  Adds missing resolved/integrity fields to package-lock.json for
  workspace-local overrides. Idempotent, uses `npm view`.

scripts/update-nix.sh:
  Runs fix-lockfile.mjs, prefetches deps, computes NAR hash, and
  updates npmDepsHash in nix/package.nix. Supports --check for CI.

.github/workflows/nix-build.yml:
  Builds the Nix package on push/PR and verifies the lockfile and
  hash are up to date.

.github/workflows/fix-nix-hash.yml:
  Auto-fixes lockfile signatures and Nix hash on dependabot PRs.

fix: update npmDepsHash after upstream sync

nix: allowlist workspace symlinks instead of blocklist

Prevents build failures when upstream adds new workspace packages.

* don't block PRs on nix failures

* better document npm workaround

* fix hash update script, and update hash

* integrate with npm run build:daemon

* ci: trigger nix build on highlight changes

* fix(nix): update npmDepsHash

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-03-25 23:42:44 +07:00
Mohamed Boudra
765a11c7ab fix(metadata): update project contact email 2026-03-25 23:36:55 +07:00
Mohamed Boudra
08e37540eb refactor(desktop): sync package metadata from root 2026-03-25 23:34:02 +07:00
Mohamed Boudra
1768e2787f fix(desktop): align website assets and linux package metadata 2026-03-25 23:31:41 +07:00
Mohamed Boudra
6d467d4659 refactor(website): extract shared download constants and icons into ~/downloads
Single source of truth for version, release URLs, store URLs, platform
detection, and shared icon components between landing page and /download.
2026-03-25 23:21:47 +07:00
Mohamed Boudra
701480c11c Add /download page and link in nav
Desktop (macOS, Windows, Linux with AppImage/DEB/RPM), Mobile (Android/iOS),
and Web & CLI sections. Simplify homepage download button to a single link
with "All download options" text link below.
2026-03-25 23:06:35 +07:00
Mohamed Boudra
9ce6b45cf9 fix(desktop): make mac artifact names explicit and add deb/rpm 2026-03-25 23:05:17 +07:00
Mohamed Boudra
7eebee2de0 Fix Claude model catalog CI assertion 2026-03-25 22:49:14 +07:00
Mohamed Boudra
ff2fc72581 chore(release): cut 0.1.34 2026-03-25 22:33:28 +07:00
Mohamed Boudra
cdd7071885 docs(changelog): draft 0.1.34 notes 2026-03-25 22:33:04 +07:00
Mohamed Boudra
30cba9550e Fix attach button trigger and Claude interrupt test race 2026-03-25 22:31:24 +07:00
Mohamed Boudra
1318372e81 Remove dead agent-prompt and terminal-mcp code
Delete unused system-prompt.ts, agent-prompt.md, and the entire
terminal-mcp/ directory (tmux-based MCP server) — nothing imports
any of these. The codebase uses src/terminal/ (node-pty) instead.
2026-03-25 21:54:34 +07:00
Mohamed Boudra
185990f46b Update project row workspace action icon 2026-03-25 21:01:32 +07:00
Mohamed Boudra
508cd524a8 Tighten Claude partial tool input parsing 2026-03-25 20:38:43 +07:00
Mohamed Boudra
08f25f065a Fix stale abort result contaminating replacement turns after interrupt
When interrupting a Claude agent mid-tool-call and sending a replacement
message, the SDK's abort error result was being attributed to the new
foreground turn, causing [System Error] banners and displaced replies.

Root cause: pendingInterruptAbort was cleared by visible activity from
the new turn before the stale result arrived (timing race), and the
stale result then poisoned the replacement turn.

Fix:
- Suppress stale non-success results at the top of routeSdkMessageFromPump
  before any turn attribution, using both flag-based (pendingInterruptAbort)
  and content-based (isAbortError) detection
- Stop clearing pendingInterruptAbort on visible activity — only consume
  it when a result message arrives
- Prevent idle flash during replacement by checking pendingReplacement
  in agent-manager turn_completed/turn_canceled handlers
- Fix vitest env loading to use import.meta.url instead of process.cwd()
- Load .env.test eagerly in agent-configs.ts for collection-time availability

Includes regression tests covering the exact f160a2a3 daemon log ordering.
2026-03-25 20:12:18 +07:00
Mohamed Boudra
36327cad00 Eliminate "Default" from thinking selector, always resolve to a real option
Like the model selector, the thinking selector now always resolves to an
actual thinking option ID instead of falling back to "Default" display text.
2026-03-25 19:04:01 +07:00
Mohamed Boudra
939b5c22db Fix --no-wait flag never working on paseo send
Commander's --no-* pattern sets options.wait = false, not
options.noWait = true. The check was always falsy so every
send waited up to 10 minutes for the agent to finish.
2026-03-25 18:13:27 +07:00
Mohamed Boudra
5567652c6c Add top-level paseo archive as alias for paseo agent archive 2026-03-25 17:46:32 +07:00
Mohamed Boudra
a65f4c1465 Link Android button and footer to Google Play Store 2026-03-25 17:46:17 +07:00
Mohamed Boudra
253319ee50 Complete (not cancel) autonomous turns on interrupt
When a foreground message interrupts an autonomous turn, the notification
content has already been dispatched to subscribers. Canceling says "this
turn didn't happen" — but it did. Complete it instead, preserving the
lifecycle semantics and preventing notification loss.

- Change interrupt() to call completeAutonomousTurn instead of
  cancelAutonomousTurn, with flushPendingToolCalls before completion
- Remove dead cancelAutonomousTurn method (no remaining callers)
- Fix autonomous wake tests B and C to handle notification/foreground
  timing race: when task_notification arrives during a foreground turn,
  there is no separate autonomous running edge afterwards
2026-03-25 17:37:00 +07:00
Mohamed Boudra
4c1b11f733 Revert incorrect Claude interrupt flags that broke autonomous wake
Remove `pendingInterruptAbort = false` from startTurn() and
`queryRestartNeeded = true` from requestCancel(). Both violated the
existing coordination contracts:

- pendingInterruptAbort must only be cleared by the stream pump at its
  safe consumption points, not eagerly by the turn lifecycle
- queryRestartNeeded is for transport-level restarts (config changes,
  rewind), not for normal interrupt/cancel flows — setting it on every
  cancel killed the Claude process and destroyed background tasks

The actual fix for the send-during-tool-call bug was the manager-side
pendingForegroundRun settlement wait added in the previous commit.
2026-03-25 17:22:46 +07:00
Mohamed Boudra
6b67e6569b Fix chat read --since to accept message IDs
The --since filter was comparing ISO timestamps lexicographically against
message IDs (msg-*), which always filtered out every message. Now resolves
message IDs to their file first and skips messages up to that point.
2026-03-25 17:18:53 +07:00
Mohamed Boudra
f5a28a434d Restore per-provider form preferences and remove Auto model fallback
- Add per-provider preference storage: model, mode, thinkingByModel
- resolveFormState reads stored prefs as fallback between initial
  values and provider defaults
- setProviderFromUser seeds model/mode/thinking from stored prefs
  when switching providers
- persistFormPreferences writes full per-provider state on agent create
- Remove workingDir and serverId from stored preferences (redundant
  with workspace tab context)
- Simplify settings.tsx by removing preference-based server resolution
2026-03-25 16:46:35 +07:00
Mohamed Boudra
638c633a21 Fix Claude agent interrupt race and refactor mode color tiers
- Fix race in agent-manager where pendingRun wasn't fully cleaned up
  before the next streamAgent call, causing "already has an active run"
- Fix Claude agent query restart: null out query/input before awaiting
  old iterator return so the old pump skips failActiveTurns
- Reset pendingInterruptAbort on new foreground turn
- Add system error assertion to send-during-tool-call e2e test
- Rename mode color tiers: drop "default", rename "readonly" to
  "planning", update color assignments across providers and UI
2026-03-25 16:46:23 +07:00
Mohamed Boudra
935e55eb27 Prune wrong-platform native binaries from Electron builds
Strips onnxruntime-node (linux/win32), claude-agent-sdk ripgrep
(non-target platforms), node-pty prebuilds, and sharp libvips from
the app.asar.unpacked directory during afterPack. Saves ~80 MB on
macOS arm64 builds.
2026-03-25 16:43:19 +07:00
Mohamed Boudra
c4782ec71c Fix nested Claude Code session detection and centralize provider availability checks
Strip parent Claude Code session env vars (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, etc.)
from child agent environments so spawned agents don't fail with "cannot be launched
inside another Claude Code session". Centralize isProviderAvailable to check both
binary and credential availability. Add red e2e test for send-during-tool-call bug
and force-cancel stale foreground turns in cancelAgentRun.
2026-03-25 16:00:24 +07:00
Mohamed Boudra
81ee887d03 Reduce unnecessary re-renders in agent panel and input components
Narrow zustand selectors to only subscribe to fields each component
actually uses. Split nested objects (availableModes, projectPlacement)
into separate selectors with structural equality checks. Remove the
updatedAt processing-spinner hack in agent-input-area — the server
already guarantees agent status is "running" before ACKing the send
request, so isProcessing can clear on submit success directly.
2026-03-25 13:09:17 +07:00
Mohamed Boudra
c2c5bc815e Fix Claude stderr diagnostics and hide max effort 2026-03-25 11:46:35 +07:00
Mohamed Boudra
bd635cf07e test: add e2e test for model resolution on agent init 2026-03-25 10:50:20 +07:00
Mohamed Boudra
ad565c2555 Fix agent input focus scoping 2026-03-24 23:41:41 +07:00
Mohamed Boudra
ac991e064c Improve codex activity log tool-call summaries 2026-03-24 23:39:41 +07:00
Mohamed Boudra
fadeca82eb Use parsed Claude models and remove auto defaults 2026-03-24 23:32:23 +07:00
Mohamed Boudra
891dbb0f6e Fix terminal snapshot ordering on subscribe 2026-03-24 23:06:17 +07:00
Mohamed Boudra
140e0ef965 fix(chat): skip archived agent notifications 2026-03-24 22:48:40 +07:00
Mohamed Boudra
8c49f74fd9 docs(skills): add @mention guidance for chat notifications
Orchestrator skill: tell agents to @mention you when you expect a
response back, using $PASEO_AGENT_ID. Chat skill: clarify that
mentions interrupt immediately, so use them deliberately.
2026-03-24 21:53:01 +07:00
Mohamed Boudra
535774a235 fix(chat): improve transcript readability with box-drawing borders
Replace flat markdown headers and --- dividers with box-drawing
characters (┌─ │ └─) so each message is visually distinct. Clean
up timestamp format and trim leading blank lines from message bodies.
2026-03-24 21:37:15 +07:00
Mohamed Boudra
fa45ab593b chore: update orchestrator/chat skills and dev tooling
- Orchestrator skill: add Claude vs Codex guidelines, structured audit
  patterns, narrow single-purpose review passes
- Chat skill: improved coordination helpers
- Dev script and config tweaks
2026-03-24 21:22:14 +07:00
Mohamed Boudra
a54687d3ef refactor(server): replace stream() with subscribe() + startTurn() across all providers
Replace three competing event paths (foreground stream, live event pump,
JSONL history poller) with a single push-based subscribe() + startTurn()
contract. This fixes duplicate user messages and stuck running state caused
by timing-based routing between concurrent event sources.

Key changes:
- AgentSession interface: remove stream(), add subscribe() and startTurn()
- All providers (Claude, Codex, OpenCode): single subscribers Set with
  notifySubscribers() for push-based event delivery and turnId stamping
- Agent manager: identity-based turn ownership via activeForegroundTurnId
  replacing pendingRun async generator
- Delete: dual queues, routeSdkMessageFromPump, startLiveHistoryPolling,
  snapHistoryOffsetToEnd, liveEventBacklog, Pushable
- Fix Codex provider not clearing activeForegroundTurnId on turn completion
- Add real-provider integration tests for event stream invariants
2026-03-24 21:22:00 +07:00
Mohamed Boudra
6cc81e3ab4 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-24 14:08:57 +07:00
Mohamed Boudra
7699d2fcbd Expose PASEO_AGENT_ID to Claude and Codex agents (#143)
* Expose PASEO_AGENT_ID to managed agents

* refactor(server): make managed agent launch context explicit

* refactor(server): pass launch env through agent providers

* fix: use workspace-agnostic root tsconfig

* fix(server): align CI tests with current agent behavior

* fix(server): restore Claude history and sidechain CI coverage
2026-03-24 15:02:15 +08:00
Mohamed Boudra
77cdfbcb06 fix(app): sync keyboard pane focus with active panel (#141)
* fix(app): move keyboard focus to target pane on keyboard navigation (#137)

Add a pane focus registry so each panel type can expose its own focus
callback. When keyboard shortcuts navigate between panes, the workspace
screen calls the target panel's registered focus function, moving DOM
focus to the correct input element.

- Agent panels register a callback that focuses the message input
- Terminal panels register a callback that triggers terminal focus
- Panel-internal focus logic stays inside each panel type
- No DOM selectors or panel knowledge in shared infrastructure

* fix(app): simplify workspace pane focus handoff
2026-03-24 15:02:02 +08:00
Mohamed Boudra
cbab9332a9 feat(app): redesign command autocomplete with detail card and dropdown styling
- Add detail card above command list showing full description and argument hint
- Compact command rows to single line (label + truncated description)
- Match dropdown component styling (surface1 bg, borderAccent border, shadow)
2026-03-24 13:35:59 +07:00
Mohamed Boudra
fa0346921a feat(skills): add paseo chat coordination 2026-03-24 12:37:13 +07:00
Mohamed Boudra
c6aff35db2 fix(app): restore assistant text selection on web (#140) 2026-03-24 12:01:08 +08:00
Mohamed Boudra
af8509d012 fix(release): unblock 0.1.33 app and server checks 2026-03-24 00:09:05 +07:00
Mohamed Boudra
0e6aba2886 refactor(paseo-loop): support self and new-agent targets with max-time 2026-03-24 00:04:17 +07:00
Mohamed Boudra
e969f42c68 chore(release): cut 0.1.33 2026-03-23 23:21:14 +07:00
Mohamed Boudra
876c08ebc2 docs: add 0.1.33 changelog 2026-03-23 23:13:01 +07:00
Mohamed Boudra
8ba5df358a Revert "debug(app): add dictation pipeline logging"
This reverts commit e36784e510.
2026-03-23 23:06:55 +07:00
Mohamed Boudra
771acd11a1 fix(server): close query stream before interrupt/return in claude agent
Call query.close() before awaiting interrupt/return to ensure the
stream is properly torn down on session close and query restart.
2026-03-23 22:04:37 +07:00
Mohamed Boudra
d4735edd45 fix(desktop): surface notification test errors in UI
Show inline error text when the test notification fails or isn't
delivered instead of silently logging to console.
2026-03-23 22:04:30 +07:00
Mohamed Boudra
e36784e510 debug(app): add dictation pipeline logging
Trace the full dictation flow — confirm, transcript, and auto-send
paths — to diagnose transcription delivery issues.
2026-03-23 22:04:24 +07:00
Mohamed Boudra
a237285808 fix(desktop): resolve to Helper app for daemon child processes on macOS
When packaged, Electron's process.execPath points to the main app
binary which inherits the full app entitlements and UI lifecycle.
Resolve to the Helper binary instead so daemon child processes run
without inheriting the main app's entitlement/UI baggage.
2026-03-23 22:04:19 +07:00
Mohamed Boudra
ee6d8072ed feat(desktop): add microphone entitlement for dictation
Add com.apple.security.device.audio-input to both mac entitlement
plists so the sandboxed app can access the microphone.
2026-03-23 22:04:12 +07:00
Mohamed Boudra
c8d259b1df feat(highlight): make package public and add to release pipeline
Remove private flag, add publishConfig with public access, point
types to dist output, and include highlight in release:check,
release:publish, and dry-run scripts.
2026-03-23 22:04:02 +07:00
Mohamed Boudra
2da56634a1 refactor(skills): strip orchestrator concerns from paseo skill
Paseo skill is now a pure CLI reference — commands, models,
permissions, waiting guidelines, and bash composition patterns.
Orchestrator-specific guidance (agent interrogation, investigation
vs implementation, management principles, committee patterns) moved
to the new paseo-orchestrator skill.
2026-03-23 21:52:36 +07:00
Mohamed Boudra
27be8c3b1e feat(skills): add paseo-orchestrator skill for agent orchestration
Dedicated skill for orchestrator mode — how to manage agents as a
product owner rather than a coder. Covers two-audience model (design
partner to user, product owner to agents), pre-launch logistics,
agent types beyond just impl/review, prompt structure, mandatory
review step, challenging agent bad behaviors (hand-waving,
over-engineering, lying, working around problems), and user signal
interpretation.
2026-03-23 21:52:30 +07:00
Mohamed Boudra
988c432ded fix(release): resolve android-v* tags to release tag in APK workflow
android-v0.1.32 must resolve to v0.1.32 for the release upload step,
otherwise it looks for a non-existent release and times out.
2026-03-23 15:59:21 +07:00
Mohamed Boudra
dce4e668f1 fix(release): set EP_GH_IGNORE_TIME to allow asset overwrites on rebuilds
electron-builder skips uploading to releases older than 2 hours.
This breaks all rebuild workflows since the release already exists.
2026-03-23 15:58:27 +07:00
Mohamed Boudra
2c69008a4a fix(desktop): use explicit artifact name for Linux to prevent scoped-name conflicts
The scoped package name @getpaseo/desktop was leaking into the tar.gz
artifact name. GitHub sanitizes the / to . on upload, causing a name
mismatch that prevents electron-builder's overwrite logic from finding
the existing asset on rebuild.
2026-03-23 15:53:34 +07:00
Mohamed Boudra
19452c2742 fix(release): add android retry tags and ban workflow_dispatch for rebuilds
workflow_dispatch checks out the tag ref, not main — so build fixes
on main never get picked up. Always use retry tags instead.
2026-03-23 15:49:38 +07:00
Mohamed Boudra
956828fa55 Fix terminal stream stalls after resize 2026-03-23 15:34:07 +07:00
Mohamed Boudra
b873523eab fix(terminal): skip resize snapshots and standardize key encoding 2026-03-23 14:47:27 +07:00
Mohamed Boudra
b862f7b72d fix(desktop): guard invalid badge count payloads 2026-03-23 14:42:17 +07:00
Mohamed Boudra
af0f629073 fix(website): update download links to match Electron asset filenames
The Electron migration changed the release asset naming convention from
Tauri's underscore format (Paseo_VERSION_ARCH.ext) to Electron's hyphen
format (Paseo-VERSION-arch.ext).
2026-03-23 14:36:31 +07:00
Mohamed Boudra
a4f433195d fix(terminal): only send resize from the focused client
Multiple clients viewing the same terminal would fight over PTY size
because every client sent resizes independently. Gate resize sends on
three focus levels: pane focus (split panes), screen focus (navigation),
and app visibility (browser window / mobile foreground). When a client
regains focus, clear the last reported size and trigger a reflow to
reclaim the PTY dimensions.
2026-03-23 13:31:07 +07:00
Mohamed Boudra
12ea75ab65 fix(app): add tooltip with shortcut hint to source control explorer toggle
The git checkout mode explorer button was missing the tooltip that
the non-git and mobile variants already had via HeaderToggleButton.
2026-03-23 13:25:41 +07:00
Mohamed Boudra
a6f24508e8 docs: add 0.1.32 release notes 2026-03-23 13:12:25 +07:00
Mohamed Boudra
d562fd40c0 Merge branch 'terminal-multiplexing-slot' 2026-03-23 13:05:20 +07:00
Mohamed Boudra
cded2c52ab feat(app): add focus mode (Cmd+Shift+F) to show only the active pane
Hides ScreenHeader, LeftSidebar, and ExplorerSidebar on desktop,
replacing the split layout with the single focused pane. Respects
macOS traffic light padding in the tab bar when active. Shortcut
is restricted to workspace screens. Also nudges traffic light
buttons up 3px globally.
2026-03-23 13:05:07 +07:00
Mohamed Boudra
df99442123 Fix terminal slot review issues 2026-03-23 12:44:18 +07:00
Mohamed Boudra
b9b1601bb8 fix(terminal): suppress DA response feedback loop after closing vim
Client-side xterm.js was generating Device Attributes responses via
onData, which fed back to the PTY as visible text. Register CSI handlers
to consume query responses on the client and respond to DA1 on the server.
2026-03-23 12:43:21 +07:00
Mohamed Boudra
5efac0dfaf fix(app): match sidebar diff stat colors to workspace header
Use theme palette colors instead of hardcoded muted hex values.
2026-03-23 12:36:54 +07:00
Mohamed Boudra
ea41dbc8e7 Add multiplexed terminal stream slots 2026-03-23 12:36:35 +07:00
Mohamed Boudra
c9ca1697e5 fix(server): free finalized message text in TimelineAssembler
TimelineAssembler.messages map retained full assistantText and
reasoningText for every message for the lifetime of the session.
Replace with a lightweight finalizedMessageIds set that prevents
duplicate emission during history replay without holding the text.
2026-03-23 12:29:23 +07:00
Mohamed Boudra
ed3ed78ec3 feat(app): add xterm addons and improve daemon memory reporting 2026-03-23 12:06:21 +07:00
Mohamed Boudra
37aaa7b155 feat(app): keep workspace tabs alive in panes 2026-03-23 12:03:02 +07:00
Mohamed Boudra
4b4e55a246 fix(workspace): suppress pane focus when moving tabs between panes 2026-03-23 11:30:35 +07:00
Mohamed Boudra
d6977352aa feat(terminal): capture cursor presentation modes (style, blink, hidden) 2026-03-23 10:58:39 +07:00
Mohamed Boudra
87145f1e6a Stabilize workspace stream render boundaries 2026-03-23 10:45:48 +07:00
Mohamed Boudra
7f9dafab68 refactor(cli): deduplicate command options between top-level and agent subcommands
Extract addXxxOptions() builder functions for all 9 shared commands
(ls, run, attach, logs, stop, delete, send, inspect, wait) so both
paseo <cmd> and paseo agent <cmd> use a single source of truth.

Fixes drift where agent subcommands were missing --wait-timeout (run),
--worktree/--base/--image (run), --since (logs), and --image (send).
2026-03-23 10:33:06 +07:00
Mohamed Boudra
07720f1d99 feat(keyboard): add Cmd+./Ctrl+. shortcut to toggle both sidebars
Reassign Cmd+. (Mac) and Ctrl+. (Win/Linux) from toggling the left
sidebar to toggling both sidebars at once. If either is open, both
close; if both are closed, both open. Left sidebar toggle on non-Mac
now uses Ctrl+B to match the Mac Cmd+B binding.
2026-03-23 10:06:38 +07:00
Mohamed Boudra
c0e63f76da feat(terminal): add shift+enter support and force resize on focus 2026-03-23 00:03:09 +07:00
Mohamed Boudra
93f4b84b8b Add terminal prod-ready e2e coverage 2026-03-22 23:17:26 +07:00
Mohamed Boudra
804d5d2487 Reorder status bar: permission trigger after thinking trigger 2026-03-22 23:13:48 +07:00
Mohamed Boudra
babc724220 Align terminal stream with snapshot-only attach 2026-03-22 22:49:05 +07:00
Mohamed Boudra
138c774297 Remove terminal output buffer leftovers 2026-03-22 22:31:57 +07:00
Mohamed Boudra
50ea5a76fb Simplify terminal streaming protocol 2026-03-22 22:31:23 +07:00
Mohamed Boudra
54282409bb refactor(sidebar): restore workspace name font weight to 400 2026-03-22 21:36:15 +07:00
Mohamed Boudra
f5b66f4745 test(agent-manager): add e2e tests for mode-switch update propagation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:12:53 +00:00
Mohamed Boudra
907074a454 fix(agent-manager): add missing touchUpdatedAt before emitState calls
Several emitState() calls were missing a preceding touchUpdatedAt(),
causing agent state updates to be silently dropped by the bootstrap
dedup logic in session.ts (which drops updates where
updateUpdatedAt <= snapshotUpdatedAt).

This caused two user-visible bugs:
- Permission mode switches not reflected in UI until next message
- Agent stuck showing "running" when it had transitioned to idle

Added touchUpdatedAt() to: setAgentMode, setAgentModel,
setAgentThinkingOption, setLabels, streamAgent.finalize,
replacement error fallback, and cancelAgentRun permission clear.
2026-03-22 21:08:36 +07:00
Mohamed Boudra
1f764d418c refactor(sidebar): mute diff stats and PR badge colors, add state label
- Reduce diff stat number brightness with muted green/red
- PR badge uses foregroundMuted by default, foreground on hover
- Show PR state label (Open/Merged/Closed) next to number
- Show external link icon on hover
2026-03-22 21:07:43 +07:00
Mohamed Boudra
e2248015aa refactor(sidebar): improve dropdown animations and spacing layout 2026-03-22 20:12:01 +07:00
Mohamed Boudra
1e5b809b70 refactor(sidebar): use status dot overlay on icons instead of replacing them
Sidebar workspace and project rows now keep their icon (Monitor/FolderGit2
for workspaces, project icon/initial for projects) and overlay a small
colored status dot on the bottom-right, matching the tab icon pattern.
Loading and syncing states still replace the icon entirely.
2026-03-22 19:45:26 +07:00
Mohamed Boudra
d79ea8b15c fix(sidebar): fix PR hint for fork PRs, remove badge styling, add hover effect
- Replace REST API PR lookup with `gh pr view` which handles fork PRs
- Fix state comparison to use lowercase (matching server output)
- Remove pill/badge styling, show plain icon + #number
- Add brighter hover colors and underline for clickability hint
2026-03-22 19:07:01 +07:00
Mohamed Boudra
c60ea67a9b Merge branch 'feat/sidebar-pr-hint' 2026-03-22 18:52:49 +07:00
Mohamed Boudra
4819011c97 feat(app): show keyboard shortcut for archive-worktree action 2026-03-22 18:52:20 +07:00
Mohamed Boudra
9be754cdbf fix(desktop): filter --no-sandbox from CLI arg parsing on Linux
The Linux wrapper injects --no-sandbox for Electron sandboxing, but
the CLI parser was treating it as an unknown Paseo option. Filter it
out alongside the existing macOS -psn_ prefix.
2026-03-22 18:52:01 +07:00
Mohamed Boudra
35ed62ad0f feat(sidebar): add PR status hint badge to workspace rows 2026-03-22 18:51:40 +07:00
Mohamed Boudra
d705d68f2f fix(desktop): simplify expo module build to plain tsc
Replace the cross-platform wrapper script with a direct tsc call.
expo-module build is just tsc + conditional --watch; tsc alone is
one-shot and cross-platform without needing env var hacks.
2026-03-22 18:19:50 +07:00
Mohamed Boudra
43a1f46047 refactor(shortcuts): rename section to "Shortcuts" and split into groups
Replace the flat "global" section with navigation, tabs-panes, projects,
and panels groups. Rename "Keyboard Shortcuts" to "Shortcuts" in settings
nav, section titles, and dialog.
2026-03-22 18:19:38 +07:00
Mohamed Boudra
1911946ed1 fix(desktop): make expo module build cross-platform 2026-03-22 18:02:26 +07:00
Mohamed Boudra
1572c95b4d Merge branch 'feat/chord-keyboard-shortcuts' 2026-03-22 17:44:51 +07:00
Mohamed Boudra
cc80d05e49 refactor(shortcuts): simplify chord display and clean up capture UI 2026-03-22 17:44:49 +07:00
Mohamed Boudra
83b681cebb fix(desktop): harden packaged launcher and desktop build 2026-03-22 17:38:10 +07:00
Mohamed Boudra
57f8c7615c Add chord keyboard shortcuts and staged capture UI 2026-03-22 16:28:09 +07:00
Mohamed Boudra
d9bcf2a439 dev(server): add heap snapshot and inspect flags to dev runner 2026-03-22 16:12:36 +07:00
Mohamed Boudra
051868c74c fix(app): remove terminal padding and fetch catch-up timeline on mobile resume 2026-03-22 15:45:47 +07:00
Mohamed Boudra
ffee4a472a Add keyboard shortcuts for worktree actions and dynamic shortcut display
Replace all hardcoded shortcut key rendering with useShortcutKeys() so
rebound shortcuts always display correctly. Add Cmd+O (new worktree),
Cmd+Shift+Backspace (archive worktree) with proper active-project
scoping. Show shortcut hints in tooltips and dropdown menus. Fix tooltip
alignment to center, bump dropdown menu surface level, simplify
TerminalPane to single-terminal, and add special key display symbols.
2026-03-22 15:43:45 +07:00
Mohamed Boudra
00a2eec5f6 Fix Linux AppImage sandbox startup 2026-03-22 01:16:38 +07:00
Mohamed Boudra
859d234543 Add no-sandbox switch for Linux alongside chrome-sandbox removal
Both are needed: afterPack removes chrome-sandbox to prevent SUID fatal,
appendSwitch handles Ubuntu 24.04+ AppArmor user namespace restrictions.
2026-03-22 01:12:01 +07:00
Mohamed Boudra
207bdd1fae desktop: remove chrome-sandbox from linux builds 2026-03-22 01:10:05 +07:00
Mohamed Boudra
d102a8fd64 fix Linux AppImage sandbox 2026-03-22 00:47:40 +07:00
Mohamed Boudra
fcbdb09796 Add resizable left sidebar on desktop
Mirror the right sidebar's resize pattern: Gesture.Pan() drag handle
on the right edge, animated width, persisted to panel store.
2026-03-21 23:44:40 +07:00
Mohamed Boudra
59f3f4737a Split diff horizontal scroll into platform-specific components
Use Metro .web.tsx resolution to separate native gesture arbitration
(RNGH ScrollView, waitFor, closeGestureRef) from web (plain RN ScrollView),
fixing text selection drag on desktop web.
2026-03-21 23:32:38 +07:00
Mohamed Boudra
c80c3e0256 Extract line number gutter width calculation and improve text selection 2026-03-21 23:15:52 +07:00
Mohamed Boudra
ad1cb4c200 Add keyboard shortcut rebinding with string-based format and sectioned settings UI
- Parse/format shortcut strings ("Cmd+Shift+O") bidirectionally via shortcut-string.ts
- Convert all 60+ bindings from structured KeyCombo objects to declarative strings
- Add keyboard shortcut overrides storage (AsyncStorage + React Query)
- Wire overrides into the keyboard shortcut resolution pipeline
- Add sectioned settings layout with sidebar on desktop, single scroll on mobile
- Add keyboard shortcuts settings section with rebind/reset UI and key capture
- Create useShortcutKeys hook so tooltip shortcut displays update on rebind
- Replace hardcoded shortcut displays in agent-input-area, left-sidebar, command center
2026-03-21 23:14:59 +07:00
Mohamed Boudra
68ab7b81a1 Remove cleanup-assets job from desktop release workflow
Assets should be overwritten by publish jobs, not deleted upfront.
The cleanup job was wiping macOS/Linux assets on platform-specific retries.
2026-03-21 22:34:56 +07:00
Mohamed Boudra
f1cf4ebe60 Fix Windows desktop build: split workspace deps from metro-patched export
The NODE_OPTIONS --require patch for metro config was being applied
during build:workspace-deps, causing expo-module-build (a bash script)
to be loaded through Node's JS loader on Windows.
2026-03-21 22:31:59 +07:00
Mohamed Boudra
0a452fad04 Sort Claude model catalog from most to least powerful
Reorder: Opus 4.6 → Sonnet 4.6 → Sonnet 4.5 → Haiku 4.5
2026-03-21 22:26:30 +07:00
Mohamed Boudra
eb618638d2 Use useMutation for worktree creation with loading spinner on mobile
Move worktree creation into a useMutation in ProjectHeaderRow so each
row owns its own pending state. Show ActivityIndicator on the + button
while the request is in flight, and always show the + button on mobile
breakpoints where hover isn't available.
2026-03-21 22:26:17 +07:00
Mohamed Boudra
7b8a2fbcef Fix deleted diff syntax highlighting 2026-03-21 22:25:53 +07:00
Mohamed Boudra
928169a4f2 Add highlight package to build:daemon pipeline and add build:highlight script 2026-03-21 21:01:14 +07:00
Mohamed Boudra
0af666e3ac Dismiss keyboard before opening mobile tab switcher sheet 2026-03-21 20:47:42 +07:00
Mohamed Boudra
d41808efe1 fix(app): clear composer immediately on submit 2026-03-21 20:47:33 +07:00
Mohamed Boudra
aec28c8b67 Fix mobile diff mode trigger clipping 2026-03-21 20:47:02 +07:00
Mohamed Boudra
3b5daa1451 Fix agent preferences sheet restoring tab switcher on close
Use stackBehavior="replace" on the preferences BottomSheetModal so it
dismisses the tab switcher combobox instead of minimizing it, preventing
the provider from auto-restoring the tab switcher when preferences closes.
2026-03-21 20:46:44 +07:00
Mohamed Boudra
f6579dbeb4 Switch to double quotes and reformat codebase with Biome 2026-03-21 20:22:15 +07:00
Mohamed Boudra
ab40786882 Remove freezeOnBlur from workspace and agent screens 2026-03-21 20:21:57 +07:00
Mohamed Boudra
5f065c2685 Extract isomorphic @getpaseo/highlight package with syntax-highlighted file preview
- Create packages/highlight with shared Lezer-based syntax highlighting (types, parsers, highlighter, color maps)
- Add syntax highlighting and line numbers to file-pane.tsx with memoized CodeLine component
- Import shared highlight colors in git-diff-pane.tsx instead of local definitions
- Delete duplicated syntax-highlighter.ts from both app and server
- Fix TSX dialect to "ts jsx" (correct per Lezer docs)
2026-03-21 19:56:49 +07:00
Mohamed Boudra
6b50ace2bd Refactor agent input draft persistence 2026-03-21 19:56:23 +07:00
Mohamed Boudra
c46c03158e Simplify Claude agent session flow 2026-03-21 19:47:56 +07:00
Mohamed Boudra
7395fc6fd1 fix: interrupt scaffold drain and desktop CLI bin layout 2026-03-21 18:37:51 +07:00
Mohamed Boudra
c35d649f6d refactor(app): rename agents route to sessions with improved list layout 2026-03-21 16:20:17 +07:00
Mohamed Boudra
60e833245d fix(desktop): add cleanup-assets job to delete stale release assets before rebuild 2026-03-21 15:56:01 +07:00
Mohamed Boudra
f424503234 fix(app): skip agent list refresh when screen is unfocused 2026-03-21 15:47:43 +07:00
Mohamed Boudra
1d42542514 refactor: extract isInteractiveDesktopDragTarget and add debug logging 2026-03-21 15:44:49 +07:00
Mohamed Boudra
d1314a4d5d Merge branch 'feat/unit-1-workspace-tab-store-navigation'
# Conflicts:
#	packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
#	packages/app/src/screens/workspace/workspace-agent-visibility.test.ts
#	packages/app/src/screens/workspace/workspace-agent-visibility.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
2026-03-21 15:27:35 +07:00
Mohamed Boudra
48c3308c31 refactor: move workspace tab navigation logic to prepareWorkspaceTab 2026-03-21 15:21:02 +07:00
Mohamed Boudra
ba149330b1 fix(desktop): fix release workflow publish target, signing, and Windows build
- Change electron-builder publish owner from anthropics to getpaseo
- Remove CSC_NAME (auto-discovered from cert, secret had rejected prefix)
- Remove CSC_IDENTITY_AUTO_DISCOVERY=false from build script (breaks Windows cmd.exe)
2026-03-21 15:02:40 +07:00
Mohamed Boudra
6731879931 fix(desktop): guard manual window drag coordinates 2026-03-21 14:58:57 +07:00
Mohamed Boudra
b15159caeb chore(release): cut 0.1.32 2026-03-21 14:47:53 +07:00
Mohamed Boudra
ec8c51a014 chore(release): cut 0.1.31 2026-03-21 14:46:58 +07:00
Mohamed Boudra
84d3aa1962 fix(desktop): update release workflow for Electron migration and add notarization
Fix desktop-release workflow to reference correct package path after
Tauri→Electron migration. Add macOS entitlements and notarization config
for electron-builder.
2026-03-21 14:46:26 +07:00
Mohamed Boudra
0bc693f157 feat(desktop): manual window dragging and agent tab pruning 2026-03-21 14:40:30 +07:00
Mohamed Boudra
5ae7a0c9c2 fix(app): reduce unnecessary workspace screen re-renders from unstable callback deps
- Add useCloseTabs hook to centralize tab close state with stable closeTab callback and reactive closingTabIds
- Replace killTerminalMutation/isArchivingAgent props with closingTabIds Set in split container
- Stabilize archiveAgent callback by depending on mutateAsync instead of whole mutation object
- Add bail-out to focusTabInLayout and focusPaneInLayout when already focused
- Remove redundant isArchivingAgent guard (closeTab handles dedup)
2026-03-21 14:29:55 +07:00
Mohamed Boudra
e6bc752b68 refactor(app): make keyboard shortcuts declarative and separate Cmd/Ctrl per platform
Replace imperative matches/when functions with declarative KeyCombo and
ShortcutWhen data structures, laying groundwork for a future rebinding UI.

Split all isMod (metaKey || ctrlKey) bindings into explicit platform pairs:
- Mac: Cmd (meta) only — Ctrl is never intercepted, passes through to terminal
- Linux/Windows: Ctrl — disabled when terminal is focused so Ctrl+W, Ctrl+K,
  etc. reach vim/shell
2026-03-21 14:29:37 +07:00
Mohamed Boudra
78fc1fe862 docs(server): document mapBlocksToTimeline role-awareness and user/assistant text mapping 2026-03-21 13:32:26 +07:00
Mohamed Boudra
84500cdcae fix(server): make mapBlocksToTimeline role-aware to prevent user text emitting as assistant_message
User messages with array content were routed through mapBlocksToTimeline
which hardcoded all text blocks as assistant_message. This caused user
interrupt text to appear as assistant output in paseo logs and the app UI.

Add textMessageType option so callers declare the role explicitly.
When set to "user_message", text blocks coalesce into a single item
matching extractUserMessageText semantics.
2026-03-21 13:31:11 +07:00
Mohamed Boudra
480af26b57 feat(desktop): add React DevTools extension for Electron dev mode
Downloads and loads React DevTools Chrome extension using Electron 41's
session.extensions API directly, avoiding the deprecated session.loadExtension
used by electron-devtools-installer. Extension is cached in userData after
first download. Only loaded when !app.isPackaged.
2026-03-21 13:05:24 +07:00
Mohamed Boudra
2003f308f3 fix(server): reconcile stale workspaces by pruning missing directories and fully-archived agents
Workspaces whose directories were deleted (e.g. cleaned-up worktrees) and
workspaces where every agent has been archived now get auto-archived during
reconciliation, cascading to project archival when no active siblings remain.

Extracts detectStaleWorkspaces() as a pure function in workspace-registry-model.ts
with standalone unit tests.
2026-03-21 13:04:57 +07:00
Mohamed Boudra
a042fbbe43 Keep explicitly reopened archived agent tabs open 2026-03-21 13:01:23 +07:00
Mohamed Boudra
ee5577c2da fix(app): remove background and border from host selector trigger
Show background only on hover for a cleaner default appearance.
2026-03-21 11:48:59 +07:00
Mohamed Boudra
24945a9498 feat(app): add line numbers, wrap toggle, and icon sizing improvements 2026-03-21 11:47:04 +07:00
Mohamed Boudra
f6f689d570 feat(app): show archived agent callout with unarchive button
When viewing an archived agent, display a callout in place of the input
area that matches the message input styling. The Unarchive button calls
refreshAgent which auto-unarchives server-side.
2026-03-21 11:46:35 +07:00
Mohamed Boudra
7703625e76 feat(app): redesign mobile tab switcher as secondary header row
Move tab switcher from bottom to top (after header), restyle as a
clean pressable row with chevron instead of count badge. Add "New
agent" and "New terminal" to header kebab menu. Fix BottomSheet
portal crash by nesting BottomSheetModalProvider inside QueryProvider.
2026-03-21 11:38:52 +07:00
Mohamed Boudra
17b81fb132 Merge branch 'main' into read-electron-migration-skills
# Conflicts:
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/left-sidebar.tsx
2026-03-21 01:52:25 +07:00
Mohamed Boudra
111576e2ca chore: finalize electron desktop migration 2026-03-21 01:50:00 +07:00
Mohamed Boudra
862cc43db7 feat: stream persisted history and show workspace kind indicators 2026-03-21 00:12:44 +07:00
Mohamed Boudra
48924626df fix(app): reduce workspace screen re-renders and fix archived agent handling
Replace greedy agents Map subscription with a Zustand selector that
derives workspace agent visibility (ID sets only) with custom equality,
so the workspace screen only re-renders when agents are added, removed,
or archived — not on every status/activity update.

Fix tab pruning to check against all known agents (including archived)
instead of only active agents, so archived agent tabs survive
reconciliation. Remove the auto-unarchive effect that called
refreshAgent() as a workaround. Hide input area for archived agents.
2026-03-20 21:07:51 +07:00
Mohamed Boudra
72ea9b7a72 fix(app): replace greedy host runtime subscriptions with targeted hooks
useHostRuntimeSession returned the full HostRuntimeSnapshot, causing
every consumer to rerender on any internal state change (probe cycles,
client generation bumps). Replace with targeted hooks that return
primitives/stable references so useSyncExternalStore skips rerenders
when the subscribed value hasn't actually changed.

New hooks: useHostRuntimeClient, useHostRuntimeConnectionStatus,
useHostRuntimeLastError, useHostRuntimeAgentDirectoryStatus,
useHostRuntimeIsDirectoryLoading. Existing useHostRuntimeIsConnected
already followed this pattern.

useHostRuntimeSnapshot kept only for settings-screen where probe
data is legitimately needed.
2026-03-20 19:16:06 +07:00
Mohamed Boudra
e0f9b33d23 refactor(app): drive agent panel focus from panes 2026-03-20 18:46:23 +07:00
Mohamed Boudra
c7fe944e73 Extract mobile sidebar and gesture wrappers 2026-03-20 18:44:57 +07:00
Mohamed Boudra
584f5ce05e refactor(app): extract agent panel content 2026-03-20 18:34:52 +07:00
Mohamed Boudra
614c085310 fix(app): reduce unnecessary re-renders in sidebar and workspace screens
- Gate WorkspaceScreen on useIsFocused to prevent background rendering
- Stabilize SidebarAnimationProvider context with useCallback/useMemo
- Wrap LeftSidebar in memo and memoize gesture/styles
- Extract inline style objects in AppContainer to stable references
2026-03-20 18:26:36 +07:00
Mohamed Boudra
866aeb8ad9 fix(app): extract favicon sync and add freezeOnBlur to screens 2026-03-20 17:29:46 +07:00
Mohamed Boudra
23aaecd99a fix(server): include actual error details in DaemonRpcError messages
The catch-all handler was replacing real error messages with "Request
failed", and DaemonRpcError only showed the error string without
requestType or code context.
2026-03-20 17:22:17 +07:00
Mohamed Boudra
77fb74c188 feat(app): redirect to next workspace after archiving 2026-03-20 13:08:28 +07:00
Mohamed Boudra
70fb5f5bfd fix(app): add worklet directives to working indicator functions
Functions called inside useAnimatedStyle must be marked as worklets.
Missing directives caused a native crash on Android with newer
react-native-reanimated/worklets that enforce UI-thread safety.
2026-03-20 12:19:22 +07:00
Mohamed Boudra
26c07b671f Fix archived workspace session routing\n\nCloses #128 2026-03-20 12:17:50 +07:00
Mohamed Boudra
c9f2e01131 desktop: fix AppImage patch step using absolute paths
The patch step cd's to a temp directory for extraction, so the
AppImage path must be absolute (via $GITHUB_WORKSPACE).
2026-03-20 00:41:41 +07:00
Mohamed Boudra
f4f3e4204d desktop: patch AppImage for Wayland compatibility
The linuxdeploy-plugin-gtk hook forces GDK_BACKEND=x11, which
prevents GTK initialization on Wayland-only systems. The bundled
libgdk-3.so already has Wayland support built in.

Add a post-build step that extracts the AppImage, comments out the
GDK_BACKEND=x11 line, and repackages with appimagetool.
2026-03-20 00:28:34 +07:00
Mohamed Boudra
d7d1e2d169 desktop: enable key repeat on macOS 2026-03-19 23:47:33 +07:00
Mohamed Boudra
6ab97c579e Simplify workspace creation with inline worktree API
Replace the multi-step new-agent route flow with a single
create_paseo_worktree endpoint that registers the workspace immediately
and creates the git worktree in the background. The sidebar now calls
this endpoint directly and shows a creating spinner inline.

Also auto-resolves the base branch from origin/HEAD when not explicitly
provided, removes the unused Tauri WebSocket transport layer, and
normalizes loopback endpoints to localhost.

Closes #125
2026-03-19 23:33:35 +07:00
Mohamed Boudra
3d4ac57bd0 Refactor project opening and add status bar tooltips 2026-03-19 19:04:01 +07:00
Mohamed Boudra
efb3df8233 fix desktop notification handling on macos 2026-03-19 16:43:34 +07:00
Mohamed Boudra
a952112910 Refactor agent sync toasts into panel host 2026-03-19 16:43:14 +07:00
Mohamed Boudra
20fa1a3a3b Fix workspace tab presentation hook ordering 2026-03-19 15:45:29 +07:00
Mohamed Boudra
7609ccee4c feat: expand diff syntax highlighting languages 2026-03-19 15:44:04 +07:00
Mohamed Boudra
9065dcef54 Fix daemon startup blocking on model downloads 2026-03-19 14:03:49 +07:00
Mohamed Boudra
4ab307b10f Add fetch tool details and improve sidebar shortcuts handling 2026-03-19 13:04:44 +07:00
Mohamed Boudra
675d825f6b chore(release): cut 0.1.30 2026-03-19 08:59:23 +07:00
Mohamed Boudra
1328e0cc05 docs(changelog): add 0.1.30 release notes 2026-03-19 08:58:33 +07:00
Mohamed Boudra
f0ba64f23b Add combined model selector and refactor working indicator 2026-03-19 08:55:40 +07:00
Mohamed Boudra
22c27dd583 Fix UI spacing and sizing in status bar and message input 2026-03-18 23:13:19 +07:00
Mohamed Boudra
35da664a09 Add agent mode visuals (icons and color tiers) to UI components 2026-03-18 22:54:09 +07:00
Mohamed Boudra
1323cb13c4 Add agent mode visuals (icons and color tiers) to UI components 2026-03-18 22:31:31 +07:00
Mohamed Boudra
3287f2d00b Only show bg on expanded file headers in git diff panel 2026-03-18 22:07:34 +07:00
Mohamed Boudra
d15a1451b7 Track active workspace from navigation state 2026-03-18 21:50:24 +07:00
Mohamed Boudra
9d370a18b8 Clean up git diff panel file header styles 2026-03-18 21:50:09 +07:00
Mohamed Boudra
865e25b3a9 Fix workspace tab drag hit area 2026-03-18 21:11:09 +07:00
Mohamed Boudra
82ab598426 Fix workspace route state syncing 2026-03-18 21:11:00 +07:00
Mohamed Boudra
09fae62888 Tighten terminal scrollbar gutter 2026-03-18 21:10:51 +07:00
Mohamed Boudra
7e0a220fde feat: improve terminal scrollbar and pane focus handling 2026-03-18 20:39:16 +07:00
Mohamed Boudra
ed9a15c0bd feat: add tab drop preview indicator for split pane drag-and-drop 2026-03-18 20:09:28 +07:00
Mohamed Boudra
b763d4358e feat: add terminal tabs and split pane controls to workspace UI 2026-03-18 19:55:04 +07:00
Mohamed Boudra
5f2d4ac122 chore(release): sync 0.1.29 versions 2026-03-18 19:47:30 +07:00
Mohamed Boudra
3bdc90a661 refactor: simplify tab layout to use equal widths with ideal clamping 2026-03-18 16:59:03 +07:00
Mohamed Boudra
b5212a69c9 refactor: improve split pane drag-and-drop and resize handle UX 2026-03-18 16:19:43 +07:00
Mohamed Boudra
0bc903fa21 Fix agent spawn PATH resolution and add provider binary checks to status
Shell env PATH was being overridden by process.env PATH in
applyProviderEnv, causing agent spawns to fail with ENOENT when the
daemon runs from the Tauri desktop app (minimal GUI PATH). Flip the
merge order so login shell env wins.

Add a Providers section to `paseo daemon status` that resolves each
agent binary (claude, codex, opencode) and runs --version using the
same applyProviderEnv environment the daemon uses to spawn agents.
2026-03-18 10:08:48 +07:00
Mohamed Boudra
e2f20f0e24 refactor: extract workspace layout actions and pane state logic 2026-03-18 09:39:12 +07:00
Mohamed Boudra
30a225ce9f Add split-pane layout store and navigation shortcuts 2026-03-18 01:36:54 +07:00
Mohamed Boudra
30dd54a318 refactor: extract panel registry and pane context from workspace screen
Replace hardcoded renderContent() switch and per-kind descriptor logic
with a registry-based panel interface. Each panel type (agent, draft,
terminal, file) now self-registers with its component, useDescriptor
hook, and optional confirmClose. Panels access workspace context via
usePaneContext() instead of prop drilling. This prepares the workspace
screen for split pane support.
2026-03-17 21:58:11 +07:00
Mohamed Boudra
abc8ad3fd4 refactor: migrate workspace tab actions to keyboard action dispatcher 2026-03-17 20:19:41 +07:00
Mohamed Boudra
4897627943 Fix Tauri tab close confirm reentry 2026-03-17 16:14:18 +07:00
Mohamed Boudra
18533ef52b style(app): add padding to terminal emulator 2026-03-17 14:51:17 +07:00
Mohamed Boudra
faa9c9c491 fix(app): start explorer panel closed and narrower on clean start 2026-03-17 14:20:32 +07:00
Mohamed Boudra
11f6494c20 fix: route Claude partial tool input through canonical mapping (#108) (#122)
* fix: show loading skeleton for pending tool call details

* fix: surface claude tool previews before full json

* fix: route claude partial tool input through canonical mapping

* fix: remove pending tool-call summary placeholder
2026-03-17 14:42:39 +08:00
Mohamed Boudra
9364da3414 fix(website): fix horizontal scroll on mobile
The APK tooltip with whitespace-nowrap and centered positioning
overflowed past the viewport on narrow screens. Anchor it to the
right on mobile, center it on sm+. Also stack nav logo and menu
vertically on mobile.
2026-03-16 18:44:31 +07:00
Mohamed Boudra
ed5bc3091e fix(app): shorten paths in project picker and close sidebar on workspace press 2026-03-15 20:16:41 +07:00
Mohamed Boudra
8ff51eb176 docs(release): add guide for retrying failed release builds 2026-03-15 14:34:15 +07:00
Mohamed Boudra
90fb5e1f33 fix(app): build workspace audio module during EAS install 2026-03-15 14:28:30 +07:00
Mohamed Boudra
cda08ec033 feat(website): add og:title and og:description via pageMeta helper
Extract a DRY pageMeta(title, description) helper that returns both
standard and OpenGraph meta tags from a single source of truth.
2026-03-15 14:28:10 +07:00
Mohamed Boudra
21c585abec chore(website): update OG image 2026-03-15 14:23:40 +07:00
Mohamed Boudra
a1d0492d8d feat(website): add OG image for social sharing
Copy og-image.png to public assets and add OpenGraph and Twitter Card
meta tags to the root layout so link previews work across all pages.
2026-03-15 14:15:51 +07:00
Mohamed Boudra
745f2e2768 chore(release): cut 0.1.28 2026-03-15 13:53:47 +07:00
Mohamed Boudra
ef55fecfe2 docs(changelog): prepare 0.1.28 release notes 2026-03-15 13:53:03 +07:00
Mohamed Boudra
cc675da49c Stabilize git action menu 2026-03-15 13:51:04 +07:00
Mohamed Boudra
a9d5502fad fix(app): route assistant file links to workspace files 2026-03-15 13:50:52 +07:00
Mohamed Boudra
849f49b622 fix: settings screen design tweaks for mobile
- Center settings cog icon vertically in host card
- Normalize icon sizes (relay/host/settings all use iconSize.sm)
- Hide theme picker labels on mobile (icon-only)
- Simplify audio test copy, remove verbose description
2026-03-15 13:50:50 +07:00
Mohamed Boudra
d89f1ea663 fix: align mobile workspace header icons with desktop
Use same explorer toggle icons (SourceControlPanelIcon/PanelRight) on
mobile as desktop, use vertical kebab icon for mobile header menu.
2026-03-15 13:50:42 +07:00
Mohamed Boudra
5701543746 refactor: move startup logic to app-support, improve welcome screen UI 2026-03-15 12:52:29 +07:00
Mohamed Boudra
57db31c662 feat(website): update hero copy, add sponsor CTA section
Update hero title to "All your coding agents, from anywhere" with
responsive line break. Add sponsor CTA section with personal messaging.
Update meta title for SEO. Fix GitHub sponsors link.
2026-03-15 12:07:14 +07:00
Mohamed Boudra
eadca4a96b Remove fix-tests script 2026-03-15 11:27:23 +07:00
Mohamed Boudra
4a111099c4 docs: fix license to AGPL-3.0 in README 2026-03-15 11:25:35 +07:00
Mohamed Boudra
6f34699e4b Add OpenCode build and plan modes 2026-03-15 11:17:23 +07:00
Mohamed Boudra
aa6c521394 feat(website): add SEO landing pages for Claude Code, Codex, and OpenCode
Extract homepage into reusable LandingPage component with configurable
hero. Add /claude-code, /codex, and /opencode routes targeting
agent-specific mobile app search terms. Replace single-link footer
with four-column layout (Product, Agents, Community, Download).
2026-03-15 11:16:28 +07:00
Mohamed Boudra
e80db346da fix(deploy): force production deployment on Cloudflare Pages
Add --branch main to wrangler pages deploy so tag-triggered deploys
go to production instead of preview. Add app-v* tag for retriggering.
2026-03-15 11:08:53 +07:00
Mohamed Boudra
26c7f3f5f3 docs: add GitHub releases link to getting started 2026-03-15 10:57:56 +07:00
Mohamed Boudra
0057ee7b72 docs: update README to match current homepage messaging
- Lead with desktop app download instead of CLI install
- Move CLI install under headless/server mode section
- Remove early development warning
- Add feature highlights from homepage
2026-03-15 10:56:12 +07:00
Mohamed Boudra
0a3e55734b docs(skill): document --wait-timeout option and clarify wait behavior 2026-03-15 10:47:07 +07:00
Mohamed Boudra
0b88383b01 chore: remove stale e2e specs and fix-tests scripts
Delete all 34 Playwright e2e specs — they test against an outdated UI
and will be rewritten from scratch with a clean DSL-based approach.
Remove scripts/fix-tests/ overnight loop infrastructure.
2026-03-15 10:45:36 +07:00
Mohamed Boudra
a364edde17 chore: update fix-tests prompts with known issues and pre-existing failures 2026-03-15 10:41:09 +07:00
Mohamed Boudra
22c6df3f85 feat(cli): add --wait-timeout option to run command
Replace hardcoded 10-minute timeout with configurable --wait-timeout.
Default is no limit, matching the wait command behavior. Extracts
parseDuration to shared utils so both run and wait can use it.

Fixes OUTPUT_SCHEMA_FAILED errors when agents take >10 minutes.
2026-03-15 10:41:06 +07:00
Mohamed Boudra
93a3a742ac Merge branch 'quality-gate-verify-tests-standards' 2026-03-15 10:40:20 +07:00
Mohamed Boudra
0abca5e0c7 fix(server): clean up test suite — remove mocks, delete redundant tests, fix races
- Remove vi.mock() from provider-launch-config.test.ts, use dependency injection instead
- Delete codex-app-server-agent.test.ts integration infrastructure (1900 lines) —
  real codex coverage lives in daemon e2e tests (agent-basics, permissions-codex, etc.)
- Delete claude-agent-commands.test.ts — redundant with daemon e2e suite
- Delete 16-agent-update.test.ts — tested removed functionality
- Fix getAvailablePort race condition with port 0 in bootstrap.ts
- Extract large integration test blocks from unit test files into daemon e2e
- Clean up speech-runtime TTS manager test to use real dependencies
- Net -2258 lines across 17 files, all tests passing
2026-03-15 10:30:44 +07:00
Mohamed Boudra
f275474fec Serialize workspace registry persists 2026-03-14 22:48:46 +07:00
Mohamed Boudra
f12dc6127b feat: add --prompt and --prompt-file options to agent send command 2026-03-14 22:38:13 +07:00
Mohamed Boudra
e526f375c8 Merge branch 'fix-tests-suite-loop'
# Conflicts:
#	package-lock.json
#	packages/server/src/server/speech/speech-config-resolver.ts
#	packages/server/src/shared/tool-call-display.ts
2026-03-14 22:06:42 +07:00
Mohamed Boudra
3ae1102d13 feat: add agent ID/name resolution and provider/model syntax support 2026-03-14 22:04:09 +07:00
Mohamed Boudra
d1632d80e3 refactor(voice): defer speech confirmation and add diagnostics 2026-03-14 20:05:48 +07:00
Mohamed Boudra
cad4110e24 feat: add expo-two-way-audio module to workspace 2026-03-14 16:46:35 +07:00
Mohamed Boudra
fc7369384b feat(voice): implement turn-detection and real-time audio streaming 2026-03-14 13:46:10 +07:00
Mohamed Boudra
c45b414476 chore(release): cut 0.1.27 2026-03-13 21:09:06 +07:00
Mohamed Boudra
73bd0c1dba docs: add 0.1.27 changelog 2026-03-13 21:08:53 +07:00
Mohamed Boudra
502dd2d2e2 refactor(app): remove debug logging from voice and UI components 2026-03-13 21:07:18 +07:00
Mohamed Boudra
80a2f8052c refactor(app): replace voice hooks with voice runtime and audio engine architecture 2026-03-13 20:53:55 +07:00
Mohamed Boudra
a495138374 refactor: simplify connection selection with linear scan 2026-03-13 13:44:25 +07:00
Mohamed Boudra
7f69166e97 refactor: extract detail display logic into helper functions 2026-03-13 13:44:25 +07:00
Mohamed Boudra
2268790656 refactor: consolidate speech provider config resolution logic 2026-03-13 13:44:25 +07:00
Mohamed Boudra
3a02d1cdd6 refactor: extract createShortcutTarget and simplify shortcut logic 2026-03-13 13:44:25 +07:00
Mohamed Boudra
2e560677e7 fix(tests): iteration 9 2026-03-13 13:44:25 +07:00
Mohamed Boudra
b4a6b098a4 fix(tests): iteration 8 2026-03-13 13:44:25 +07:00
Mohamed Boudra
12f8c0db96 fix(tests): iteration 7 2026-03-13 13:44:25 +07:00
Mohamed Boudra
d39c95e0e7 fix(tests): iteration 6 2026-03-13 13:44:25 +07:00
Mohamed Boudra
f74031b50d fix(tests): iteration 4 2026-03-13 13:44:25 +07:00
Mohamed Boudra
8e67f5a707 fix(tests): iteration 1 2026-03-13 13:44:03 +07:00
Mohamed Boudra
a194a901b4 test: remove unimplemented worktree archival tests and unused code 2026-03-13 13:44:03 +07:00
Mohamed Boudra
049edbfcaf fix(e2e): parametrize reply line count and improve scroll behavior 2026-03-13 13:43:34 +07:00
Mohamed Boudra
7c18616160 fix(tests): iteration 20 2026-03-13 13:43:34 +07:00
Mohamed Boudra
70191ceaa8 fix(tests): iteration 19 2026-03-13 13:43:34 +07:00
Mohamed Boudra
99bac0fd27 fix(tests): iteration 18 2026-03-13 13:43:34 +07:00
Mohamed Boudra
e8b55eccab fix(tests): iteration 16 2026-03-13 13:43:34 +07:00
Mohamed Boudra
40ebd1b730 fix(tests): iteration 15 2026-03-13 13:43:34 +07:00
Mohamed Boudra
b5102e7d7e fix(tests): iteration 14 2026-03-13 13:42:51 +07:00
Mohamed Boudra
f3036ad2c9 fix(tests): iteration 13 2026-03-13 13:42:51 +07:00
Mohamed Boudra
4a01b9ec90 fix(tests): iteration 12 2026-03-13 13:42:51 +07:00
Mohamed Boudra
2f13806a2c fix(tests): iteration 11 2026-03-13 13:42:51 +07:00
Mohamed Boudra
5f5599a1a8 fix(tests): iteration 10 2026-03-13 13:42:51 +07:00
Mohamed Boudra
f2680563bd fix(tests): iteration 9 2026-03-13 13:42:51 +07:00
Mohamed Boudra
79d658243d fix(tests): iteration 8 2026-03-13 13:42:51 +07:00
Mohamed Boudra
3994fa65c0 fix(tests): iteration 7 2026-03-13 13:42:51 +07:00
Mohamed Boudra
10671cf999 fix(tests): iteration 6 2026-03-13 13:42:51 +07:00
Mohamed Boudra
40b9b4672e fix(tests): iteration 5 2026-03-13 13:42:51 +07:00
Mohamed Boudra
44fc4bfeff fix(tests): iteration 3 2026-03-13 13:42:51 +07:00
Mohamed Boudra
402f3e32ae fix(tests): iteration 2 2026-03-13 13:42:51 +07:00
Mohamed Boudra
436a595839 fix(tests): iteration 1 2026-03-13 13:42:40 +07:00
Mohamed Boudra
8a360281c6 Add test automation loop and CI secrets for test fixes 2026-03-13 13:42:08 +07:00
Mohamed Boudra
730f34753a fix(app): use platform-specific components for markdown links 2026-03-13 13:27:24 +07:00
Mohamed Boudra
013f573cff refactor: migrate to shell-env and custom desktop notifications bridge 2026-03-13 12:26:14 +07:00
Mohamed Boudra
ee8a81d2e1 feat(server): support Grep tool in Claude tool-call mapping 2026-03-13 10:32:33 +07:00
Mohamed Boudra
bed03be332 feat(app): support file:// protocol URLs for opening workspace files 2026-03-13 10:24:01 +07:00
Mohamed Boudra
debe4b543b fix(desktop): resolve linux appimage cli resources 2026-03-13 09:58:01 +07:00
Mohamed Boudra
1c601e09b6 fix(release): repair linux appimage symlinks 2026-03-13 09:38:59 +07:00
Mohamed Boudra
ba54aae206 fix(server): pin sherpa runtime deps 2026-03-13 08:48:28 +07:00
Mohamed Boudra
36961f83bf fix(release): recover desktop r8 failures 2026-03-12 23:49:07 +07:00
Mohamed Boudra
6db99c03cb feat(app): allow agents to open workspace files from chat 2026-03-12 23:43:59 +07:00
Mohamed Boudra
999d100464 fix(release): recover desktop runtime packaging 2026-03-12 23:33:50 +07:00
Mohamed Boudra
9190d86148 feat(app): improve image picker, markdown rendering, and UI interactions 2026-03-12 23:29:26 +07:00
Mohamed Boudra
4b5cb5a3e2 fix(release): recover desktop packaging artifacts 2026-03-12 23:20:26 +07:00
Mohamed Boudra
847e1d2c60 fix(app): avoid metro exclusionList breakage on windows 2026-03-12 23:06:36 +07:00
Mohamed Boudra
970df6478b fix(app): make metro blocklist regex windows-safe 2026-03-12 22:56:46 +07:00
Mohamed Boudra
3371f17606 fix(release): add manual linux appimage fallback 2026-03-12 22:55:18 +07:00
Mohamed Boudra
0f65fe894c fix(release): preload metro patch inline on windows 2026-03-12 22:52:15 +07:00
Mohamed Boudra
2ac27a3e3e fix(release): support iterative desktop recovery tags 2026-03-12 22:48:26 +07:00
Mohamed Boudra
defcc54af2 fix(release): harden desktop recovery workflows 2026-03-12 22:41:34 +07:00
Mohamed Boudra
fe84c5c9a3 Match explorer files pane sidebar background 2026-03-12 22:34:42 +07:00
Mohamed Boudra
2ff8041e15 Fix hidden sidebar workspace shortcuts 2026-03-12 22:33:27 +07:00
Mohamed Boudra
1d3e551e7f fix(release): recover desktop release builds 2026-03-12 22:26:44 +07:00
Mohamed Boudra
79795b6f49 Update outdated Paseo skill CLI options 2026-03-12 22:26:10 +07:00
Mohamed Boudra
16da3300e3 fix: prevent Codex replacement stream from being killed by stale turn_completed notification
The turn_completed notification handler called eventQueue.end(), which
could terminate a replacement stream's queue if the old interrupted
turn's completion arrived after the new stream had set up its queue.
The streamInternal loop already breaks on terminal events, making the
end() call redundant and unsafe during replaceAgentRun.
2026-03-12 22:24:41 +07:00
Mohamed Boudra
69d8427bd9 chore(release): cut 0.1.26 2026-03-12 21:34:33 +07:00
Mohamed Boudra
d8ccbd5c32 docs: update CHANGELOG for 0.1.26 2026-03-12 21:34:22 +07:00
Mohamed Boudra
be93f8b240 refactor: remove performance monitoring and diagnostics infrastructure 2026-03-12 21:15:01 +07:00
Mohamed Boudra
b09a731d85 refactor: replace welcome message with server_info status payload
Unify the initial connection handshake and capability update paths.
The server now sends a server_info status message on connect and
whenever capabilities change, enabling the onboarding flow where
voice becomes available after models are configured.
2026-03-12 20:38:03 +07:00
Mohamed Boudra
554017b0b8 refactor: move daemon registry into host-runtime store 2026-03-12 19:02:06 +07:00
Mohamed Boudra
9f089be946 refactor: rename sockPath to listen in pid lock, add startup timing instrumentation 2026-03-12 14:19:21 +07:00
Mohamed Boudra
213f155c9c fix(desktop): fix Claude agent spawn from managed runtime and rotate logs on restart
- Always override spawnClaudeCodeProcess to use process.execPath instead of
  SDK's PATH-based "node" lookup, which fails in the managed runtime bundle
- Fix append-mode arg ordering in resolveClaudeSpawnCommand so extra CLI args
  (e.g. --chrome) go after cli.js, not before it (Node exit code 9)
- Rotate daemon.log on every daemon restart for clean startup logs
- Remove dead dev_resource_root fallback from runtime_manager.rs
- Canonicalize current_exe path in CLI runner
2026-03-12 13:16:08 +07:00
Mohamed Boudra
9e6b45e2f0 refactor(cli): remove daemon update command and simplify status runtime info 2026-03-12 11:19:33 +07:00
Mohamed Boudra
355e56db53 fix(server): add trace logging for Codex app server spawn 2026-03-12 10:51:31 +07:00
Mohamed Boudra
a79134ec9e feat: add single-instance support, Android APK download, and splash screen styling 2026-03-12 10:42:40 +07:00
Mohamed Boudra
91dde29146 feat(server): bundle Codex and OpenCode binaries instead of requiring global installs 2026-03-12 10:25:53 +07:00
Mohamed Boudra
586b48e150 Update files 2026-03-11 23:13:36 +07:00
Mohamed Boudra
aeefa22ddf fix: hide chrome on home route 2026-03-11 21:34:16 +07:00
Mohamed Boudra
9f3ef07322 docs: extract guidance into dedicated docs, streamline CLAUDE.md 2026-03-11 21:23:02 +07:00
Mohamed Boudra
e2cb67462d refactor(cli): extract common command options into reusable helpers 2026-03-11 21:08:08 +07:00
Mohamed Boudra
b60d253926 fix: update metro exclusionList import for Expo compatibility 2026-03-11 21:01:31 +07:00
Mohamed Boudra
ee156adffb Merge remote-tracking branch 'origin/improve-startup' 2026-03-11 20:10:11 +07:00
Mohamed Boudra
c99b78f5b0 Handle noisy shell output in executable lookup 2026-03-11 19:05:44 +07:00
Mohamed Boudra
4c6d21af4a refactor(desktop): simplify managed runtime by removing state file and delegating to CLI daemon status 2026-03-11 19:01:49 +07:00
Mohamed Boudra
327b315610 Suppress console windows on Windows and use ~/.paseo as default home
Add CREATE_NO_WINDOW flag to all Windows process spawns to prevent
visible console windows. Change Windows default managed home from
AppData\Roaming to ~/.paseo for consistency with macOS and server.
2026-03-11 15:52:07 +07:00
Mohamed Boudra
78849fa0bf Strip \\?\ extended-length prefix from Windows resource paths
Node.js module resolver can't handle the \\?\ prefix that Tauri's
resource_dir() returns on Windows, causing EISDIR errors. Use dunce
to simplify paths before passing them to Node.
2026-03-11 14:51:47 +07:00
Mohamed Boudra
e5014a5f57 Add Discord link to website navigation and changelog 2026-03-11 14:21:23 +07:00
Mohamed Boudra
5bf698ff84 Add Windows support and improve cross-platform shell execution 2026-03-11 14:14:06 +07:00
Mohamed Boudra
eb5f011161 Add screen orientation polyfill, refactor provider launch config, and update desktop runtime manager
- Add .claude schedule tasks to .gitignore
- Add screen-orientation polyfill for Expo web
- Refactor agent provider launch config into shared utility
- Update desktop runtime manager with improved binary management
- Update desktop release workflow
2026-03-11 14:01:07 +07:00
Mohamed Boudra
acfb933ee8 Update CHANGELOG for 0.1.25 2026-03-11 12:29:39 +07:00
Mohamed Boudra
c37684b246 Preserve entitlements when re-signing managed runtime binaries
The sign script was re-signing Mach-O executables with --force and
hardened runtime but without --entitlements, stripping entitlements
like allow-jit that Node.js needs for V8. This caused SIGTRAP on
any Mac where the binary went through Gatekeeper validation.

Extract existing entitlements before re-signing and pass them back
via --entitlements so they are preserved.
2026-03-11 11:36:21 +07:00
Mohamed Boudra
e2068e3d72 chore(release): cut 0.1.25 2026-03-11 11:09:56 +07:00
Mohamed Boudra
443eb16e67 Notarize macOS DMG to fix quarantine on bundled binaries
Tauri notarizes the .app but not the .dmg container. When users
download the DMG from GitHub Releases, macOS quarantines everything
and Gatekeeper doesn't clear quarantine on embedded helper binaries
(like the bundled Node runtime), causing SIGTRAP on first launch.

Add a post-build step that signs, notarizes, and staples the DMG,
then re-uploads it to the release.
2026-03-11 11:09:41 +07:00
Mohamed Boudra
240dc26013 Restore AppImage bundle for Linux (revert deb workaround)
The linuxdeploy failure was caused by CUDA shared library references in
onnxruntime-node, not by linuxdeploy itself. The CUDA stripping step
added in the previous commit fixes the root cause, so AppImage bundling
should work now.
2026-03-11 09:07:44 +07:00
Mohamed Boudra
6b07555a46 Switch Linux bundle from appimage to deb
linuxdeploy-plugin-appimage's "continuous" release on GitHub is broken,
causing every AppImage build to fail. The downloaded binary is actually
an HTML error page. Switch to deb format which doesn't depend on
linuxdeploy at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:21:09 +07:00
Mohamed Boudra
b69bd5271b Fix Linux AppImage build: add APPIMAGE_EXTRACT_AND_RUN=1
linuxdeploy is an AppImage itself and needs FUSE to run. GitHub Actions
runners don't always have working FUSE support. Setting this env var
tells AppImage tools to extract-and-run instead, avoiding the FUSE
dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:07:48 +07:00
Mohamed Boudra
cf4cae2c7d Fix Linux AppImage: strip CUDA deps from onnxruntime binaries
linuxdeploy scans all ELF binaries in the AppDir and fails when it
can't find libcublasLt.so.12 (a CUDA library referenced by the
onnxruntime native module). Use patchelf to remove these optional
CUDA dependencies since we only need CPU inference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:59:36 +07:00
Mohamed Boudra
d51f18a2f7 Add workflow step to strip CUDA providers before Linux AppImage build
The build script fix only applies to future tags. For v0.1.24 (and any
tag built before that fix), we need the workflow itself to remove the
CUDA .so files after building the managed runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:54 +07:00
Mohamed Boudra
006db65f08 Remove CUDA/TensorRT providers from onnxruntime-node in managed runtime
linuxdeploy scans all ELF files in the AppDir and fails when it finds
libonnxruntime_providers_cuda.so which links to libcublasLt.so.12 — a
CUDA library not available on CI runners.

onnxruntime falls back to the CPU provider when CUDA is absent, so
removing these has no functional impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:44:32 +07:00
Mohamed Boudra
f21221c1e1 Add libfuse2 to Linux AppImage build dependencies
linuxdeploy is distributed as an AppImage and may need libfuse2 to
execute even with APPIMAGE_EXTRACT_AND_RUN=1. ubuntu-22.04 runners
don't have it by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:26:11 +07:00
Mohamed Boudra
9faa88e13b Add --verbose to Linux AppImage build for debugging
Need to see the actual linuxdeploy error output instead of the opaque
"failed to run linuxdeploy" message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:16:34 +07:00
Mohamed Boudra
faf1eed0ab Fix Linux AppImage build: pin ubuntu-22.04 and disable strip
ubuntu-latest switched to 24.04 which has libraries with .relr.dyn
sections that linuxdeploy's bundled eu-strip cannot handle, causing
consistent "failed to run linuxdeploy" errors.

Pin to ubuntu-22.04 (also better glibc compat for AppImage) and set
NO_STRIP=true as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:06:38 +07:00
Mohamed Boudra
8fc37eac52 Update CHANGELOG for v0.1.24
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:24:06 +07:00
Mohamed Boudra
9e76d1c2d6 Use --no-bundle for desktop smoke builds
Smoke tags have non-numeric pre-release identifiers (e.g. gha-smoke.1)
which MSI bundler rejects. Since smoke builds only need to prove Rust
compilation succeeds, skip bundling entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:01:49 +07:00
Mohamed Boudra
5609c89517 Simplify desktop release pipeline: single build, no process smoke test
Replace the 855-line managed-daemon-smoke.mjs (which spawned relay
servers, daemons, and tested E2E connectivity in CI) with a fast
validate-managed-runtime.mjs that checks the bundle is correctly
assembled without launching any processes.

Structural changes:
- Eliminate double-build: removed the pre-build step that compiled the
  Tauri app just for smoke testing before tauri-action rebuilt it
- Move version-setting before the build so there's no version confusion
- Sign managed runtime before tauri-action build (macOS)
- Smoke tags now do a real tauri build instead of --no-bundle, giving
  actual signal about whether the release would succeed
- Reduce each platform job from ~15 steps to ~12

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:43:06 +07:00
Mohamed Boudra
4e4a751921 Improve command center keyboard navigation and new tab shortcut 2026-03-10 20:32:22 +07:00
Mohamed Boudra
6b4978b428 Clean up Windows smoke relay shutdown 2026-03-10 20:22:07 +07:00
Mohamed Boudra
e1f4e6fafb Fix Windows smoke relay launch 2026-03-10 19:43:30 +07:00
Mohamed Boudra
f09b43eef0 Tighten Windows desktop smoke loop 2026-03-10 19:22:54 +07:00
Mohamed Boudra
2813f35eb1 Relax Windows smoke app build failures 2026-03-10 18:49:10 +07:00
Mohamed Boudra
90dfe36e3e Speed up Windows desktop smoke 2026-03-10 18:27:02 +07:00
Mohamed Boudra
7588c1791b Remove accidental release notes 2026-03-10 18:23:43 +07:00
Mohamed Boudra
bfa7f65c3d chore(release): cut 0.1.24 2026-03-10 18:12:16 +07:00
Mohamed Boudra
51bbebcdd5 Fix Windows smoke npm invocation 2026-03-10 18:12:09 +07:00
Mohamed Boudra
7b4ca8394b chore(release): cut 0.1.23 2026-03-10 18:01:55 +07:00
Mohamed Boudra
438a9f6d48 Fix Windows smoke path resolution 2026-03-10 18:01:39 +07:00
Mohamed Boudra
9604b8d57b chore(release): cut 0.1.22 2026-03-10 17:44:28 +07:00
Mohamed Boudra
2a0b0b9109 Fix Windows runtime packaging 2026-03-10 17:44:16 +07:00
Mohamed Boudra
f3338ee824 chore(release): cut 0.1.21 2026-03-10 17:25:20 +07:00
Mohamed Boudra
e73d40b260 Fix release follow-up issues 2026-03-10 17:25:03 +07:00
Mohamed Boudra
89a25276a5 chore(release): cut 0.1.20 2026-03-10 17:09:13 +07:00
Mohamed Boudra
244eed8696 Finalize release content 2026-03-10 17:08:25 +07:00
Mohamed Boudra
dce7316931 Skip duplicate smoke artifact rebuilds 2026-03-10 16:45:34 +07:00
Mohamed Boudra
d69addaad2 Relax relay startup timeout in desktop smoke 2026-03-10 16:03:33 +07:00
Mohamed Boudra
845cf68d38 Refactor git actions and update React to 19.1.4 2026-03-10 15:49:33 +07:00
Mohamed Boudra
2b17aa1a1d Skip GitHub release publishing for smoke tags 2026-03-10 15:23:53 +07:00
Mohamed Boudra
d32c196fd5 Cache desktop release dependencies 2026-03-10 15:06:09 +07:00
Mohamed Boudra
a0266e29e3 Avoid notarization in macOS smoke prebuild 2026-03-10 14:54:26 +07:00
Mohamed Boudra
752d29c146 Prebuild macOS smoke app in CI 2026-03-10 14:38:24 +07:00
Mohamed Boudra
36660b3cc1 Import Apple cert before runtime signing 2026-03-10 13:59:36 +07:00
Mohamed Boudra
bf355aaaf3 Sign macOS managed runtime artifacts 2026-03-10 13:39:58 +07:00
Mohamed Boudra
98d91fd696 Instrument desktop smoke hangs 2026-03-10 13:07:10 +07:00
Mohamed Boudra
8dfc866d40 Enhance CLI section with bash syntax highlighting and updated examples 2026-03-10 13:04:58 +07:00
Mohamed Boudra
15e9569157 refactor: extract stream render model and segment-based rendering 2026-03-10 12:56:18 +07:00
Mohamed Boudra
483dd7cb6d Use supported Intel macOS runner 2026-03-10 12:40:03 +07:00
Mohamed Boudra
6a0e48c10c Fix desktop smoke managedHome references 2026-03-10 12:35:13 +07:00
Mohamed Boudra
7a4be5233c Harden desktop smoke bootstrap check in CI 2026-03-10 12:19:16 +07:00
Mohamed Boudra
965704da20 refactor: extract settings styles and improve badge/button UI 2026-03-10 12:17:37 +07:00
Mohamed Boudra
f760255d50 Avoid macOS CI CLI shim prompt in smoke test 2026-03-10 11:49:35 +07:00
Mohamed Boudra
f3acdedfb1 Fix desktop release runtime bundling 2026-03-10 11:41:49 +07:00
Mohamed Boudra
cc45c3772f feat: add multi-platform downloads and improve homepage animations 2026-03-10 11:20:17 +07:00
Mohamed Boudra
a3e271a1e7 fix: use freshest comparison base for git status and shortstat 2026-03-09 17:42:27 +07:00
Mohamed Boudra
7b22fc5c3f refactor: extract Claude binary lookup into separate function 2026-03-09 16:49:00 +07:00
Mohamed Boudra
a15b52efc8 feat: add diff stats and archive actions to workspace sidebar 2026-03-09 16:38:36 +07:00
Mohamed Boudra
7f11b93e0f fix: add missing Rust imports for Windows build and check in pending changes
Add `use std:#️⃣:{DefaultHasher, Hash, Hasher}` behind #[cfg(windows)]
in runtime_manager.rs — these types are used in hash_seed() which only
compiles on Windows, causing CI failure.

Also includes: app component refactors (agent-list, agent-status-bar,
stream-strategy-web), website index updates, server dep additions
(fast-uri, rotating-file-stream sort), lockfile sync, and removal of
RUNTIME_SIMPLIFICATION_PLAN.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:09:48 +07:00
Mohamed Boudra
02d74777b2 fix(ci): regenerate lockfile for cross-platform optional deps
npm/cli#4828 caused package-lock.json to prune platform variants
not matching the local OS. Regenerated from scratch so Windows CI
gets @tauri-apps/cli-win32-x64-msvc and lightningcss-win32-x64-msvc.

Removed the lightningcss Windows install workaround from desktop
workflow. Removed deprecated asyncRequireModulePath from metro config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:42:35 +07:00
Mohamed Boudra
cf148ba3af fix(ci): stabilize platform-scoped desktop job gating 2026-03-09 14:47:39 +07:00
Mohamed Boudra
19b6aaa2f3 fix(ci): add platform-scoped desktop retry tags 2026-03-09 13:40:50 +07:00
Mohamed Boudra
97737be91c fix(ci): install lightningcss for windows desktop builds 2026-03-09 13:15:45 +07:00
Mohamed Boudra
e3d7dabb87 fix(ci): support desktop release retries 2026-03-09 12:58:41 +07:00
Mohamed Boudra
a90a7f454c chore(release): cut 0.1.19 2026-03-09 11:41:44 +07:00
Mohamed Boudra
8a60dc30d6 feat(release): add draft GitHub release flow 2026-03-09 11:41:21 +07:00
Mohamed Boudra
06f8722f25 Split stream rendering into platform-specific strategies 2026-03-09 11:13:57 +07:00
Mohamed Boudra
e3552f6365 Merge branch 'managed-daemon-bundling' 2026-03-08 20:48:39 +07:00
Mohamed Boudra
7c6eb2ad74 Add detailed logging to bottom anchor controller and scroll strategy 2026-03-08 20:48:37 +07:00
Mohamed Boudra
2721ce331a Simplify managed runtime to execute in place from app bundle 2026-03-08 20:48:13 +07:00
Mohamed Boudra
ca787271b3 Support per-arch desktop builds and prune runtime artifacts 2026-03-08 17:06:05 +07:00
Mohamed Boudra
87948e956a refactor keyboard shortcut tests into table-driven suites 2026-03-08 16:36:05 +07:00
Mohamed Boudra
1e5e0f625d Clarify desktop daemon wording and helper text 2026-03-08 16:05:28 +07:00
Mohamed Boudra
6f7b3db4fa docs: remove managed CLI plan 2026-03-08 15:46:07 +07:00
Mohamed Boudra
93b5cc530c test: align host runtime connection type 2026-03-08 15:41:04 +07:00
Mohamed Boudra
785124eb9f docs: tighten managed CLI install notes 2026-03-08 15:40:02 +07:00
Mohamed Boudra
2a1ef17107 feat: implement two-shim CLI install with fallback instructions 2026-03-08 15:40:02 +07:00
Mohamed Boudra
2d5d0dcacd Add managed desktop daemon runtime support 2026-03-08 15:40:02 +07:00
Mohamed Boudra
faa5aaab6f Refine agent list attention handling 2026-03-08 15:39:40 +07:00
Mohamed Boudra
03e1915316 Improve agent input placeholder 2026-03-08 14:17:16 +07:00
Mohamed Boudra
b339c5e61d Fix bottom anchoring and Claude wake routing 2026-03-08 12:54:08 +07:00
Mohamed Boudra
0c44e7db80 refactor: prefix distributable skills with paseo- namespace
Rename handoff, committee, loop to paseo-handoff, paseo-committee,
paseo-loop to avoid name collisions with other skill sources.
Symlinked ~/.agents/skills/ and ~/.claude/skills/ to the repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:36:10 +07:00
Josep Lluis Giralt D'Lacoste ( Pep )
870d95f35e fix(test): add missing getRuntimeMetrics mock to MockSession (#92)
The production code calls connection.session.getRuntimeMetrics() when
closing the WebSocket server, but MockSession in the relay-reconnect
tests didn't implement this method, causing all close() calls to throw.
2026-03-07 22:35:18 +08:00
Zi Makki
cfb9784ea9 Fix Android autolinking cache for variant builds (#93) 2026-03-07 22:35:08 +08:00
Mohamed Boudra
484edb1e1e feat: add distributable skills (paseo, handoff, committee, loop)
Package the four core skills into the repo under skills/ so users can
install them via `npx skills add getpaseo/paseo`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:34:39 +07:00
Mohamed Boudra
356faa0563 feat(app): add project picker modal and simplify open project screen 2026-03-07 21:33:13 +07:00
Mohamed Boudra
7d76da3249 Update workspace and app changes 2026-03-07 19:09:00 +07:00
Mohamed Boudra
1d1c7058f1 feat: add agent delete command and bulk tab close operations 2026-03-07 12:32:49 +07:00
Mohamed Boudra
6b51088f39 fix(app): remove initial workspace white flash 2026-03-06 23:55:39 +07:00
Mohamed Boudra
efa6a6aed3 chore(release): cut 0.1.18 2026-03-06 23:48:34 +07:00
Mohamed Boudra
ba054b4dfb docs(changelog): add 0.1.18 notes 2026-03-06 23:48:07 +07:00
Mohamed Boudra
604b41db9b refactor(app): extract project icon placeholder label logic 2026-03-06 23:43:02 +07:00
Zi Makki
8dd94757d7 fix(server): restore auto metadata generation 2026-03-06 17:36:29 +01:00
Mohamed Boudra
a99edcc0b2 feat(app): auto-focus terminal on create/switch, stabilize sidebar ordering for new items 2026-03-06 23:19:12 +07:00
Mohamed Boudra
1985bd6669 feat(server): add Phoenix priv/static to project icon search directories 2026-03-06 23:09:40 +07:00
Mohamed Boudra
674573938a Merge remote-tracking branch 'origin/tool-version-changes' 2026-03-06 22:53:46 +07:00
Mohamed Boudra
7821a8a8af feat(app): add Mod+W close-tab shortcut for desktop, simplify Android build scripts, and use universal DMG download 2026-03-06 22:53:03 +07:00
Zi Makki
226ece2bdd add expo stuff 2026-03-06 16:49:40 +01:00
Zi Makki
cec0b96adb updates to tool versions 2026-03-06 16:49:40 +01:00
Zi Makki
9d64c3e01e adding mise toml file 2026-03-06 16:49:40 +01:00
Mohamed Boudra
bb300fa2f8 fix(app): unblock deploy app timer typings [skip ci] 2026-03-06 22:33:20 +07:00
Mohamed Boudra
8a41c92488 chore(release): cut 0.1.17 2026-03-06 22:25:02 +07:00
Mohamed Boudra
5f76fbb222 docs(changelog): add 0.1.17 release notes 2026-03-06 22:24:40 +07:00
Mohamed Boudra
d512932233 refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic 2026-03-06 22:23:35 +07:00
Josep Lluis Giralt D'Lacoste ( Pep )
cb24822cac build: add universal macOS binary (aarch64 + x86_64) (#90)
Add x86_64-apple-darwin Rust target alongside the existing aarch64 target
and switch Tauri build to --target universal-apple-darwin, producing a single
DMG that runs natively on both Apple Silicon and Intel Macs.
2026-03-06 23:11:15 +08:00
Mohamed Boudra
304d42d969 Merge branch 'draft-status-controls-refactor' 2026-03-06 16:25:11 +07:00
Mohamed Boudra
7f29baf915 refactor: add authoritative revalidation on app resume and reconnect 2026-03-06 16:24:12 +07:00
Mohamed Boudra
74c99a827d Fix workspace tab focus from route open intent 2026-03-06 16:18:19 +07:00
Mohamed Boudra
5b270444d5 refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing 2026-03-06 14:49:06 +07:00
Mohamed Boudra
885b575d14 refactor app draft status controls into input area 2026-03-05 15:07:56 +07:00
Mohamed Boudra
8371ea0c7d refactor: unify workspace tab management and add runtime metrics 2026-03-05 14:21:16 +07:00
Mohamed Boudra
6dc49ccbc7 feat: add workspace and tab navigation keyboard shortcuts 2026-03-05 11:28:02 +07:00
Mohamed Boudra
6f2b967599 refactor: replace tab layout modes with per-tab proportional sizing 2026-03-05 10:22:30 +07:00
Mohamed Boudra
bdfed2db4b app: add testIDs to workspace header and use them in e2e assertions 2026-03-05 09:58:02 +07:00
Mohamed Boudra
21b9743c61 app: use replace for sidebar workspace switches 2026-03-04 22:56:21 +07:00
Mohamed Boudra
73fce89edd app: hard-cut workspace routing to workspace-only URLs 2026-03-04 22:35:30 +07:00
Mohamed Boudra
ee8e2ab691 refactor: improve tab layout sizing and always show close buttons 2026-03-04 21:32:57 +07:00
Mohamed Boudra
26ea214d64 fix: canonicalize workspace tab routes and prevent orphan runs 2026-03-04 17:39:19 +07:00
Mohamed Boudra
7578846c27 refactor: add promoteDraftToAgent store action with tests 2026-03-04 15:37:46 +07:00
Mohamed Boudra
430ad54d77 refactor: use confirmDialog and unique draft agent IDs 2026-03-04 15:28:55 +07:00
Mohamed Boudra
f47ea7afe5 refactor: centralize host route builders and add dedicated new-agent route 2026-03-04 15:09:06 +07:00
Mohamed Boudra
92991ffdb8 refactor: remove draft agent routes and consolidate terminal output into session model 2026-03-04 14:17:39 +07:00
Mohamed Boudra
b21764fd40 feat: update terminal theme without remounting the xterm instance 2026-03-04 13:29:47 +07:00
Mohamed Boudra
4f1783fc76 refactor: improve drag interaction and workspace identity normalization 2026-03-04 12:25:14 +07:00
Mohamed Boudra
b5b4e60916 feat: add explicit drag handles to sidebar lists and refactor workspace tab model 2026-03-04 11:57:22 +07:00
Mohamed Boudra
952ff0dd72 Merge branch 'archive-ui-label-hard-cut-impl'
# Conflicts:
#	packages/app/src/screens/agent/agent-ready-screen.tsx
2026-03-04 10:39:55 +07:00
Mohamed Boudra
111b9d95f4 feat: carry archive UI label hard cut updates 2026-03-04 10:39:28 +07:00
Mohamed Boudra
6de7756952 feat: add workspace-aware notification routing and clean up agent screen header 2026-03-04 10:37:43 +07:00
Mohamed Boudra
3c066f5dd9 feat: refactor sidebar to nested draggable lists with long-press gesture arbitration 2026-03-04 10:27:01 +07:00
Mohamed Boudra
bf1cded8a1 feat: rename sidebar-agent-list to sidebar-workspace-list and emit workspace updates on agent removal 2026-03-04 10:01:11 +07:00
Mohamed Boudra
e9b57a5ac6 feat: simplify tab layout to CSS-driven sizing and throttle terminal state broadcasts 2026-03-03 23:29:48 +07:00
Mohamed Boudra
cb92f39e73 feat: workspace source-of-truth hard cut 2026-03-03 23:28:16 +07:00
Mohamed Boudra
f3796e2ba5 feat: unify workspace routing through tab-based URLs and remove legacy per-type routes 2026-03-03 21:14:15 +07:00
Mohamed Boudra
74ac17f910 feat: workspace-level sidebar shortcuts with shortcut badges and desktop tabs extraction 2026-03-03 20:56:42 +07:00
Mohamed Boudra
73eabd2eea feat: canonical workspace tab IDs with URL routing and draft agent tabs
Rewrite workspace tab management to use stable tab IDs (agent_, terminal_, draft_, file_) with
dedicated /tab/[tabId] routes, replace separate file-tabs store with unified workspace-tabs store,
support multiple concurrent draft agent tabs, and detect text files by content instead of extension allowlist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:11:55 +07:00
Mohamed Boudra
97f0f4b266 docs: add daemon.log location for debugging trace logs 2026-03-03 18:48:56 +07:00
Mohamed Boudra
888af29485 feat: add worktree archiving with context menu and improved logging 2026-03-03 17:35:48 +07:00
Mohamed Boudra
f418f3ab02 feat: add file tabs and improve terminal rendering with WebGL 2026-03-02 20:02:54 +07:00
Mohamed Boudra
92b5f1e963 Merge branch 'main' of github.com:getpaseo/paseo 2026-03-02 15:59:23 +07:00
Mohamed Boudra
223c8d7d0b feat: sortable workspace tabs, tab context menus, and terminal auto-create removal
- Add drag-and-drop tab reordering with persistent tab order
- Add right-click context menu with copy agent ID, copy resume command, close to right
- Switch workspace route encoding from percent-encoding to base64url
- Remove terminal auto-creation on directory listing (explicit create only)
- Add swipe gestures on terminal emulator for mobile sidebar navigation
- Highlight active workspace row in sidebar
2026-03-02 15:59:13 +07:00
Mohamed Boudra
c8cef9bec8 Restore workspace header controls and polish tab management 2026-03-02 12:23:04 +07:00
Mohamed Boudra
01faa3467d feat(daemon): split console/file logging defaults with rotation (#88) 2026-03-02 12:18:30 +07:00
Mohamed Boudra
18d4df339f Add iteration plans for workspace header/layout restore 2026-03-01 23:18:54 +07:00
Mohamed Boudra
16984c138d Fix header toggle aria-expanded on web 2026-03-01 23:15:28 +07:00
Mohamed Boudra
cdf59e8315 Restore workspace header controls and terminal tab close 2026-03-01 22:56:19 +07:00
Mohamed Boudra
c7cbf89f92 Polish workspace header and unify workspace tab creation 2026-03-01 20:45:16 +07:00
Mohamed Boudra
ac1106b259 Polish sidebar drag snapback and workspace rows 2026-03-01 20:45:09 +07:00
Mohamed Boudra
e1067c623b docs: add iteration 2 polish plan 2026-03-01 20:35:27 +07:00
Mohamed Boudra
a8e867dc7b test(server): avoid prompt expansion flake 2026-03-01 18:32:14 +07:00
Mohamed Boudra
080fb74642 feat: projects → workspaces → tabs 2026-03-01 18:22:11 +07:00
Mohamed Boudra
2e201cab09 feat(server): workspace-scoped explorer + worktree createdAt 2026-03-01 16:32:02 +07:00
Mohamed Boudra
ec5b416b8a Improve sidebar agent list selection UI and drag behavior 2026-03-01 15:44:58 +07:00
Mohamed Boudra
2566171b4c Simplify project filter to single selection and persist sidebar order 2026-03-01 11:26:56 +07:00
Mohamed Boudra
3a6a8cfa38 fix(server): add debug logging for Claude query pump raw messages 2026-02-26 22:14:25 +07:00
Mohamed Boudra
6648cb8917 Merge branch 'main' of github.com:getpaseo/paseo 2026-02-26 19:23:36 +07:00
Mohamed Boudra
4ca97c4fdf Improve provider error surfacing and add Sonnet 4.6 2026-02-26 04:03:08 +00:00
Mohamed Boudra
aa2f35656e Add daemon dev scripts and linux Tauri CLI dependency 2026-02-26 03:26:25 +00:00
Mohamed Boudra
c0420ac1f9 Fix mobile terminal tab routing and remount restoration 2026-02-25 20:05:23 +07:00
Mohamed Boudra
91634ca6f2 fix(server): dedupe opencode assistant timeline echoes 2026-02-25 19:53:04 +07:00
Mohamed Boudra
3355f7d8d9 server: hardcode Claude model catalog with runtime IDs 2026-02-25 16:38:06 +07:00
Mohamed Boudra
c4523c70b5 chore: commit all local changes 2026-02-25 13:53:23 +07:00
Mohamed Boudra
a060ae1256 test: harden daemon lifecycle and e2e reliability across app/cli/server 2026-02-25 10:21:46 +07:00
Mohamed Boudra
094ce49d4f fix: make agent metadata title/branch application reliable (#81) 2026-02-24 18:15:31 +07:00
Mohamed Boudra
0832b12608 Show task notifications as synthetic tool calls in chat (#79)
* fix(server): render claude task notifications as synthetic tool calls

* refactor(claude): extract task-notification tool-call mapper

* refactor: drive tool icons from plain_text detail metadata

* refactor(claude): make task-notification parsing schema-first
2026-02-24 18:15:23 +07:00
Mohamed Boudra
9d2ec0e9e1 refactor(app): use strategy-driven stream renderer on web (#80) 2026-02-24 18:15:06 +07:00
Mohamed Boudra
5c773371d6 fix(server): decouple explicit and auto agent title limits (#78) 2026-02-24 11:36:19 +08:00
Mohamed Boudra
3f06063b4b fix(cli): make agent wait unlimited by default 2026-02-23 20:51:42 +07:00
Mohamed Boudra
b167abfda5 fix(app): narrow stream reducer cursor and agent values 2026-02-23 19:34:25 +07:00
Mohamed Boudra
a43008e9c7 Merge branch 'main' of github.com:getpaseo/paseo
# Conflicts:
#	packages/app/src/contexts/session-context.tsx
2026-02-23 19:33:15 +07:00
Mohamed Boudra
a134132418 Fix app stream/timeline ordering desync and init lifecycle race (#73)
* fix(app): enforce canonical stream sequencing and init resolution

* refactor: extract timeline and stream reducers to pure functions
2026-02-23 19:30:48 +07:00
Mohamed Boudra
cbf5621a73 Handle skill tool details and skip synthetic timeline events 2026-02-23 18:06:38 +07:00
Mohamed Boudra
ff4f2eeb56 Merge branch 'fix/janky-scroll-performance' 2026-02-23 15:48:01 +07:00
Mohamed Boudra
560e0f2362 fix: optimize agent stream rendering and timeline cursor updates 2026-02-23 15:47:58 +07:00
Mohamed Boudra
f3ebe0545f refactor: decouple permission state from attention signal 2026-02-23 15:47:08 +07:00
Mohamed Boudra
89a117e1c1 feat(app): show app version on welcome screen 2026-02-23 15:29:04 +07:00
Mohamed Boudra
317d33a9a7 Restore symmetric agent stream spacing and input alignment 2026-02-23 14:49:21 +07:00
Mohamed Boudra
42786956c7 fix(server): reconcile claude runtime model ids against live catalog 2026-02-22 22:37:16 +07:00
Mohamed Boudra
9b6f5e028c Fix reversed edge wheel scroll in agent stream 2026-02-22 21:06:29 +07:00
Mohamed Boudra
84e3140f19 fix: keep optimistic status during agent bootstrap phase 2026-02-22 20:01:05 +07:00
Mohamed Boudra
1974c33971 fix(desktop): rotate tauri updater public key 2026-02-22 19:09:07 +07:00
Mohamed Boudra
3e877c9f24 docs(changelog): add missing 0.1.16 entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:50:08 +07:00
Mohamed Boudra
6c4fcd1486 docs(changelog): rewrite 0.1.16 release notes
Replace jargon-heavy entries with clear, user-facing language.
Add missing CLI wait output improvement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:47:28 +07:00
Mohamed Boudra
fed842e9f1 chore(release): cut 0.1.16 2026-02-22 18:43:55 +07:00
Mohamed Boudra
f4b98d1d81 docs(changelog): add 0.1.16 release notes 2026-02-22 18:43:26 +07:00
Mohamed Boudra
bfea00df78 Fix user-message echo dedupe with canonical message IDs 2026-02-22 18:05:12 +07:00
Mohamed Boudra
0c710b09f5 refactor(app): introduce attachment storage layer and draft lifecycle management
Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots.
2026-02-22 16:22:21 +07:00
Mohamed Boudra
2652bdda2e desktop: make tauri attachments filesystem-only 2026-02-22 15:25:31 +07:00
Mohamed Boudra
6f2944ec7f chore(desktop): configure tauri updater pubkey 2026-02-22 14:43:42 +07:00
Mohamed Boudra
40f376b824 Preserve authoritative agent title across refresh 2026-02-22 14:10:42 +07:00
Mohamed Boudra
8ee9e7274d Polish desktop settings update/local-daemon sections 2026-02-22 13:28:14 +07:00
Mohamed Boudra
44c3ee670f refactor: remove debug logging and simplify event identifier extraction 2026-02-22 13:10:20 +07:00
YK
8bcfde8184 perf(app): reduce streaming rerenders and scroll churn (#67)
Amp-Thread-ID: https://ampcode.com/threads/T-019c793a-d087-762a-882a-8cc11e6101db

Co-authored-by: Ubuntu <ubuntu@dev-yk.tail388b2e.ts.net>
2026-02-22 13:09:49 +07:00
Mohamed Boudra
55b450c8b9 fix(app): stabilize hook order in agent archive flows 2026-02-22 12:37:26 +07:00
Mohamed Boudra
74a9a3c33b refactor: remove permission timeout logic and rely on abort handler (#62) 2026-02-22 12:17:37 +07:00
Mohamed Boudra
1fdad9e2a7 Merge pull request #68 from badri/fix/pin-opencode-sdk-1.2.6
fix: pin @opencode-ai/sdk to 1.2.6 to fix startup crash
2026-02-22 12:11:53 +07:00
Mohamed Boudra
52336425bc Improve wait output with recent activity snapshot 2026-02-22 12:10:38 +07:00
Mohamed Boudra
afc74d67e2 Merge pull request #69 from getpaseo/feat/auto-update-daemon-app
Add desktop app + local daemon update controls in Settings
2026-02-22 12:05:39 +07:00
Mohamed Boudra
88f7763430 polish(app): align desktop updates copy and labeling 2026-02-22 12:05:00 +07:00
Mohamed Boudra
e9e267ff14 Merge pull request #70 from getpaseo/diagnostics/android-hang-cleanup-policy
Centralize perf diagnostics and activity coalescing
2026-02-22 11:57:33 +07:00
Mohamed Boudra
d6b7c2ed2d refactor(app): centralize perf diagnostics and activity coalescing 2026-02-22 11:52:08 +07:00
Mohamed Boudra
18fbd1dfbf docs: remove obsolete daemon session connection architecture specs 2026-02-22 11:50:15 +07:00
Mohamed Boudra
409a3ac5df refactor(app): isolate desktop settings update and permission flows 2026-02-22 11:45:37 +07:00
Mohamed Boudra
ccd42751d2 Harden Claude provider lifecycle and clean temp artifacts 2026-02-22 11:44:56 +07:00
Mohamed Boudra
c1e4b0fcbf server: only emit attention notifications for ui agents 2026-02-22 11:16:57 +07:00
Mohamed Boudra
0d28dae444 feat(app): add tauri web overlay module resolution 2026-02-22 11:12:53 +07:00
Mohamed Boudra
1c83e10c6d style(settings): align updates section with app UI patterns 2026-02-22 10:44:13 +07:00
Mohamed Boudra
231b83dbc2 feat(desktop): add app and local daemon update controls in Settings 2026-02-22 10:40:12 +07:00
Mohamed Boudra
d7e2ab4437 feat: add thinking option support and autonomous run handling 2026-02-21 22:09:48 +07:00
Mohamed Boudra
c3c8103563 refactor: migrate from clientSessionKey to clientId identity 2026-02-21 15:33:29 +07:00
Mohamed Boudra
e906bffbb9 server: add ws hello/welcome message schemas 2026-02-21 11:48:18 +07:00
Mohamed Boudra
c123c98f5f relay: rename encrypted handshake message types 2026-02-21 11:47:26 +07:00
Mohamed Boudra
e6a7c4de42 feat: add delayed toast for history refresh with proper header spacing 2026-02-21 10:35:30 +07:00
Mohamed Boudra
5dae7f854f refactor: remove daemon connections context, use host runtime directly 2026-02-21 08:52:35 +07:00
Mohamed Boudra
0350f00840 feat: implement daemon connectivity updates and relay protocol versioning 2026-02-20 19:27:56 +07:00
Lakshmi Narasimhan
cc7483ea12 fix: pin @opencode-ai/sdk to 1.2.6 to fix startup crash
Versions 1.2.7–1.2.9 have a broken npm publish where compiled files
land in dist/src/ but the package exports map references dist/v2/,
causing ERR_MODULE_NOT_FOUND on startup. Pin to 1.2.6 which has the
correct layout.

Closes #64

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 16:35:22 +05:30
Mohamed Boudra
6dad3212da fix: interrupt running agents before archive 2026-02-20 13:34:06 +07:00
Mohamed Boudra
e22647af62 test: add real multi-provider UI action stress coverage 2026-02-20 13:16:39 +07:00
Mohamed Boudra
d128a32d2f Unify app connection state around host runtime 2026-02-20 10:37:59 +07:00
Mohamed Boudra
6b23509b96 Merge branch 'debug/android-auto-connect-status' 2026-02-19 21:21:00 +07:00
Mohamed Boudra
f8e3204572 refactor: extract host runtime controller for connection management 2026-02-19 21:19:51 +07:00
Mohamed Boudra
603e95de09 refactor(agent-screen): extract state machine for agent loading flow 2026-02-19 21:17:51 +07:00
Mohamed Boudra
bff1a5accc docs(changelog): update 0.1.15 release notes with final features 2026-02-19 13:25:33 +07:00
Mohamed Boudra
5db14ee50c docs: trim changelog to user-facing items and clarify release notes guidelines 2026-02-19 13:13:49 +07:00
Mohamed Boudra
810ac63e21 chore(release): cut 0.1.15 2026-02-19 13:07:40 +07:00
Mohamed Boudra
0f66aa9d67 docs(changelog): add 0.1.15 release notes 2026-02-19 13:07:25 +07:00
Mohamed Boudra
4df517d84b docs: add CHANGELOG update step to release checklist 2026-02-19 13:05:50 +07:00
Mohamed Boudra
e1d51dfec9 chore: bump version to 0.1.14 2026-02-19 13:04:08 +07:00
Mohamed Boudra
7d1122a371 fix(app): redirect archived agent screen to draft 2026-02-19 13:03:53 +07:00
Mohamed Boudra
1473820cc4 app: make draft screen unhandled areas draggable in Tauri 2026-02-19 13:03:15 +07:00
Mohamed Boudra
ff0be38cff Simplify working directory placeholder and empty state messaging 2026-02-19 12:59:48 +07:00
Mohamed Boudra
7bda40bd4e ci: deploy app workflow only on release tags 2026-02-19 12:58:47 +07:00
Mohamed Boudra
fb6b5ca886 Rename sidebar to LeftSidebar and hide empty filters 2026-02-19 12:57:23 +07:00
Mohamed Boudra
8d379a6215 feat(website): redesign homepage get-started as two-step flow
Step 1 installs the daemon, Step 2 shows download options. Replace
iOS/Android text buttons with icon-only Apple and Google Play buttons
that show a "Coming soon" tooltip on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:46:09 +07:00
Mohamed Boudra
d4a9ee482a Fix duplicate /rewind user message and add real e2e regression 2026-02-19 12:32:48 +07:00
Mohamed Boudra
2f98f073ce ci(release): sync github release notes from changelog 2026-02-19 11:01:17 +07:00
Mohamed Boudra
719ae73c7d feat(website): simplify github nav and changelog headings 2026-02-19 10:16:59 +07:00
Mohamed Boudra
dd5218ebdb docs(website): add changelog page and publish v0.1.14 notes 2026-02-19 10:07:03 +07:00
Mohamed Boudra
f1f4afaaa9 chore(release): cut 0.1.14 2026-02-19 09:55:55 +07:00
Mohamed Boudra
b6b0933a39 Add desktop web scrollbar handles to file preview pane 2026-02-19 09:55:08 +07:00
Mohamed Boudra
24a8db5763 refactor: extract explorer open gesture and standardize icon sizes 2026-02-19 09:53:44 +07:00
Mohamed Boudra
08d4cfac11 fix(settings): show daemon version badge only when available 2026-02-19 09:44:43 +07:00
Mohamed Boudra
02bcfcd5af refactor(scrollbar): improve scroll tracking and animation timing 2026-02-19 09:02:14 +07:00
Mohamed Boudra
a1fdeeb7ae fix(dictation): avoid timeout from dangling non-final segments 2026-02-18 21:21:27 +07:00
Mohamed Boudra
bcae95c372 refactor(app): extract theme toggle to SegmentedControl component 2026-02-18 20:38:46 +07:00
Mohamed Boudra
62d69410d3 fix(git): show Sync only when branch diverges from origin 2026-02-18 20:36:24 +07:00
Mohamed Boudra
e8c4ae2024 fix(app): prefill new agent cwd with main repo for worktrees 2026-02-18 20:35:49 +07:00
Mohamed Boudra
e22f183bb2 Fix autocomplete popover stability and workspace file suggestion ranking 2026-02-18 20:32:19 +07:00
Mohamed Boudra
cbdf8ea0bc Improve web scrollbar visibility and grab interaction 2026-02-18 20:31:04 +07:00
Mohamed Boudra
e1bd459bfd fix: simplify git sync button label when on base branch 2026-02-18 19:57:05 +07:00
Mohamed Boudra
f070e86746 refactor: extract keyboard shortcut routing logic to separate module 2026-02-18 19:55:00 +07:00
Mohamed Boudra
e26dcde2c8 fix(server): use parent pid for lock ownership when spawned as child process 2026-02-18 19:40:46 +07:00
Mohamed Boudra
005875de2b Merge pull request #61 from getpaseo/fix/agent-archive-loading-state
Fix archive UX with shared pending state and agent-screen overlay
2026-02-18 18:27:03 +07:00
Mohamed Boudra
dd4cc82ce8 Merge pull request #60 from getpaseo/feature/image-paste-attachment
feat(app): paste images into prompt attachments
2026-02-18 18:26:16 +07:00
Mohamed Boudra
52044e2a7d Merge remote-tracking branch 'origin/main' into pr60-sync
# Conflicts:
#	packages/app/src/components/message-input.tsx
2026-02-18 18:25:30 +07:00
Mohamed Boudra
95eec2b6c0 Merge pull request #59 from getpaseo/implement/custom-scrollbars-web-desktop
feat(app): shared custom overlay scrollbars on desktop web panes
2026-02-18 18:22:37 +07:00
Mohamed Boudra
f2fa7bb79e Merge remote-tracking branch 'origin/main' into pr59-sync
# Conflicts:
#	packages/app/src/components/git-diff-pane.tsx
2026-02-18 18:21:48 +07:00
Mohamed Boudra
b1afb2673d Merge pull request #58 from getpaseo/implement/workspace-file-autocomplete
Add @ workspace file autocomplete for agent chat
2026-02-18 18:20:28 +07:00
Mohamed Boudra
32a0e7e8ef Merge remote-tracking branch 'origin/main' into pr58-sync
# Conflicts:
#	packages/server/src/shared/messages.ts
2026-02-18 18:19:41 +07:00
Mohamed Boudra
b09cb77514 Merge pull request #57 from getpaseo/investigate/git-workflow-ux-improvements
Improve post-ship worktree flow and merged PR handling
2026-02-18 18:18:06 +07:00
Mohamed Boudra
8e02a59a93 Merge pull request #56 from getpaseo/research/claude-rewind-command
Add Claude provider /rewind command support
2026-02-18 18:17:23 +07:00
Mohamed Boudra
4affac9ce9 fix(desktop): improve tauri permissions and settings UX 2026-02-18 18:13:20 +07:00
Mohamed Boudra
229e747244 fix(app): add shared archive pending state across sidebar and agent screen 2026-02-18 18:02:47 +07:00
Mohamed Boudra
69e009990c feat(app): support pasting images into prompt attachments 2026-02-18 17:55:13 +07:00
Mohamed Boudra
5079ca386b feat(app): add shared desktop web overlay scroll handles 2026-02-18 15:10:20 +07:00
Mohamed Boudra
d30bb84741 feat(app): add @ workspace file autocomplete in agent chat 2026-02-18 13:25:55 +07:00
Mohamed Boudra
e36d318001 Improve post-ship worktree UX and merged PR detection 2026-02-18 13:12:36 +07:00
Mohamed Boudra
e88a0c1db6 feat(server): add Claude /rewind command support 2026-02-18 13:08:40 +07:00
Mohamed Boudra
b1955cf74d fix: unify agent attention notification payloads (#55)
* fix: simplify tmux session creation in paseo.json

* fix: unify agent attention notification payloads
2026-02-18 12:28:27 +07:00
Mohamed Boudra
4305a37ec3 Enable explorer sidebar on draft screen after cwd selection (#54)
* fix: simplify tmux session creation in paseo.json

* feat(app): enable draft explorer sidebar from working directory

* chore: align paseo config with main
2026-02-18 12:27:19 +07:00
Mohamed Boudra
96a80036f6 Improve desktop command autocomplete to match combobox behavior (#53)
* feat(app): align command autocomplete with desktop combobox

* refactor(app): extract shared autocomplete UI and command hook

* refactor(app): rename autocomplete hooks to generic naming
2026-02-18 11:58:57 +07:00
Mohamed Boudra
dab08865c7 Merge branch 'main' of github.com:getpaseo/paseo 2026-02-18 11:19:25 +07:00
Mohamed Boudra
483966a05b fix(app): confirm terminal close for running shell commands (#52) 2026-02-18 11:13:48 +07:00
Mohamed Boudra
08a35c9dbf Merge pull request #51 from getpaseo/implement/issue-43
fix: archive worktree when last agent is archived
2026-02-18 11:11:10 +07:00
Mohamed Boudra
d3704456a3 fix(server): archive worktree when last agent is archived 2026-02-18 11:06:57 +07:00
Mohamed Boudra
62e3b214ff fix: simplify tmux session creation in paseo.json 2026-02-18 11:06:22 +07:00
Mohamed Boudra
64b36d0df7 refactor: remove executeCommand in favor of standard run/stream path 2026-02-18 10:57:21 +07:00
Mohamed Boudra
803e31e61c fix(server): hide hidden directories from cwd suggestions 2026-02-18 10:27:22 +07:00
Mohamed Boudra
79b64d3e38 feat: route slash commands through executeCommand in run/stream 2026-02-18 10:03:54 +07:00
Mohamed Boudra
0337204281 ci: remove Desktop from GitHub release title 2026-02-18 10:03:54 +07:00
Mohamed Boudra
2324065e0a Merge pull request #33 from getpaseo/show-image-previews-in-optimistic-messages
Show image previews in optimistic user messages
2026-02-18 10:03:07 +07:00
Mohamed Boudra
d51fd74f55 Merge origin/main into show-image-previews-in-optimistic-messages 2026-02-18 10:02:46 +07:00
Mohamed Boudra
792f5d8d6e Merge pull request #32 from getpaseo/shaky-penguin
Fix worktree archive terminal cleanup and tighten worktree setup
2026-02-18 09:59:02 +07:00
Mohamed Boudra
63b088a646 Merge pull request #45 from getpaseo/fix/terminal-resize-agent-switch-clean
fix: terminal shrinks after switching agents
2026-02-18 09:58:59 +07:00
Mohamed Boudra
fdb3c6830d Merge pull request #48 from getpaseo/feature/commands-in-draft-screen-clean
Make slash commands available in the new-agent draft screen
2026-02-18 09:58:56 +07:00
Mohamed Boudra
ab8ae975f6 Merge pull request #49 from getpaseo/feat/issue-41-tdd-clean
fix: hash worktree root by cwd to prevent path clashes
2026-02-18 09:58:35 +07:00
Mohamed Boudra
ecd276e3d2 refactor(server): route draft command listing through agent manager 2026-02-17 23:08:33 +07:00
Mohamed Boudra
0354c409f1 fix(worktree): hash cwd for collision-safe worktree roots 2026-02-17 23:07:20 +07:00
Mohamed Boudra
6aa5e97d75 feat: make slash commands available in draft agent screen 2026-02-17 23:06:37 +07:00
Mohamed Boudra
8c0d289c2a fix(app): refit terminal when returning to agent 2026-02-17 23:03:11 +07:00
Mohamed Boudra
a5842482b0 chore(release): cut 0.1.13 2026-02-17 21:39:24 +07:00
Mohamed Boudra
9b9535aa78 ci: publish Android APK to GitHub releases 2026-02-17 21:36:15 +07:00
Mohamed Boudra
515cb0a777 refactor: remove legacy agent update RPCs 2026-02-17 21:16:31 +07:00
Mohamed Boudra
0dc944df3a fix(server): pin node-pty beta with macOS spawn-helper fix 2026-02-17 21:07:11 +07:00
Mohamed Boudra
16ea92aec5 fix(app): stop mobile web sidebars from intercepting taps when closed 2026-02-17 20:05:20 +07:00
Mohamed Boudra
93ec537095 chore(release): cut 0.1.12 2026-02-17 17:17:21 +07:00
Mohamed Boudra
9255212043 fix(server): self-heal node-pty spawn-helper execute bit 2026-02-17 17:16:47 +07:00
Mohamed Boudra
a03da6774a chore(release): cut 0.1.11 2026-02-17 16:22:01 +07:00
Mohamed Boudra
7c5bbc8048 chore(release): cut 0.1.10 2026-02-17 16:19:52 +07:00
Mohamed Boudra
76e6ad89d2 fix(server): publish daemon-runner sherpa runtime module 2026-02-17 16:19:38 +07:00
Mohamed Boudra
93d6eb611e chore(release): cut 0.1.9 2026-02-17 14:53:22 +07:00
Mohamed Boudra
ce5d9d6dc8 refactor: unify structured generation pipeline 2026-02-17 14:52:58 +07:00
Mohamed Boudra
abc146b186 chore(release): cut 0.1.8 2026-02-17 13:45:15 +07:00
Mohamed Boudra
4300750001 chore(release): cut 0.1.8 2026-02-17 13:32:13 +07:00
Mohamed Boudra
0af2d8989a fix(web): route tool-detail wheel events at scroll edges 2026-02-17 13:20:49 +07:00
Mohamed Boudra
34e35621f0 simplify speech bootstrap and clean daemon startup locking 2026-02-17 12:16:02 +07:00
Mohamed Boudra
efde557d6f Fix stuck send-while-running processing recovery 2026-02-17 10:43:20 +07:00
Mohamed Boudra
50b99584a1 fix(app): update combobox options behavior and tests 2026-02-17 10:38:31 +07:00
Mohamed Boudra
459af22670 Add cross-platform confirm dialog wrapper for daemon restart 2026-02-17 10:04:58 +07:00
Mohamed Boudra
12ba65d6e7 fix(app): guard tauri listener cleanup to prevent uncaught unlisten errors 2026-02-17 09:43:22 +07:00
Mohamed Boudra
bb6d30e4f8 fix(server): preserve claude session identity on agent reload 2026-02-17 09:43:19 +07:00
Mohamed Boudra
82a5723240 docs: add changelog for releases 0.1.1 through 0.1.7 2026-02-17 09:00:26 +07:00
Mohamed Boudra
949ac8decb website: update hero copy to on your machine 2026-02-17 00:20:40 +07:00
Mohamed Boudra
de99646880 chore(release): cut 0.1.7 2026-02-16 23:19:16 +07:00
Mohamed Boudra
170ee27813 feat: improve agent workspace flows and directory suggestions 2026-02-16 23:19:01 +07:00
Mohamed Boudra
22da0951d5 website: shorten iOS and Android CTA labels 2026-02-16 22:47:54 +07:00
Mohamed Boudra
c371dc92a5 Wire Firebase services files per app variant 2026-02-16 21:21:04 +07:00
Mohamed Boudra
427e3daca9 fix(app): prevent desktop combobox initial 0,0 flash 2026-02-16 18:37:04 +07:00
Mohamed Boudra
f590e60617 Remove uppercase text transform from settings label 2026-02-16 18:17:34 +07:00
Mohamed Boudra
259209b7ef feat: add iOS TestFlight and Android request forms to website 2026-02-16 18:11:25 +07:00
Mohamed Boudra
93111b47ae chore(release): auto-increment android versionCode and fix CLI version output 2026-02-16 12:22:32 +07:00
Mohamed Boudra
f8f4c55976 Show image previews in optimistic user messages 2026-02-16 12:19:38 +07:00
Mohamed Boudra
90a81462cb fix worktree archive terminal cleanup and worktree setup 2026-02-16 11:55:16 +07:00
Mohamed Boudra
7008803800 docs(website): add updates guide for daemon and apps 2026-02-16 11:06:04 +07:00
Mohamed Boudra
52b3001111 docs: simplify root readme for end-user quick start 2026-02-16 10:58:28 +07:00
Mohamed Boudra
6c7ce63272 chore(website): generate Mac download link from package version 2026-02-16 10:55:54 +07:00
Mohamed Boudra
04a06197de refactor(startup): unify dev and cli daemon startup through runner 2026-02-16 03:22:03 +00:00
Mohamed Boudra
5bd6cc11de fix(server): harden sherpa native loading and centralize runtime env 2026-02-16 02:59:12 +00:00
Mohamed Boudra
4313e3623b chore: update desktop download link and remove stray file 2026-02-16 09:15:29 +07:00
1417 changed files with 239781 additions and 66579 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [boudra]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,132 @@
name: Android APK Release
on:
push:
tags:
- "v*"
- "android-v*"
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build (e.g. v0.1.0)"
required: true
type: string
concurrency:
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-android-apk:
permissions:
contents: write
packages: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
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 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"
--generate-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
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Android APK on EAS
id: eas_build
shell: bash
run: |
set -euo pipefail
cd packages/app
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
if [ -z "$build_id" ]; then
echo "Failed to determine EAS build ID."
cat "$RUNNER_TEMP/eas-build.json"
exit 1
fi
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
- name: Resolve APK artifact URL
id: artifact
shell: bash
run: |
set -euo pipefail
cd packages/app
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
if [ -z "$artifact_url" ]; then
echo "Failed to determine APK artifact URL."
cat "$RUNNER_TEMP/eas-build-view.json"
exit 1
fi
asset_name="paseo-${RELEASE_TAG}-android.apk"
asset_path="$RUNNER_TEMP/$asset_name"
curl --fail --location --output "$asset_path" "$artifact_url"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Upload APK to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"

184
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,184 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Check formatting
run: npx biome format .
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck all packages
run: npm run typecheck
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run server tests
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
app-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
relay-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build relay
run: npm run build --workspace=@getpaseo/relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- 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'

View File

@@ -2,11 +2,11 @@ name: Deploy App
on:
push:
branches: [main]
paths:
- 'packages/app/**'
- 'packages/server/src/**'
- '.github/workflows/deploy-app.yml'
tags:
- 'v*'
- '!v*-rc.*'
- 'app-v*'
- '!app-v*-rc.*'
workflow_dispatch:
jobs:
@@ -28,6 +28,9 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app

View File

@@ -9,10 +9,13 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:
deploy:
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
runs-on: ubuntu-latest
steps:

View File

@@ -5,25 +5,85 @@ on:
tags:
- 'v*'
- 'desktop-v*'
- 'desktop-macos-v*'
- 'desktop-linux-v*'
- 'desktop-windows-v*'
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to build (e.g. v0.1.0)'
required: true
type: string
platform:
description: 'Optional desktop platform to build.'
required: false
default: 'all'
type: choice
options:
- all
- macos
- linux
- windows
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
DESKTOP_WORKSPACE: '@getpaseo/desktop'
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
publish-tauri:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
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 metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Create GitHub release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
if [[ "$IS_PRERELEASE" == "true" ]]; then
prerelease_flag="--prerelease"
fi
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
fi
publish-macos:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
electron_arch: arm64
- runner: macos-15-intel
electron_arch: x64
permissions:
contents: write
packages: read
runs-on: macos-latest
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
@@ -31,83 +91,306 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
- name: Set desktop version from tag
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const rawTag = process.env.RELEASE_TAG;
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
console.log(`Using desktop version ${version} from tag ${rawTag}`);
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build and publish Tauri release
uses: tauri-apps/tauri-action@v0
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo Desktop ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: false
prerelease: false
args: --target aarch64-apple-darwin
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
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: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
permissions:
contents: write
packages: read
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
permissions:
contents: write
packages: read
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
registry-url: 'https://npm.pkg.github.com'
scope: '@boudra'
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const version = process.env.DESKTOP_VERSION;
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npx expo export --platform web
working-directory: packages/app
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
run: |
set -euo pipefail
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
fi
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"

45
.github/workflows/fix-nix-hash.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: write
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Fix lockfile and update hash
run: ./scripts/update-nix.sh
- name: Commit changes
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push

59
.github/workflows/nix-build.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
pull_request:
branches: [main]
paths:
- 'nix/**'
- 'flake.nix'
- 'flake.lock'
- 'package.json'
- 'package-lock.json'
- 'packages/highlight/**'
- 'packages/server/**'
- 'packages/relay/**'
- 'packages/cli/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Build Nix package
run: nix build .#default -o result
- name: Verify lockfile is complete
# npm silently omits resolved/integrity fields in workspace monorepos.
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
run: |
node scripts/fix-lockfile.mjs package-lock.json
git diff --exit-code package-lock.json || {
echo "ERROR: package-lock.json has missing resolved/integrity fields."
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
exit 1
}
- name: Check npmDepsHash is up to date
run: ./scripts/update-nix.sh --check

View File

@@ -0,0 +1,67 @@
name: Release Notes Sync
on:
push:
tags:
- "v*"
branches:
- main
paths:
- "CHANGELOG.md"
workflow_dispatch:
inputs:
tag:
description: "Release tag to sync (e.g. v0.1.14). Leave empty to use top changelog entry."
required: false
type: string
create_if_missing:
description: "Create release if missing (normally only needed for tag events)."
required: false
default: false
type: boolean
concurrency:
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
sync-release-notes:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Sync release body from changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
shell: bash
run: |
set -euo pipefail
args=(--repo "$REPO")
if [ -n "${INPUT_TAG:-}" ]; then
args+=(--tag "$INPUT_TAG")
fi
create_if_missing="false"
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
fi
if [ "$create_if_missing" = "true" ]; then
args+=(--create-if-missing)
fi
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"

View File

@@ -36,8 +36,17 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/server
- name: Test
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

9
.gitignore vendored
View File

@@ -46,6 +46,9 @@ test-results/
# Vercel
.vercel/
# Expo
.expo/
# Misc
*.pem
.vercel
@@ -71,6 +74,12 @@ valknut-report.json/
**/.paseo-provider-history/
.claude/settings.local.json
**/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
/artifacts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/

9
.mise.toml Normal file
View File

@@ -0,0 +1,9 @@
[env]
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
_.path = [
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
]
[tools]
java = "17"

View File

@@ -1,21 +0,0 @@
# Dependencies
node_modules
# Build outputs
dist
.next
.expo
build
*.tsbuildinfo
# Coverage
coverage
# Lock files
*.lock
package-lock.json
# Generated
android
ios
.turbo

View File

@@ -1,7 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}

View File

@@ -1,2 +1,4 @@
rust 1.85.1
nodejs 22.20.0
rust 1.85.1
nodejs 22.20.0
java 21
android-sdk latest

641
CHANGELOG.md Normal file
View File

@@ -0,0 +1,641 @@
# Changelog
## 0.1.53 - 2026-04-12
### Added
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
- Middle-click to close tabs on desktop.
- Keyboard shortcut to cycle themes.
### Improved
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
- Streaming markdown on mobile renders significantly faster.
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
- Relay and pairing URLs are stripped from daemon logs.
### Fixed
- Closed agent tabs no longer reappear after reconnecting.
- Desktop notification badge counts match across all workspaces.
- Host switcher status syncs correctly when switching between hosts.
## 0.1.52 - 2026-04-10
### Added
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
### Fixed
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
- Initializing agents no longer show a loading spinner as if they're running.
- Reconnecting to a running agent now works even when session persistence is unavailable.
- Error screens on desktop are now scrollable.
- Model list refreshes in the background when you open the model selector.
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
- Follow-up messages to agents that just finished a turn now work correctly.
- Commands now load properly for Pi agents.
- Internal debug output no longer appears in Claude agent timelines.
- QR scan screen cleaned up with simpler visuals.
## 0.1.50 - 2026-04-07
### Added
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
- Settings shows a Refresh button for providers and displays error details inline.
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
- Restarting an agent quickly could crash the session.
- Copilot no longer pauses for permission prompts in autopilot mode.
- Connection and pairing dialogs now display correctly on tablets.
- Orchestration errors from agents are now surfaced instead of silently lost.
- Diff stats no longer reset to zero when reconnecting.
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
## 0.1.48 - 2026-04-05
### Added
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
### Improved
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
- Removed noisy agent lifecycle toasts that fired on every state change.
### Fixed
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
- Input field on running agent screens now correctly receives keyboard focus.
- Mobile model selector alignment and sizing.
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
- Daemon status dialog in desktop settings for quick health checks.
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
- Pinned archived agents are no longer pruned when closing tabs.
- OpenCode event stream starvation during slash command execution.
- Duplicate workspaces when multiple git worktrees share the same root.
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
- Forward-compatible provider handling so older app clients don't break on new provider types.
- Input event listener race condition in the web scrollbar hook.
- Open-project screen content now vertically centered.
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
- Shell injection, symlink escape, and pairing endpoint security hardening.
## 0.1.43 - 2026-04-02
### Added
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
- Searchable model favorites — quickly find and pin preferred models.
- Slash command support for OpenCode agents.
### Improved
- Refined model selector UX with better mobile sheet behavior.
- Workspace status now uses amber alert styling for "needs input" state.
- Themed scrollbar on message input for consistent styling.
### Fixed
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
- Shell arguments with spaces are now properly quoted on Windows.
- Claude models with 1M context support are now correctly reported.
## 0.1.42 - 2026-04-01
### Fixed
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
## 0.1.41 - 2026-04-01
### Fixed
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
- Fixed context menu for copy/paste across the desktop app.
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1F12).
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
### Improved
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
- File diff tracking in workspaces now works correctly on Linux.
- iPad layout now renders correctly in desktop mode.
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
- The notification test button now surfaces errors instead of failing silently.
## 0.1.32 - 2026-03-23
### Added
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
- Added line numbers and word-wrap toggle to file previews.
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
- Expanded diff syntax highlighting to cover more languages.
- Added status bar tooltips for project and agent status.
### Improved
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
- Desktop key repeat now works correctly on macOS.
- Desktop notifications on macOS are more reliable.
- Daemon startup no longer blocks on model downloads.
- Better error messages from the daemon — RPC errors now include the actual underlying details.
### Fixed
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
- Fixed desktop window drag coordinates being applied when they shouldn't be.
## 0.1.30 - 2026-03-19
### Added
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
- Added a combined model selector and agent mode visuals across key UI surfaces.
- Added Open Graph metadata improvements for richer website sharing previews.
### Improved
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
- Improved project picker path display and general workspace UI polish.
### Fixed
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
## 0.1.28 - 2026-03-15
### Added
- Added OpenCode build and plan modes.
- Added website landing pages for Claude Code, Codex, and OpenCode.
### Improved
- Improved the git action menu for more reliable repository actions.
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
- Updated the website hero copy and added a sponsor callout section.
### Fixed
- Fixed assistant file links so they open the correct workspace files from chat.
## 0.1.27 - 2026-03-13
### Added
- Added voice runtime with new audio engine architecture for voice interactions.
- Added Grep tool support in Claude tool-call mapping.
- Added ability to open workspace files directly from agent chat messages.
- Added desktop notifications via a custom native bridge.
### Improved
- Improved image picker, markdown rendering, and UI interactions.
- Improved shell environment detection using shell-env.
### Fixed
- Fixed platform-specific markdown link rendering.
- Fixed Linux AppImage CLI resource paths.
- Fixed Codex replacement stream being killed by stale turn notifications.
## 0.1.26 - 2026-03-12
### Added
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
- Added Windows support with improved cross-platform shell execution.
### Improved
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
- Added a Discord link to the website navigation.
### Fixed
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
- Fixed the home route to hide browser chrome when appropriate.
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
- Fixed noisy shell output interfering with executable lookup.
- Fixed Windows resource-path handling by stripping the extended-length path prefix.
## 0.1.25 - 2026-03-11
### Fixed
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
## 0.1.24 - 2026-03-10
### Improved
- Improved command center keyboard navigation and new tab shortcut.
- Simplified desktop release pipeline for faster and more reliable builds.
## 0.1.21 - 2026-03-10
### Improved
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
### Fixed
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
## 0.1.20 - 2026-03-10
### Added
- Added workspace sidebar git actions with quick diff stats and archive controls.
- Added refreshed website downloads and homepage presentation for desktop installs.
### Improved
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
### Fixed
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
## 0.1.19 - 2026-03-09
### Added
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
## 0.1.18 - 2026-03-06
### Added
- Added a desktop `Mod+W` shortcut to close the current tab.
### Improved
- New and newly selected terminals now take focus automatically so you can type immediately.
- Kept newly created workspaces and projects in a more stable order in the sidebar.
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
- Updated the website desktop download link to use the universal macOS DMG.
### Fixed
- Restored automatic agent metadata generation for Claude runs.
## 0.1.17 - 2026-03-06
### Added
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
### Improved
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
- More reliable workspace routing and tab identity handling across refreshes and deep links.
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
- Smoother terminal/file rendering and WebGL-backed terminal performance improvements.
- Stronger provider error surfacing and updated Claude model/runtime handling.
### Fixed
- Fixed orphan workspace runs caused by non-canonical tab routes.
- Fixed mobile terminal tab remount/routing restore issues.
- Fixed agent metadata title/branch update reliability.
- Fixed stream/timeline ordering and cursor synchronization issues in the app.
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
## 0.1.16 - 2026-02-22
### Added
- Update the Paseo desktop app and local daemon directly from Settings.
- Microphone and notification permission controls in Settings.
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
- Autonomous run mode — let agents keep working without manual approval at each step.
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
### Improved
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
- Faster agent sidebar list rendering.
- Archiving an agent now stops it first instead of archiving a half-running session.
- Agent titles no longer reset when refreshing.
- More reliable relay connections.
### Fixed
- Fixed Claude background tasks desyncing the chat.
- Fixed duplicate user messages appearing in the timeline.
- Fixed a startup crash caused by an OpenCode SDK update.
- Fixed spurious "needs attention" notifications from background agent activity.
## 0.1.15 - 2026-02-19
### Added
- Added a public changelog page on the website so users can browse release notes.
### Improved
- Redesigned the website get-started experience into a clearer two-step flow.
- Simplified website GitHub navigation and changelog headings.
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
- Hid empty filter groups in the left sidebar.
### Fixed
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
- Fixed duplicate `/rewind` user-message behavior.
## 0.1.14 - 2026-02-19
### Added
- Added Claude `/rewind` command support.
- Added slash command access in the draft agent composer.
- Added `@` workspace file autocomplete in chat prompts.
- Added support for pasting images directly into prompt attachments.
- Added optimistic image previews for pending user message attachments.
- Added shared desktop/web overlay scroll handles, including file preview panes.
### Improved
- Improved worktree flow after shipping, including better merged PR detection.
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
- Improved desktop command autocomplete behavior to match combobox interactions.
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
- Improved desktop settings and permissions UX on desktop.
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
### Fixed
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
- Fixed terminal sizing when switching back to an agent session.
- Fixed accidental terminal closure risk by adding confirmation for running shell commands.
- Fixed archive loading-state consistency across the sidebar and agent screen.
- Fixed autocomplete popover stability and workspace suggestion ranking.
- Fixed dictation timeouts caused by dangling non-final segments.
- Fixed server lock ownership when spawned as a child process by using parent PID ownership.
- Fixed hidden directory leakage in server CWD suggestions.
- Fixed agent attention notification payload consistency across providers.
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
## 0.1.9 - 2026-02-17
### Improved
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
- Reused provider availability checks for structured generation fallback selection.
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
### Fixed
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
## 0.1.8 - 2026-02-17
### Added
- Added a cross-platform confirm dialog flow for daemon restarts.
### Improved
- Simplified local speech bootstrap and daemon startup locking behavior.
- Updated website hero copy to emphasize local execution.
### Fixed
- Fixed stuck "send while running" recovery across app and server session handling.
- Fixed Claude session identity preservation when reloading existing agents.
- Fixed combobox option behavior and related interactions.
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
- Fixed web tool-detail wheel event routing at scroll edges.
## 0.1.7 - 2026-02-16
### Added
- Improved agent workspace flows with better directory suggestions.
- Added iOS TestFlight and Android app access request forms on the website.
### Improved
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
- Improved website app download and update guidance.
### Fixed
- Prevented an initial desktop combobox `0,0` position flash.
- Fixed CLI version output issues.
- Hardened server runtime loading for local speech dependencies.
## 0.1.6 - 2026-02-16
### Notes
- No major visible product changes in this patch release.
## 0.1.5 - 2026-02-16
### Added
- Added terminal reattach support and better worktree terminal handling.
- Added global keyboard shortcut help in the app.
- Added sidebar host filtering and improved agent workflow controls.
### Improved
- Improved worktree setup visibility by streaming setup progress.
- Improved terminal streaming reliability and lifecycle handling.
- Preserved explorer tab state so context survives navigation better.
## 0.1.4 - 2026-02-14
### Added
- Added voice capability status reporting in the client.
- Added background local speech model downloads with runtime gating.
- Added adaptive dictation finish timing based on server-provided budgets.
- Added relay reconnect behavior with grace periods and branch suggestions.
### Improved
- Improved connection selection and agent hydration reliability.
- Improved timeline loading with cursor-based fetch behavior.
- Improved first-run experience by bootstrapping a default localhost connection.
- Improved inline code rendering by auto-linkifying URLs.
### Fixed
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
- Fixed stale relay client timer behavior.
- Fixed unnecessary git diff header auto-scroll on collapse.
## 0.1.3 - 2026-02-12
### Added
- Added CLI onboarding command.
- Added CLI `--output-schema` support for structured agent output.
- Added CLI agent metadata update support for names and labels.
- Added provider availability detection with normalization of legacy default model IDs.
### Improved
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
- Added better voice interrupt handling with a speech-start grace period.
- Improved CLI defaults to list all non-archived agents by default.
- Improved website UX with clearer install CTA and privacy policy access.
### Fixed
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
## 0.1.2 - 2026-02-11
### Notes
- No major visible product changes in this patch release.
## 0.1.1 - 2026-02-11
### Added
- Initial `0.1.x` release line.

19
CI_STATUS.md Normal file
View File

@@ -0,0 +1,19 @@
# CI Test Status
Tracking progress toward all-green CI.
## CI Jobs
| Job | Status | Notes |
|-----|--------|-------|
| format | unknown | `npx biome format .` |
| typecheck | unknown | `npm run typecheck` |
| server-tests | unknown | unit + integration (vitest) |
| app-tests | unknown | unit tests (vitest) |
| playwright | unknown | E2E tests (playwright) |
| relay-tests | unknown | unit tests (vitest) |
| cli-tests | unknown | local tests |
## Log
- 2026-04-10: Branch created, automated agents begin iterating

239
CLAUDE.md
View File

@@ -1,220 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
**Key features:**
- Real-time streaming of agent output
- Voice commands for hands-free interaction
- Push notifications when tasks complete
- Multi-agent orchestration across projects
**Not a cloud sandbox** - Paseo 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, and OpenCode.
## Monorepo Structure
## Repository map
This is an npm workspace monorepo:
- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
- **packages/cli**: The `paseo` CLI that is used to manage the deamon, and acts as a client to it with Docker-style commands like `paseo run/ls/logs/wait`
- **packages/website**: Marketing site at paseo.sh (TanStack Router + Cloudflare Workers).
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
- `packages/app` — Mobile + web client (Expo)
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
- `packages/relay` — E2E encrypted relay for remote access
- `packages/desktop` — Electron desktop wrapper
- `packages/website` — Marketing site (paseo.sh)
## Development Server
## Documentation
The `npm run dev` script automatically picks an available port for the development server.
| Doc | What's in it |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
When running in a worktree or alongside the main checkout, set `PASEO_HOME` to isolate state:
## Quick start
```bash
PASEO_HOME=~/.paseo-blue npm run dev
npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
- `PASEO_HOME` path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
## Running and checking logs
## Critical rules
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
- **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 assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Debugging
### Daemon and CLI
The Paseo daemon communicates via WebSocket. In the main checkout:
- Daemon runs at `localhost:6767`
- Expo app at `localhost:8081`
- State lives in `$PASEO_HOME`
In worktrees or when running `npm run dev`, ports and home directories may differ. Never assume the defaults.
Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` which points to the main checkout). Always run `npm run cli -- --help` or load the `/paseo` skill before using it - do not guess commands.
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
### Quick reference CLI commands
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- daemon status # Check daemon status
```
### Agent state
Agent data is stored at:
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
To find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
To find an agent by title or other content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
rg -l "spiteful-toad" $PASEO_HOME/agents/
```
### Provider session files
Get the session ID from the agent JSON file (`persistence.sessionId`), then:
**Claude sessions:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex sessions:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```
## Android
Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
### Android variants (vanilla Expo)
Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package ID (no custom Gradle flavor plugin):
- `production` -> app name `Paseo`, package `sh.paseo`
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
`development` uses Android `debug`.
### Local build + install (Android device)
From `packages/app`:
```bash
# development (debug)
APP_VARIANT=development npx expo prebuild --platform android --clean --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# production (release)
APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
```
From repo root:
```bash
npm run android:development
npm run android:production
```
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
That workflow does:
- Build iOS with the `production` profile
- Build Android with the `production` profile
- Submit each build with the `production` submit profile
Useful commands:
```bash
# List recent mobile workflow runs
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect one run (jobs, status, outputs)
cd packages/app && npx eas workflow:view <run-id>
# Stream logs for all steps in one failed job
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
```
## Testing with Playwright MCP
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state.
## Expo troubleshooting
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
## Release playbook
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
```bash
# Recommended: full patch release (bump, check, publish, push branch+tag)
npm run release:patch
# Manual, step-by-step fallback:
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
```
Notes:
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
- All workspaces share one version by design. Keep versions synchronized and release together.
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
Release completion checklist:
- `npm run release:patch` completes successfully.
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
## Orchestrator Mode
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
## Agent Authentication
All agent providers (Claude, Codex, OpenCode) handle their own authentication outside of environment variables. They are authenticated without providing any extra configuration—Paseo does not manage API keys or tokens for agents.
**Do not add auth checks to tests.** If auth fails for whatever reason, let the user know instead of patching the code or adding conditional skips.
## NEVER DO THESE THINGS
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log

168
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,168 @@
# Contributing to Paseo
Thanks for taking the time to contribute.
## How this project works
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
This means:
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
- There is no obligation to merge a PR as-submitted, regardless of code quality.
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
## How to contribute
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
2. **Keep it small.** One bug, one flow, one focused change.
3. **Open a PR** once there is alignment on scope.
If you want to propose a direction change, start a conversation.
## Before you start
Please read these first:
- [README.md](README.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
The most useful contributions right now are:
- bug fixes
- windows and linux specific fixes
- regression fixes
- doc improvements
- packaging / platform fixes
- focused UX improvements that fit the existing product direction
- tests that lock down important behavior
## Scope expectations
Please keep PRs narrow.
Good:
- fix one bug
- improve one flow
- add one focused panel or command
- tighten one piece of UI
Bad:
- combine multiple product ideas in one PR
- bundle unrelated refactors with a feature
- sneak in roadmap decisions
If a contribution contains multiple ideas, split it up.
## Product fit matters
Paseo is an opinionated product.
When reviewing contributions, the bar is not just:
- is this useful?
- is this well implemented?
It is also:
- does this fit Paseo?
- does this add product surface that will be hard to maintain?
- does the value justify the maintenance surface it adds?
- does this solve a common need or over-serve an edge case?
- does this preserve the product's current direction?
## Development setup
### Prerequisites
- Node.js matching `.tool-versions`
- npm workspaces
### Start local development
```bash
# runs both daemon and expo app
npm run dev
```
Useful commands:
```bash
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
Common checks:
```bash
npm run typecheck
npm run test --workspaces --if-present
```
Important rules:
- always run `npm run typecheck` after changes
- tests should be deterministic
- prefer real dependencies over mocks when possible
- do not make breaking WebSocket / protocol changes
- app and daemon versions in the wild lag each other, so compatibility matters
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
## Coding standards
Paseo has explicit standards. Follow them.
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
## PR checklist
Before opening a PR, make sure:
- there was prior discussion and alignment on scope (issue or conversation)
- the change is focused, one idea per PR
- the PR description explains what changed and why
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
- UI changes have been tested on mobile and web at minimum
- typecheck passes
- tests pass, or you clearly explain what could not be run
- relevant docs were updated if needed
## Communication
If you are unsure whether something fits, ask first.
That is especially true for:
- new core UX
- naming / terminology changes
- new extension points
- new orchestration models
- anything that would be hard to remove later
Early alignment saves everyone time.
## Forks are fine
If you want to explore a different product direction, a fork is completely fine.
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.

684
LICENSE
View File

@@ -1,21 +1,671 @@
MIT License
Copyright (c) 2025-present Mohamed Boudra
Copyright (c) 2025 Mohamed Boudra
Portions of this software are licensed as follows:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
* All third party components incorporated into the Paseo Software are
licensed under the original license provided by the owner of the
applicable component.
* All content outside of the above mentioned restrictions is available
under the "AGPLv3" license as defined below.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
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.
The licenses for most software and other practical works are designed
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.
When we speak of free software, we are referring to freedom, not
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
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.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
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.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
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.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(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.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
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.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
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/>.

160
README.md
View File

@@ -4,106 +4,144 @@
<h1 align="center">Paseo</h1>
<p align="center">Manage coding agents from your phone and desktop.</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>
</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
---
> [!WARNING]
> **Early development** — Features may break or change without notice. Use at your own risk.
Run agents in parallel on your own machines. Ship from your phone or your desk.
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
## Features
## Getting Started
- **Self-hosted:** The daemon runs on your laptop, home server, or VPS
- **Multi-provider:** Works with Claude Code, Codex, and OpenCode from one interface
- **Multi-host:** Connect to multiple daemons and see all your agents in one place
- **Voice input:** Dictate prompts when you're away from your keyboard
- **Optional relay:** Use the hosted end-to-end encrypted relay, or connect directly
- **Cross-device:** iOS, Android, desktop, web, and CLI
- **Git integration:** Manage agents in isolated worktrees, review diffs, ship from the app
- **Open source:** Free and open source under MIT license
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.
## Quick Start
### Prerequisites
You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
```bash
npm install -g @getpaseo/cli && paseo
npm install -g @getpaseo/cli
paseo
```
Then open the app and connect to your daemon.
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
## Local speech (STT/TTS)
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
Paseo can run dictation + voice mode STT/TTS fully locally via `sherpa-onnx`.
## CLI
When the daemon starts with a local speech provider selected, it will download any missing model files automatically (unless `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=0`).
Everything you can do in the app, you can do from the terminal.
```bash
npm run speech:download --workspace=@getpaseo/server
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
Optional configuration:
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
- `PASEO_SHERPA_ONNX_MODELS_DIR` (defaults to `~/.paseo/models/sherpa-onnx`)
- `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD` (`1` by default; set `0` to disable automatic downloads on daemon start)
- `PASEO_SHERPA_STT_PRESET` (`zipformer`, `paraformer`, or `parakeet` for NVIDIA Parakeet TDT v3)
- `PASEO_SHERPA_TTS_PRESET` (`pocket-tts` (Kyutai Pocket TTS), `kitten`, or `kokoro`)
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER` (`sherpa` or `openai`)
## Orchestration skills (Unstable)
To see all supported local model IDs:
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npm run speech:models --workspace=@getpaseo/server
npx skills add getpaseo/paseo
```
Optional: run an end-to-end test that downloads real models and exercises streaming STT + streaming TTS:
Then use them in any agent conversation:
```bash
PASEO_SPEECH_E2E_DOWNLOAD=1 PASEO_SPEECH_E2E_MODEL_SET=parakeet-pocket \
npx vitest run --workspace=@getpaseo/server src/server/speech/sherpa/speech-download.e2e.test.ts
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Documentation
## Development
See [paseo.sh/docs](https://paseo.sh/docs) for full documentation.
Quick monorepo package map:
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
- `packages/app`: Expo client (iOS, Android, web)
- `packages/cli`: `paseo` CLI for daemon and agent workflows
- `packages/desktop`: Electron desktop app
- `packages/relay`: Relay package for remote connectivity
- `packages/website`: Marketing site and documentation (`paseo.sh`)
## Releases
Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`).
Common commands:
```bash
npm run version:all:patch
npm run release:push
```
# run all local dev services
npm run dev
For the full package release flow, use:
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
```bash
npm run release:patch
```
# build the daemon
npm run build:daemon
`npm run release:patch` bumps all workspace versions together, publishes npm packages (`@getpaseo/relay`, `@getpaseo/server`, `@getpaseo/cli`), and pushes the matching `v*` tag.
The tag triggers:
- GitHub `Desktop Release` workflow (`.github/workflows/desktop-release.yml`)
- Expo EAS mobile workflow (`packages/app/.eas/workflows/release-mobile.yml`) to build + submit Android/iOS
Useful monitoring commands after a release push:
```bash
# Desktop (GitHub Actions)
gh run list --workflow "Desktop Release" --limit 10
gh run watch <run-id>
# Mobile (EAS Workflows)
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
cd packages/app && npx eas workflow:view <run-id>
# repo-wide checks
npm run typecheck
```
## License
MIT
AGPL-3.0

View File

@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
- **Replay old messages** — Each session derives fresh encryption keys
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
### Trust model
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
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.
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.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
## DNS rebinding protection
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
@@ -51,4 +63,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.

5
app.json Normal file
View File

@@ -0,0 +1,5 @@
{
"android": {
"package": "com.moboudra.paseo"
}
}

32
biome.json Normal file
View File

@@ -0,0 +1,32 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}

1
cli-client-id Normal file
View File

@@ -0,0 +1 @@
cid_518a41c4c44340aea1120d2b760fc6c6

69
docs/ANDROID.md Normal file
View File

@@ -0,0 +1,69 @@
# Android
## App variants
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
| Variant | App name | Package ID |
|---|---|---|
| `production` | Paseo | `sh.paseo` |
| `development` | Paseo Debug | `sh.paseo.debug` |
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
`development` uses Android `debug`.
## Local build + install
From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
Or from `packages/app`:
```bash
# Debug
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# Release
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# Clear generated Android project
rm -rf android
```
## Screenshots
```bash
adb exec-out screencap -p > screenshot.png
```
## Cloud build + submit (EAS)
Stable tag pushes like `v0.1.0` trigger:
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
### Useful commands
```bash
cd packages/app
# List recent workflow runs
npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect a run
npx eas workflow:view <run-id>
# Stream logs for a failed job
npx eas workflow:logs <job-id> --non-interactive --all-steps
```

192
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,192 @@
# Architecture
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
Your code never leaves your machine. Paseo is local-first.
## System overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ CLI │ │ Desktop App │
│ (Expo) │ │ (Commander) │ │ (Electron) │
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ Managed subprocess
│ (direct or │ (direct) │ + WebSocket
│ via relay) │ │
└───────────┬───────┴──────────────────┘
┌──────▼──────┐
│ Daemon │
│ (Node.js) │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode │
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon
The heart of Paseo. A Node.js process that:
- Listens for WebSocket connections from clients
- Manages agent lifecycle (create, run, stop, resume, archive)
- Streams agent output in real time via a timeline model
- Exposes an MCP server for agent-to-agent control
- Optionally connects outbound to a relay for remote access
**Key modules:**
| Module | Responsibility |
|---|---|
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
- `DaemonRegistryContext` manages saved daemon connections
- `SessionContext` wraps the daemon client for the active session
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands:
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
- `paseo daemon start/stop/restart/status/pair`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- `paseo worktree ls/archive`
Communicates with the daemon via the same WebSocket protocol as the app.
### `packages/relay` — E2E encrypted relay
Enables remote access when the daemon is behind a firewall.
- ECDH key exchange + AES-256-GCM encryption
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
See [SECURITY.md](../SECURITY.md) for the full threat model.
### `packages/desktop` — Desktop app (Electron)
Electron wrapper for macOS, Linux, and Windows.
- Can spawn the daemon as a managed subprocess
- Native file access for workspace integration
- Same WebSocket client as mobile app
### `packages/website` — Marketing site
TanStack Router + Cloudflare Workers. Serves paseo.sh.
## WebSocket protocol
All clients speak the same binary-multiplexed WebSocket protocol.
**Handshake:**
```
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
```
**Message types:**
- `agent_update` — Agent state changed (status, title, labels)
- `agent_stream` — New timeline event from a running agent
- `workspace_update` — Workspace state changed
- `agent_permission_request` — Agent needs user approval for a tool call
- Command-response pairs for fetch, list, create, etc.
**Binary multiplexing:**
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 0: control messages
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
## Agent lifecycle
```
initializing → idle → running → idle (or error → closed)
↑ │
└────────┘ (agent completes a turn, awaits next prompt)
```
- **AgentManager** tracks up to 200 timeline items per agent
- Timeline is append-only with epochs (each run starts a new epoch)
- Events stream to all subscribed clients in real time
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
## Agent providers
Each provider implements a common `AgentClient` interface:
| Provider | Wraps | Session format |
|---|---|---|
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| OpenCode | OpenCode CLI | Provider-managed |
All providers:
- Handle their own authentication (Paseo does not manage API keys)
- Support session resume via persistence handles
- Map tool calls to a normalized `ToolCallDetail` type
- Expose provider-specific modes (plan, default, full-access)
## Data flow: running an agent
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
2. Session routes to `AgentManager.create()`
3. AgentManager creates a `ManagedAgent`, initializes provider session
4. Provider runs the agent → emits `AgentStreamEvent` items
5. Events append to the agent timeline, broadcast to all subscribed clients
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
7. Permission requests flow: agent → server → client → user decision → server → agent
## Storage
```
$PASEO_HOME/
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
└── daemon.log # Daemon trace logs
```
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron app spawns daemon as subprocess
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption

174
docs/CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,174 @@
# Coding Standards
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
## Core principles
- **Zero complexity budget** — justify every abstraction with specific benefits
- **Fully typed TypeScript** — no `any`, no untyped boundaries
- **YAGNI** — build features and abstractions only when needed
- **Functional and declarative** over object-oriented
- **`interface`** over `type` when possible
- **`function` declarations** over arrow function assignments
- **Single-purpose functions** — one function, one job
- **Design for edge cases through types** rather than explicit handling
- **Don't catch errors** unless there's a strong reason to
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
- **No "while I'm at it" improvements** — stay focused on the task
## Type hygiene
### Infer from schemas
Never hand-write a TypeScript type that can be inferred from a Zod schema.
```typescript
// Bad: duplicate type that can drift
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
type RPCArgs = { procedure: string; args: Record<string, unknown> };
// Good: infer from schema
type RPCArgs = z.infer<typeof schema>;
```
### Named types over inline
No complex inline types in public function signatures.
```typescript
// Bad
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
// Good
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
function enqueueJob(input: EnqueueJobInput) {}
```
### Object parameters
If a function needs more than one argument, use a single object parameter.
```typescript
// Bad: positional args
function createToolCall(provider: string, toolName: string, payload: unknown) {}
// Good: object param
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
function createToolCall(input: CreateToolCallInput) {}
```
### One canonical type per concept
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
```typescript
// Bad: duplicated fields across layers
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
// Good: canonical type + wrappers
type ToolCall = { toolName: string; args: Record<string, unknown> };
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
```
## Make impossible states impossible
Use discriminated unions instead of bags of booleans and optionals.
```typescript
// Bad
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
// Good
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: Data };
```
## Optionality is a design decision
Don't mark fields optional to avoid migrations. Decide deliberately:
1. Is optionality actually needed?
2. If there are distinct valid states → discriminated union
3. If value can be intentionally empty → explicit `null`
4. Keep optionality at real boundaries (external input), then resolve it
## Validate at boundaries, trust internally
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
```typescript
// Bad: optional chaining because shape is unclear
const value = response?.data?.items?.[0]?.name;
// Good: validate at boundary, trust the types
const parsed = responseSchema.parse(rawResponse);
const value = parsed.data.items[0].name;
```
## Error handling
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
```typescript
class TimeoutError extends Error {
constructor(
public readonly operation: string,
public readonly waitedMs: number,
) {
super(`${operation} timed out after ${waitedMs}ms`);
this.name = "TimeoutError";
}
}
```
## Keep logic density low
Avoid packing branching, lookup, and transformation into single dense expressions.
```typescript
// Bad: nested ternaries + inline lookups
const billing = shouldUseLegacy(account)
? getLegacy(account)
: buildBilling(account, rates.find((r) => r.region === account.region));
// Good: named steps, then assemble
const rate = rates.find((r) => r.region === account.region);
if (!rate) throw new MissingRateError(account.region);
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
```
## Centralize policy
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
## React: keep components dumb
- Components render state and dispatch events — they don't compute transitions
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
- Never mirror a source of truth into local state; derive from it
- Test state logic as pure functions without rendering
## File organization
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
- Name files after the main export (`create-toolcall.ts`)
- Use `index.ts` as an entrypoint, not a dumping ground
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
## Refactoring contract
Refactoring is structure work, not feature work.
- Preserve behavior by default, especially user-facing behavior
- Do not remove features to simplify code without explicit approval
- Have a verification strategy before you start
- Fully migrate callers and remove old paths in the same refactor
- No fallback behavior by default — prefer explicit error over silent degradation

428
docs/DATA_MODEL.md Normal file
View File

@@ -0,0 +1,428 @@
# Data Model
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
---
## Directory layout
```
$PASEO_HOME/
├── config.json # Daemon configuration
├── agents/
│ └── {project-dir}/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── chat/
│ └── rooms.json # All rooms + messages
├── loops/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ └── workspaces.json # Workspace registry
└── push-tokens.json # Expo push notification tokens
```
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
| Field | Type | Description |
|---|---|---|
| `title` | `string?` | Configured title |
| `modeId` | `string?` | Configured mode |
| `model` | `string?` | Configured model |
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
| `extra` | `Record<string, any>?` | Provider-specific config |
| `systemPrompt` | `string?` | Custom system prompt |
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
### Nested: RuntimeInfo
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Active provider |
| `sessionId` | `string?` | Active session ID |
| `model` | `string?` | Active model |
| `thinkingOptionId` | `string?` | Active thinking option |
| `modeId` | `string?` | Active mode |
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
### Nested: PersistenceHandle
| Field | Type | Description |
|---|---|---|
| `provider` | `string` | Provider that owns the session |
| `sessionId` | `string` | Session ID for resumption |
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
| `metadata` | `Record<string, any>?` | Extra metadata |
### Nested: AgentFeature (discriminated union on `type`)
**Toggle:**
| Field | Type |
|---|---|
| `type` | `"toggle"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `boolean` |
**Select:**
| Field | Type |
|---|---|
| `type` | `"select"` |
| `id` | `string` |
| `label` | `string` |
| `description` | `string?` |
| `tooltip` | `string?` |
| `icon` | `string?` |
| `value` | `string?` |
| `options` | `AgentSelectOption[]` |
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
Single file, validated with `PersistedConfigSchema`.
```
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
},
app: {
baseUrl: string
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
},
agents: {
providers: {
[provider: string]: {
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
env: Record<string, string>
}
}
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,
console: { level, format },
file: { level, path, rotate: { maxSize, maxFiles } }
}
}
```
All fields are optional with sensible defaults.
---
## 3. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | 8-char hex ID |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | The prompt to send |
| `cadence` | `ScheduleCadence` | Timing (see below) |
| `target` | `ScheduleTarget` | What to run (see below) |
| `status` | `"active" \| "paused" \| "completed"` | Current state |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
| `pausedAt` | `string?` (ISO 8601) | When paused |
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
| `maxRuns` | `number?` | Max executions before completing |
| `runs` | `ScheduleRun[]` | Execution history |
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
### Nested: ScheduleTarget (discriminated union on `type`)
- `{ type: "agent", agentId: string }` — send to existing agent
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
### Nested: ScheduleRun
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Run ID |
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
| `startedAt` | `string` (ISO 8601) | |
| `endedAt` | `string?` (ISO 8601) | |
| `status` | `"running" \| "succeeded" \| "failed"` | |
| `agentId` | `string?` (UUID) | Agent used for this run |
| `output` | `string?` | Agent output text |
| `error` | `string?` | Error message if failed |
---
## 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.
| 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 |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model 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`
Array of project records.
| Field | Type | Description |
|---|---|---|
| `projectId` | `string` | Primary key |
| `rootPath` | `string` | Filesystem root of the project |
| `kind` | `"git" \| "non_git"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 7. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
|---|---|---|
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
---
## 8. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"tokens": ["ExponentPushToken[...]", ...]
}
```
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
---
## Client-side stores (App)
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
```typescript
{
drafts: Record<draftKey, {
input: { text: string, images: AttachmentMetadata[] },
lifecycle: "active" | "abandoned" | "sent",
updatedAt: number, // epoch ms
version: number // optimistic concurrency
}>,
createModalDraft: DraftRecord | null
}
```
### Attachment Store (Web)
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
Stores binary attachment blobs keyed by attachment ID.
### AttachmentMetadata
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique attachment ID |
| `mimeType` | `string` | MIME type |
| `storageType` | `string` | Storage backend identifier |
| `storageKey` | `string` | Key within the storage backend |
| `createdAt` | `number` | Epoch ms |
| `fileName` | `string?` | Original filename |
| `byteSize` | `number?` | Size in bytes |

73
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,73 @@
# Designing Features
How to think through a feature before writing code.
## Start from the user
Even for backend work, start from the user's perspective:
- What problem does this solve?
- What triggers it? User action, schedule, event?
- What does success look like from the user's perspective?
- What data does it need? Where does that data come from?
## Map existing code
Before designing anything new, understand what exists:
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
- What types and data shapes are already defined?
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
## Define verification before implementation
Before designing the solution, define how you'll know it works:
- What tests will prove this feature is correct?
- At what layer? Unit, integration, E2E?
- What's the simplest way to verify the core behavior?
If you can't define verification, you don't understand the feature well enough yet.
## Design the shape
### Data
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
### Layers
- What belongs in each layer?
- Where are the boundaries?
- What does each layer expose to the layer above?
### Interactions
- How does data flow through the system?
- What triggers what?
- Where do side effects happen?
### Refactoring
- What existing code needs to change?
- Is existing code testable enough? If not, that's part of the plan.
## Create a concrete plan
Once the design is clear:
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
3. **What to refactor** before adding new code
4. **How to verify** each step
## Principles
- **Fit, don't force** — new code should fit existing patterns, or refactor first
- **Simple** — the best design is the simplest one that works
- **Verify early** — define how to test before designing the implementation

130
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,130 @@
# Development
## Prerequisites
- Node.js (see `.tool-versions` for exact version)
- npm workspaces (comes with Node)
## Running the dev server
```bash
npm run dev
```
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
### Running alongside the main checkout
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
```bash
PASEO_HOME=~/.paseo-blue npm run dev
```
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
### Default ports
In the main checkout:
- Daemon: `localhost:6767`
- Expo app: `localhost:8081`
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
## Build sync gotchas
### Relay → Daemon
When changing `packages/relay/src/*`, rebuild before running the daemon:
```bash
npm run build --workspace=@getpaseo/relay
```
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
### Server → CLI
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
```bash
npm run build --workspace=@getpaseo/server
```
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
## CLI reference
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
```bash
npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- daemon status # Check daemon status
```
Use `--host <host:port>` to point the CLI at a different daemon:
```bash
npm run cli -- --host localhost:7777 ls -a
```
## Agent state
Agent data lives at:
```
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
Find an agent by ID:
```bash
find $PASEO_HOME/agents -name "{agent-id}.json"
```
Find by content:
```bash
rg -l "some title text" $PASEO_HOME/agents/
```
## Provider session files
Get the session ID from the agent JSON (`persistence.sessionId`), then:
**Claude:**
```
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
```
**Codex:**
```
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
```
## Testing with Playwright MCP
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## Expo troubleshooting
```bash
npx expo-doctor
```
Diagnoses version mismatches and native module issues.
## Typecheck
Always run typecheck after changes:
```bash
npm run typecheck
```

109
docs/FILE_ICONS.md Normal file
View File

@@ -0,0 +1,109 @@
# File Icons
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
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |

206
docs/MOBILE_TESTING.md Normal file
View File

@@ -0,0 +1,206 @@
# Mobile Testing
## Maestro
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
Run a flow:
```bash
maestro test packages/app/maestro/my-flow.yaml
```
### Screenshots
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
```bash
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
mkdir -p /tmp/maestro-out
cd /tmp/maestro-out && maestro test "$FLOW"
```
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
### Element targeting
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
```tsx
// Component
<Pressable testID="sidebar-sessions" onPress={onPress}>
```
```yaml
# Flow
- tapOn:
id: "sidebar-sessions"
- assertVisible:
id: "sidebar-sessions"
```
### Conditional steps
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
```yaml
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
```
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
### Don't use launchApp against a running dev app
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
### Swipe gestures
Use `start`/`end` with percentage coordinates for precise control:
```yaml
# Edge swipe from left to open sidebar
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
```
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
### Assertions
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
For async elements, use `extendedWaitUntil`:
```yaml
- extendedWaitUntil:
visible: ".*Online.*"
timeout: 90000
```
### Dev client handling
Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
This pattern also lets agents self-verify fixes without manual user testing.
### Pattern
1. Run baseline Maestro flow (confirm feature works)
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
3. Re-run Maestro flow (confirm feature still works)
4. Repeat N iterations to catch intermittent failures
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
```bash
bash packages/app/maestro/test-sidebar-theme.sh 6 1
# Args: iterations=6, wait_seconds=1 between toggle and test
```
Key elements of the script pattern:
```bash
set -euo pipefail
ITERATIONS="${1:-3}"
for i in $(seq 1 "$ITERATIONS"); do
# Toggle system state
xcrun simctl ui booted appearance light
# Wait for change to propagate
sleep 1
# Run Maestro flow and capture result
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
echo "PASS"
else
echo "FAIL"
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
fi
done
```
## Unistyles + Reanimated
### The crash
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
### The fix
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
```tsx
// BAD: Unistyles dynamic style on Animated.View
const styles = StyleSheet.create((theme) => ({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
overflow: "hidden",
},
}));
<Animated.View style={[styles.sidebar, animatedStyle]} />
```
```tsx
// GOOD: static stylesheet + inline theme values
import { StyleSheet as RNStyleSheet } from "react-native";
const staticStyles = RNStyleSheet.create({
sidebar: {
position: "absolute",
top: 0,
left: 0,
bottom: 0,
overflow: "hidden",
},
});
const { theme } = useUnistyles();
<Animated.View
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>
```
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## iOS Simulator
```bash
# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png
# Dark/light mode
xcrun simctl ui booted appearance # check current
xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).

74
docs/PRODUCT.md Normal file
View File

@@ -0,0 +1,74 @@
# Product
What Paseo is, who it's for, and where it's going.
## What is Paseo
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
## Core philosophy
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## How it works
### Projects and workspaces
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
### The daemon
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
## Target user
Anyone who builds software:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
## What compounds over time
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## Strategic bets
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (March 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model

359
docs/PROVIDERS.md Normal file
View File

@@ -0,0 +1,359 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
Existing ACP providers: `claude-acp`, `copilot`.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude`, `codex`, `opencode`.
---
## ACP Provider Checklist
### 1. Create the provider class
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
```ts
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const MY_PROVIDER_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
},
// Add more modes as needed
];
type MyProviderClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class MyProviderACPAgentClient extends ACPAgentClient {
constructor(options: MyProviderClientOptions) {
super({
provider: "my-provider", // Must match the ID used everywhere else
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
defaultModes: MY_PROVIDER_MODES,
capabilities: MY_PROVIDER_CAPABILITIES,
});
}
// Override isAvailable() if the provider needs specific auth/env vars
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false; // Binary not found
}
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
}
}
```
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
```ts
export class CopilotACPAgentClient extends ACPAgentClient {
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
capabilities: COPILOT_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
return super.isAvailable();
}
}
```
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
```ts
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Standard agent mode",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "autonomous",
label: "Autonomous",
description: "Runs without prompting",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
```
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
```ts
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
// ... existing providers ...
{
id: "my-provider",
label: "My Provider",
description: "Short description of the provider",
defaultModeId: "default",
modes: MY_PROVIDER_MODES,
// Optional: enable voice
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "some-model",
},
},
];
```
### 3. Add the factory to the provider registry
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
```ts
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
// ... existing factories ...
"my-provider": (logger, runtimeSettings) =>
new MyProviderACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["my-provider"],
}),
};
```
### 4. Add a provider icon (app)
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
```tsx
import Svg, { Path } from "react-native-svg";
interface MyProviderIconProps {
size?: number;
color?: string;
}
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="..." />
</Svg>
);
}
```
Then register it in `packages/app/src/components/provider-icons.ts`:
```ts
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
"my-provider": MyProviderIcon as unknown as typeof Bot,
};
```
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
### 5. Add E2E test config
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
```ts
export const agentConfigs = {
// ... existing configs ...
"my-provider": {
provider: "my-provider",
model: "default-model-id",
modes: {
full: "autonomous", // Mode with no permission prompts
ask: "default", // Mode that requires permission approval
},
},
} as const satisfies Record<string, AgentTestConfig>;
```
Add an availability check in `isProviderAvailable()`:
```ts
case "my-provider":
return (
isCommandAvailable("my-agent-binary") &&
Boolean(process.env.MY_PROVIDER_API_KEY)
);
```
Add to the `allProviders` array:
```ts
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
"my-provider",
];
```
### 6. Run typecheck
```bash
npm run typecheck
```
This is required after every change per project rules.
---
## Direct Provider Checklist
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
### Interfaces to implement
**`AgentClient`** -- factory for sessions and model listing:
```ts
interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
isAvailable(): Promise<boolean>;
// Optional:
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
```
**`AgentSession`** -- a running agent conversation:
```ts
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
2. Add to the provider manifest (same as ACP step 2 above)
3. Add factory to the registry (same as ACP step 3 above)
4. Add icon (same as ACP step 4 above)
5. Add E2E config (same as ACP step 5 above)
6. Run typecheck
---
## Testing
### Manual testing with the CLI
Start the daemon if not already running, then:
```bash
# Launch an agent with your provider
paseo run --provider my-provider
# Launch with a specific model and mode
paseo run --provider my-provider --model some-model --mode default
# List running agents
paseo ls -a -g
# Check if the provider reports models
paseo models --provider my-provider
```
### E2E test patterns
The E2E configs in `agent-configs.ts` expose two helpers:
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
---
## Gotchas
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.

177
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,177 @@
# Release
All workspaces share one version and release together.
## Two paths
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
## Standard release (patch)
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure local `npm run typecheck` passes on that commit.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash
npm run release:patch
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
## Manual step-by-step
```bash
npm run typecheck # Verify the exact commit you intend to release
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
## Release candidate flow
```bash
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
```
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
Use the RC path when you need to:
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
## Fixing a failed release build
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
```bash
# Desktop (all platforms)
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
# Desktop (single platform)
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
# RC
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
## Changelog format
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` is for **final stable releases only**.
- Do not add or edit changelog entries while iterating on RCs.
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
- Between stable releases, keep changelog work out of the repo until the final release is ready.
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Changelog voice
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
## Completion checklist
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green

123
docs/TESTING.md Normal file
View File

@@ -0,0 +1,123 @@
# Testing
## Philosophy
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
## Test-driven development
Work in vertical slices: one test, one implementation, repeat. Each test responds to what you learned from the previous cycle.
```
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
```
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
## Determinism first
Tests must produce the same result every run:
- No conditional assertions or branching paths
- No reliance on timing, randomness, or network jitter
- No weak assertions (`toBeTruthy`, `toBeDefined`)
- Assert the full intended behavior, not fragments
```typescript
// Bad: conditional and weak
it("creates a tool call", async () => {
const result = await createToolCall(input);
if (result.ok) {
expect(result.id).toBeDefined();
}
});
// Good: deterministic and explicit
it("returns timeout error when provider times out", async () => {
const result = await createToolCall(input);
expect(result).toEqual({
ok: false,
error: { code: "PROVIDER_TIMEOUT", waitedMs: 30000 },
});
});
```
## Flaky tests are a bug
Never remove a test because it's flaky. Find the variance source (time, randomness, race condition, shared state, non-deterministic output, environment drift) and fix it.
## Real dependencies over mocks
Mocks are not the default. They require an explicit decision.
- **Database**: real test database, not a mock
- **APIs**: real APIs with test/sandbox credentials, not request mocks
- **File system**: temporary directory that gets cleaned up, not fs mocks
Ask: "will this still hold with real dependencies at runtime?" If no, don't mock.
### Use swappable adapters instead
When you need test isolation, design code so dependencies are injectable:
```typescript
interface EmailSender {
send(to: string, body: string): Promise<void>;
}
// Production
const realSender: EmailSender = { send: sendgrid.send };
// Test: in-memory adapter
function createTestEmailSender() {
const sent: Array<{ to: string; body: string }> = [];
return {
send: async (to: string, body: string) => { sent.push({ to, body }); },
sent,
};
}
```
## End-to-end means end-to-end
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
## Test organization
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
- Extract complex setup into reusable helpers
- Test bodies should read like plain English
- Build a vocabulary of test helpers that make complex flows simple
## Agent authentication in tests
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
## Debugging with tests
Use the test as your debugging ground:
1. Add temporary logging to the code under test
2. Run the test, observe actual values
3. Trace the flow end-to-end through test output
4. Confirm each assumption with actual output
5. Remove logging when done
The test output is the source of truth, not your reading of the code.
## Design for testability
If code isn't testable, refactor it. Signs:
- You want to reach for a mock
- You can't inject a dependency
- You need to test private internals
- Setup requires too much global state
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.

View File

@@ -0,0 +1,68 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

59
flake.nix Normal file
View File

@@ -0,0 +1,59 @@
{
description = "Paseo - self-hosted daemon for AI coding agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{
self,
nixpkgs,
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
in
{
default = paseo;
paseo = paseo;
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
{
imports = [ ./nix/module.nix ];
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.python3
];
};
}
);
};
}

172
nix/module.nix Normal file
View File

@@ -0,0 +1,172 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.paseo;
in
{
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
package = lib.mkPackageOption pkgs "paseo" { };
user = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "User account under which Paseo runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "paseo";
description = "Group under which Paseo runs.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default =
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/${cfg.user}/.paseo";
defaultText = lib.literalExpression ''
if cfg.user == "paseo"
then "/var/lib/paseo"
else "/home/''${cfg.user}/.paseo"
'';
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "Port for the Paseo daemon to listen on.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the Paseo daemon to bind to.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
Set to `true` to allow any host (not recommended).
'';
};
relay = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
};
};
inheritUserEnvironment = lib.mkOption {
type = lib.types.bool;
default = cfg.user != "paseo";
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
description = ''
Whether to include the user's profile PATH in the service environment.
When Paseo runs as a real user (not the default system user), AI agents
need access to the user's tools (git, ssh, etc.). This adds the user's
NixOS profile and system paths so agents can use them without manually
setting PATH.
Enabled by default when `user` is set to a non-default value.
'';
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
}
'';
description = "Extra environment variables for the Paseo daemon.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.paseo = {
description = "Paseo - self-hosted daemon for AI coding agents";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
} // lib.optionalAttrs cfg.inheritUserEnvironment {
# mkForce overrides the default PATH from NixOS's systemd module (which
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
# includes /run/current-system/sw/bin which is a superset of those.
PATH = lib.mkForce (lib.concatStringsSep ":" [
"/etc/profiles/per-user/${cfg.user}/bin"
"/run/current-system/sw/bin"
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // cfg.environment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
"${cfg.package}/bin/paseo-server"
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
Restart = "on-failure";
RestartSec = 5;
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
KillSignal = "SIGTERM";
TimeoutStopSec = 15;
};
};
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}

144
nix/package.nix Normal file
View File

@@ -0,0 +1,144 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
}:
buildNpmPackage rec {
pname = "paseo";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
!(lib.hasPrefix "/packages/app/src" relPath)
&& !(lib.hasPrefix "/packages/app/assets" relPath)
&& !(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
&& !(lib.hasPrefix "/packages/website/src" relPath)
&& !(lib.hasPrefix "/packages/website/public" relPath)
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
# Exclude test fixtures and debug files
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store";
};
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-0VEwgqUn0j2YWofW8IB5SNpo7Vp3Znfz1TMDAnZFkzs=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
# We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty compilation)
makeWrapper
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
libuv
];
# Don't use the default npm build hook — we need a custom build sequence
dontNpmBuild = true;
buildPhase = ''
runHook preBuild
# Rebuild only node-pty (native addon for terminal emulation).
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
# intentionally left unbuilt they're lazily loaded and gracefully
# degrade when unavailable.
npm rebuild node-pty
# Build all daemon packages in dependency order (defined in package.json)
npm run build:daemon
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/paseo
# Copy root package metadata
cp package.json $out/lib/paseo/
# Copy node_modules (preserving workspace symlinks)
cp -a node_modules $out/lib/paseo/
# Auto-detect which @getpaseo/* packages were built by build:daemon
# (they'll have a dist/ directory). Copy those and remove the rest.
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
name=$(basename "$link")
if [ -d "packages/$name/dist" ]; then
mkdir -p "$out/lib/paseo/packages/$name"
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
if [ -d "packages/$name/node_modules" ]; then
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
fi
else
rm -f "$link"
fi
done
# Copy CLI bin entry
mkdir -p $out/lib/paseo/packages/cli/bin
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
# Copy extra server files referenced at runtime
for f in agent-prompt.md .env.example; do
if [ -f packages/server/$f ]; then
cp packages/server/$f $out/lib/paseo/packages/server/
fi
done
# Copy server scripts (including supervisor-entrypoint) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
fi
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
--set NODE_PATH "$out/lib/paseo/node_modules"
runHook postInstall
'';
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}

21158
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
{
"name": "paseo",
"version": "0.1.6",
"version": "0.1.53",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/server",
"packages/app",
"packages/relay",
@@ -12,49 +14,66 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format": "biome format --write .",
"format:check": "biome format .",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:prod --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
"version:all:major": "node scripts/set-release-version.mjs --mode major",
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"prettier": "^3.5.3",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -63,12 +82,22 @@
"development",
"mcp"
],
"author": "moboudra",
"license": "MIT",
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1"
"lightningcss": "1.30.1",
"react": "19.1.4",
"react-dom": "19.1.4"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
}
}

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
- "!v*-rc.*"
workflow_dispatch: {}
jobs:

View File

@@ -1,14 +1,46 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const appVariant = process.env.APP_VARIANT ?? "production";
function resolveSecretFile(params) {
const fromEnv = process.env[params.envKey];
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
return fromEnv.trim();
}
const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath);
if (fs.existsSync(fallbackAbsolutePath)) {
return params.fallbackRelativePath;
}
return undefined;
}
const variants = {
production: {
name: "Paseo",
packageId: "sh.paseo",
googleServicesFile: resolveSecretFile({
envKey: "GOOGLE_SERVICES_FILE_PROD",
fallbackRelativePath: "./.secrets/google-services.prod.json",
}),
googleServiceInfoPlist: resolveSecretFile({
envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD",
fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist",
}),
},
development: {
name: "Paseo Debug",
packageId: "sh.paseo.debug",
googleServicesFile: resolveSecretFile({
envKey: "GOOGLE_SERVICES_FILE_DEBUG",
fallbackRelativePath: "./.secrets/google-services.debug.json",
}),
googleServiceInfoPlist: resolveSecretFile({
envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG",
fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist",
}),
},
};
@@ -33,11 +65,13 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
NSMicrophoneUsageDescription:
"This app needs access to the microphone for voice commands.",
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
bundleIdentifier: variant.packageId,
...(variant.googleServiceInfoPlist
? { googleServicesFile: variant.googleServiceInfoPlist }
: {}),
},
android: {
adaptiveIcon: {
@@ -57,11 +91,15 @@ export default {
"android.permission.CAMERA",
],
package: variant.packageId,
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
output: "single",
favicon: "./assets/images/favicon.png",
},
autolinking: {
searchPaths: ["../../node_modules", "./node_modules"],
},
plugins: [
"expo-router",
[
@@ -105,6 +143,7 @@ export default {
experiments: {
typedRoutes: true,
reactCompiler: true,
autolinkingModuleResolution: true,
},
extra: {
router: {},

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -1,37 +0,0 @@
import { test, expect } from "./fixtures";
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
test.setTimeout(120_000);
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
await page.getByTestId("agent-overflow-menu").click();
await page.getByTestId("agent-menu-details").click();
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
"Not available"
);
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
await expect(
page.getByTestId("agent-details-persistence-session-id-value")
).not.toHaveText("Not available", { timeout: 90_000 });
await page.getByTestId("agent-details-agent-id").click();
await expect(page.getByTestId("app-toast")).toBeVisible();
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,34 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
const repo = await createTempGitRepo();
const marker = 'TIMELINE_HYDRATION_OK';
const prompt = `Respond with exactly: ${marker}`;
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
const assistantMessage = page
.getByTestId('assistant-message')
.filter({ hasText: marker })
.first();
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
await page.reload({ waitUntil: 'commit' });
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
await expect(
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -0,0 +1,108 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
connectArchiveTabDaemonClient,
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 120_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
});

View File

@@ -1,428 +0,0 @@
import path from 'node:path';
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures';
import {
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ mode: 'serial', timeout: 120000 });
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header');
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
if (await primaryBackdrop.isVisible().catch(() => false)) {
await primaryBackdrop.click({ force: true });
await expect(primaryBackdrop).toHaveCount(0);
}
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
if (await overflowBackdrop.isVisible().catch(() => false)) {
await overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0);
}
const scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first();
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
if (!(await modeToggle.isVisible().catch(() => false))) {
return;
}
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) {
await modeToggle.click();
}
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
}
async function openChangesOverflowMenu(page: Page) {
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
await expect(menuButton).toBeVisible();
await menuButton.click();
}
async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-primary-cta-caret').first();
await expect(caret).toBeVisible();
await caret.click();
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header');
if (await explorerHeader.isVisible()) {
const changesTab = explorerHeader.getByText('Changes', { exact: true });
if (await changesTab.isVisible().catch(() => false)) {
await changesTab.click();
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText(/view changes/i).first().click();
}
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText(/view changes/i).first().click();
}
}
await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) {
return;
}
const changesScope = getChangesScope(page);
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
timeout: 30000,
});
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
timeout: 30000,
});
}
async function sendPrompt(page: Page, prompt: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(prompt);
await input.press('Enter');
}
async function waitForAssistantText(page: Page, text: string) {
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last();
await expect(assistantMessage).toBeVisible({ timeout: 60000 });
return assistantMessage;
}
async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(message);
await input.press('Enter');
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
await expect(page.getByText(message, { exact: true })).toBeVisible();
}
async function requestCwd(page: Page) {
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
const message = await waitForAssistantText(page, 'CWD:');
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('/worktrees/');
const content = (await message.textContent()) ?? '';
const match = content.match(/CWD:\s*(\S+)/);
if (!match) {
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
}
return match[1].trim();
}
async function selectAttachWorktree(page: Page, branchName: string) {
await page.getByTestId('worktree-attach-toggle').click();
const picker = page.getByTestId('worktree-attach-picker');
await expect(picker).toBeVisible();
// Wait a bit for the worktree list to load
await page.waitForTimeout(1000);
await picker.click();
// Wait a bit for animation
await page.waitForTimeout(500);
const sheet = page.getByLabel('Bottom Sheet', { exact: true });
const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first();
await expect.poll(async () => {
const sheetVisible = await sheet.isVisible().catch(() => false);
const backdropVisible = await backdrop.isVisible().catch(() => false);
// Also check if branch name is visible directly
const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false);
return sheetVisible || backdropVisible || branchVisible;
}, { timeout: 10000 }).toBeTruthy();
const sheetVisible = await sheet.isVisible().catch(() => false);
const scope = sheetVisible ? sheet : page;
const preferredOption = scope.getByText(branchName, { exact: true }).first();
if (await preferredOption.isVisible().catch(() => false)) {
await preferredOption.click();
await expect(picker).toContainText(branchName);
return;
}
const options = scope.locator('[data-testid^="worktree-attach-option-"]');
const optionCount = await options.count();
if (optionCount === 0) {
throw new Error(`No worktree options were available in the attach picker`);
}
const fallbackOption = options.first();
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
await fallbackOption.click();
if (fallbackLabel.length > 0) {
await expect(picker).toContainText(fallbackLabel);
}
}
async function enableCreateWorktree(page: Page) {
const createToggle = page.getByTestId('worktree-create-toggle');
const willCreateLabel = page.getByText(/Will create:/);
if (await willCreateLabel.isVisible()) {
return;
}
const readyLabel = page.getByText(
/Run isolated from|Run in an isolated directory/
);
await expect(readyLabel).toBeVisible({ timeout: 30000 });
await createToggle.click({ force: true });
await expect(willCreateLabel).toBeVisible({ timeout: 30000 });
}
async function refreshUncommittedMode(page: Page) {
await selectChangesView(page, 'base');
await selectChangesView(page, 'working');
}
async function refreshChangesTab(page: Page) {
const header = page.locator('[data-testid="explorer-header"]:visible').first();
await header.getByText('Files', { exact: true }).first().click();
await header.getByText('Changes', { exact: true }).first().click();
}
function normalizeTmpPath(value: string) {
if (value.startsWith('/var/')) {
return `/private${value}`;
}
return value;
}
test('checkout-first Changes panel ship loop', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await enableCreateWorktree(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await waitForAssistantText(page, 'READY');
await openChangesPanel(page);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
await expect
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
.not.toBe('Unknown');
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0);
const firstCwd = await requestCwd(page);
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: firstCwd,
encoding: 'utf8',
}).trim();
expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd),
realpath(repo.path).catch(() => repo.path),
]);
const normalizedRepo = normalizeTmpPath(resolvedRepo);
const normalizedCwd = normalizeTmpPath(resolvedCwd);
const expectedMarker = `${path.sep}worktrees${path.sep}`;
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAssistantText(page, 'READY2');
const secondCwd = await requestCwd(page);
expect(secondCwd).toBe(firstCwd);
await sendPrompt(page, "Respond with exactly: OK");
await waitForAssistantText(page, "OK");
const readmePath = path.join(firstCwd, 'README.md');
await appendFile(readmePath, '\nFirst change\n');
await refreshUncommittedMode(page);
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible();
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toContainText('Commit');
await primaryCta.click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push once from the menu so the branch has an origin/<branch> ref.
await openChangesPrimaryMenu(page);
await page.getByTestId('changes-menu-push').click();
await expect
.poll(() => {
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
return true;
} catch {
return false;
}
}, { timeout: 30000 })
.toBe(true);
const notesPath = path.join(firstCwd, 'notes.txt');
await writeFile(notesPath, 'Second change\n');
await refreshUncommittedMode(page);
await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('changes-primary-cta').click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
await pushPrimary.click();
// Regression check: the primary CTA stays in place while pushing.
await expect(pushPrimary).toBeVisible();
await page.waitForTimeout(50);
await expect(pushPrimary).toBeVisible();
await expect
.poll(() => {
try {
const count = execSync(
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
{ cwd: firstCwd, encoding: 'utf8' }
).trim();
return Number.parseInt(count, 10);
} catch {
return null;
}
}, { timeout: 30000 })
.toBe(0);
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
execSync("git checkout main", { cwd: repo.path });
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
execSync("git push", { cwd: repo.path });
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
timeout: 60000,
});
await refreshChangesTab(page);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
await openChangesOverflowMenu(page);
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
await page.getByTestId('changes-menu-archive-worktree').click();
// Archiving a worktree deletes agents and redirects to home
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Repo inspection is async; wait until git options are interactive again.
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
await page.getByTestId('worktree-attach-picker').click();
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
if (await attachSheet.isVisible().catch(() => false)) {
await page.getByTestId('dropdown-sheet-close').click({ force: true });
await expect(attachSheet).toBeHidden({ timeout: 30000 });
}
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
await setWorkingDirectory(page, nonGitDir);
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
await waitForAssistantText(page, 'NON-GIT');
await openChangesPanel(page, { expectGit: false });
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
} finally {
await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup();
}
});

View File

@@ -1,29 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('create agent in a temp repo', async ({ page }) => {
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
// Verify user message is shown in the stream
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
// Verify we used a fast model (do not fall back to a default like Sonnet).
await page.getByTestId('agent-overflow-menu').click();
await expect(page.getByText('Model', { exact: true })).toBeVisible();
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
// Wait for agent response containing "Hello" within an assistant message
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
await expect(assistantMessage).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,19 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('daemon is connected in settings', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await gotoHome(page);
await openSettings(page);
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
});

View File

@@ -1,55 +0,0 @@
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('deleting an agent persists after reload', async ({ page }) => {
const repo = await createTempGitRepo();
const nonce = Math.random().toString(36).slice(2, 10);
const prompt = `respond-ready-${nonce}`;
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Create agent (via message input) so it shows up in the sidebar list.
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(prompt);
await input.press('Enter');
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (!match) {
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]);
const agentId = decodeURIComponent(match[2]);
// Return home and delete via long-press in the agent list.
await gotoHome(page);
const rowTestId = `agent-row-${serverId}-${agentId}`;
const agentRow = page.getByTestId(rowTestId).first();
await expect(agentRow).toBeVisible({ timeout: 30000 });
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
await agentRow.hover();
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
await expect(quickArchive).toBeVisible({ timeout: 10000 });
await quickArchive.click({ force: true });
// Ensure deletion finished before reload (avoids races).
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
// A full reload should not bring the agent back.
await page.reload();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,227 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import type { Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
async function addFakeMicrophone(page: Page) {
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm');
const base64Audio = (await readFile(fixturePath)).toString('base64');
const mimeType = 'audio/webm;codecs=opus';
return page.addInitScript(({ base64Audio, mimeType }) => {
const mic = {
active: 0,
getUserMediaCalls: 0,
stopCalls: 0,
lastRecorder: null as null | { state: string },
};
(window as any).__mic = mic;
(window as any).isSecureContext = true;
const nav = navigator as any;
if (!nav.mediaDevices) {
nav.mediaDevices = {};
}
nav.mediaDevices.getUserMedia = async () => {
mic.getUserMediaCalls += 1;
mic.active += 1;
const track = {
stop: () => {
mic.stopCalls += 1;
mic.active = Math.max(0, mic.active - 1);
},
};
return {
getTracks: () => [track],
};
};
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
};
class FakeMediaRecorder extends EventTarget {
public static isTypeSupported() {
return true;
}
public state: 'inactive' | 'recording' = 'inactive';
public mimeType: string;
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
public onerror: ((event: unknown) => void) | null = null;
constructor(_stream: unknown, options?: MediaRecorderOptions) {
super();
this.mimeType = options?.mimeType ?? 'audio/webm';
mic.lastRecorder = this;
}
public start() {
this.state = 'recording';
}
public stop() {
if (this.state !== 'recording') {
throw new Error('Not recording');
}
this.state = 'inactive';
try {
this.ondataavailable?.({
data: blobFromBase64(base64Audio, mimeType),
});
} catch (err) {
this.onerror?.(err);
}
this.dispatchEvent(new Event('stop'));
}
}
(window as any).MediaRecorder = FakeMediaRecorder;
}, { base64Audio, mimeType });
}
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await page.keyboard.press('Control+d');
await page.waitForTimeout(200);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
const active = await page.evaluate(() => (window as any).__mic.active as number);
expect(calls).toBe(1);
expect(active).toBe(1);
await page.keyboard.press('Escape');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
} finally {
await repo.cleanup();
}
});
test('dictation transcribes fixture via real STT', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
const initialCopyMessageCount = await page
.getByRole('button', { name: 'Copy message' })
.count();
await page.keyboard.press('Control+d');
await expect
.poll(
async () => page.getByRole('button', { name: 'Copy message' }).count(),
{ timeout: 60_000 }
)
.toBeGreaterThan(initialCopyMessageCount);
} finally {
await repo.cleanup();
}
});
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await page.evaluate(() => {
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
if (mic.lastRecorder) {
mic.lastRecorder.state = 'inactive';
}
});
await page.keyboard.press('Escape');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
} finally {
await repo.cleanup();
}
});
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent($|\/)/);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await page.keyboard.press('Control+d');
const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/);
await page.waitForTimeout(10_000);
const agentEntry = page.getByText(repo.path).first();
await expect(agentEntry).toBeVisible();
await agentEntry.click();
await expect(page).toHaveURL(/\/agent($|\/)/);
await expect(page.getByText(/voice note/i)).not.toBeVisible();
} finally {
await repo.cleanup();
}
});

View File

@@ -1,11 +1,12 @@
import { test as base, expect, type Page } from '@playwright/test';
import { test as base, expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await use(`http://localhost:${metroPort}`);
},
@@ -18,19 +19,19 @@ test.beforeEach(async ({ page }) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === '6767') {
if (daemonPort === "6767") {
throw new Error(
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
@@ -38,17 +39,17 @@ test.beforeEach(async ({ page }) => {
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
page.on('console', (message) => {
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
@@ -56,40 +57,21 @@ test.beforeEach(async ({ page }) => {
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = {
const testDaemon = buildSeededHost({
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
// Ensure create flow never uses a remembered host from the developer's real app.
serverId: testDaemon.serverId,
// Keep e2e fast/cheap by default.
provider: 'claude',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
},
};
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
@@ -98,15 +80,15 @@ test.beforeEach(async ({ page }) => {
}
}
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
});
@@ -120,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
return;
}
await testInfo.attach('browser-console', {
body: entries.join('\n'),
contentType: 'text/plain',
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
});

View File

@@ -1,99 +0,0 @@
import path from 'node:path';
import { appendFile } from 'node:fs/promises';
import { test, expect, type Page } from './fixtures';
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ timeout: 90000 });
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
async function openChangesPanel(page: Page) {
const changesHeader = getChangesScope(page).getByTestId('changes-header');
if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header');
if (await explorerHeader.isVisible()) {
await page.getByText('Changes', { exact: true }).click();
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText('View Changes', { exact: true }).click();
}
}
await expect(changesHeader).toBeVisible();
}
async function refreshUncommittedMode(page: Page) {
const scope = getChangesScope(page);
const toggle = scope.getByTestId('changes-diff-status').first();
await expect(toggle).toBeVisible({ timeout: 30000 });
const currentLabel = (await toggle.innerText()).trim();
await toggle.click();
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
const nextLabel = (await toggle.innerText()).trim();
await toggle.click();
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
}
async function createAgentAndWait(page: Page, message: string) {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(message);
await input.press('Enter');
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
await expect(page.getByText(message, { exact: true })).toBeVisible();
}
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-sticky-');
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await openChangesPanel(page);
const readmePath = path.join(repo.path, 'README.md');
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
await appendFile(readmePath, `\n${lines}`);
await refreshUncommittedMode(page);
const scope = getChangesScope(page);
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
await fileToggle.click();
const markerLine = scope.getByText('Sticky header line 250').first();
await expect(markerLine).toBeVisible({ timeout: 30000 });
const scroll = scope.getByTestId('git-diff-scroll').first();
await expect(scroll).toBeVisible();
await expect.poll(async () => {
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
}).toBe(true);
await scroll.hover();
for (let i = 0; i < 12; i++) {
await page.mouse.wheel(0, 700);
}
await expect.poll(async () => {
return await scroll.evaluate((el) => el.scrollTop ?? 0);
}).toBeGreaterThan(0);
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
await expect(fileToggle).toBeVisible();
} finally {
await repo.cleanup();
}
});

View File

@@ -1,20 +1,28 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
type WaitForServerOptions = {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
};
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to acquire port')));
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
@@ -22,23 +30,153 @@ async function getAvailablePort(): Promise<number> {
});
}
async function waitForServer(port: number, timeout = 15000): Promise<void> {
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
add(line: string) {
lines.push(line);
if (lines.length > maxLines) {
lines.shift();
}
},
dump() {
return lines.join("\n");
},
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) {
return "";
}
const output = getRecentOutput().trim();
if (!output) {
return "";
}
return `\nRecent output:\n${output}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, 'localhost', () => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.on('error', reject);
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
return;
} catch {
} catch (error) {
lastConnectionError = error;
await new Promise((r) => setTimeout(r, 100));
}
}
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
const reason =
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
);
}
function parseRelayStartupFailure(line: string): string | null {
const clean = stripAnsi(line);
if (/Address already in use/i.test(clean)) {
return clean;
}
if (/failed: ::bind\(/i.test(clean)) {
return clean;
}
if (/Fatal uncaught/i.test(clean)) {
return clean;
}
return null;
}
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child) {
return;
}
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
resolve();
}, 5000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
return "empty response body";
}
if (trimmed.length <= 240) {
return trimmed;
}
return `${trimmed.slice(0, 240)}`;
}
async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean> {
const key = apiKey?.trim();
if (!key) {
return false;
}
try {
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.ok) {
return true;
}
const body = await response.text();
console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
);
return false;
} catch (error) {
console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
}
let daemonProcess: ChildProcess | null = null;
@@ -54,193 +192,364 @@ type OfferPayload = {
};
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
if (existsSync(relayDistEntry)) {
return;
}
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
cwd: repoRoot,
stdio: "inherit",
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const marker = "#offer=";
const idx = url.indexOf(marker);
if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`);
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, 'base64url').toString('utf8');
const json = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(json) as Partial<OfferPayload>;
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
if (!offer.serverId) throw new Error('offer.serverId missing');
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
return offer as OfferPayload;
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
const envTestPath = path.join(repoRoot, '.env.test');
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
const port = await getAvailablePort();
const relayPort = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
relayProcess = spawn(
'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
}
);
relayProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l) => l.trim());
for (const line of lines) {
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l) => l.trim());
for (const line of lines) {
console.error(`[relay] ${line}`);
}
});
await waitForServer(relayPort, 30000);
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, '..');
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: 'none', // Don't auto-open browser
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
metroProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((l) => l.trim());
for (const line of lines) {
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[metro] ${data.toString().trim()}`);
});
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
const tsxBin = execSync('which tsx').toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
const offerPromise = new Promise<void>((resolve) => {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
NODE_ENV: 'development',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
let stdoutBuffer = '';
daemonProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString('utf8');
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
} catch {
// ignore parsing failures
}
}
}
}
console.log(`[daemon] ${trimmed}`);
}
});
daemonProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[daemon] ${data.toString().trim()}`);
});
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port),
waitForServer(metroPort, 120000), // Metro can take longer to start
]);
// Wait for daemon to emit a pairing offer (includes relay session ID).
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
),
]);
if (!offerPayload) {
throw new Error('pairing_offer was not parsed from daemon logs');
}
const offer = offerPayload as OfferPayload;
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
return async () => {
if (daemonProcess) {
daemonProcess.kill('SIGTERM');
daemonProcess = null;
}
if (metroProcess) {
metroProcess.kill('SIGTERM');
metroProcess = null;
}
if (relayProcess) {
relayProcess.kill('SIGTERM');
relayProcess = null;
}
const cleanup = async () => {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
console.log('[e2e] Test daemon stopped');
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
".paseo",
"models",
"local-speech",
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error(
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
);
}
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
try {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
relayPort = await getAvailablePort();
relayLineBuffer = createLineBuffer();
let relayStartupFailureLine: string | null = null;
let relayReadyForSelectedPort = false;
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.error(`[relay] ${line}`);
}
});
try {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
});
const readyDeadline = Date.now() + 5000;
while (
!relayReadyForSelectedPort &&
relayStartupFailureLine === null &&
relayProcess?.exitCode === null &&
relayProcess?.signalCode === null &&
Date.now() < readyDeadline
) {
await sleep(100);
}
if (relayStartupFailureLine) {
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
}
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump,
)}`,
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump,
)}`,
);
}
relayStarted = true;
break;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
if (!relayStarted) {
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none", // Don't auto-open browser
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
const offerPromise = new Promise<void>((resolve) => {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes("pairing_offer")) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
} catch {
// ignore parsing failures
}
}
}
}
console.log(`[daemon] ${trimmed}`);
}
});
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),
]);
// Wait for daemon to emit a pairing offer (includes relay session ID).
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
),
]);
if (!offerPayload) {
throw new Error("pairing_offer was not parsed from daemon logs");
}
const offer = offerPayload as OfferPayload;
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
return async () => {
await cleanup();
console.log("[e2e] Test daemon stopped");
};
} catch (error) {
await cleanup();
throw error;
}
}

View File

@@ -0,0 +1,285 @@
import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
export type ScrollMetrics = {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
export type SeededAgent = {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
};
export type DaemonClientInstance = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function buildReplyBlock(label: string, lineCount = 14): string {
return Array.from({ length: lineCount }, (_, index) => {
const line = (index + 1).toString().padStart(2, "0");
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
}).join("\n");
}
function buildProtocolMessage(label: string, lineCount = 14): string {
return [
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
"Do not add extra words, bullets, markdown fences, or tool calls.",
"REPLY:",
buildReplyBlock(label, lineCount),
].join("\n");
}
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
message: string;
expectedReply: string;
} {
return {
message: buildReplyMessage(label),
expectedReply: buildReplyBlock(label),
};
}
type DaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: DaemonClientConfig,
) => DaemonClientInstance
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
};
return mod.DaemonClient;
}
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function seedBottomAnchorAgent(input: {
client: DaemonClientInstance;
cwd: string;
title?: string;
turnCount?: number;
lineCount?: number;
}): Promise<SeededAgent> {
const title = input.title ?? `bottom-anchor-${Date.now()}`;
const turnCount = Math.max(3, input.turnCount ?? 5);
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
);
}
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
for (let index = 1; index < turnCount; index += 1) {
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
expectedTailText = buildReplyBlock(label, lineCount);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
);
}
}
return {
id: created.id,
title,
expectedTailText,
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return getVisibleChatScroll(page).evaluate((root: Element) => {
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
.filter((element): element is HTMLElement => element instanceof HTMLElement)
.filter((element) => {
const tagName = element.tagName.toLowerCase();
const isEditable =
tagName === "textarea" ||
tagName === "input" ||
element.getAttribute("contenteditable") === "true";
return !isEditable && element.scrollHeight - element.clientHeight > 1;
});
const scrollElement =
candidates.sort(
(left, right) =>
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
)[0] ?? (root as HTMLElement);
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
return {
offsetY,
contentHeight,
viewportHeight,
distanceFromBottom,
};
});
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
const scrollViewport = getVisibleChatScroll(page);
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
let remaining = Math.max(0, pixels);
while (remaining > 0) {
const delta = Math.min(240, remaining);
await scrollViewport.evaluate((element: Element, step: number) => {
const scrollContainer = element as HTMLElement;
scrollContainer.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -step,
bubbles: true,
cancelable: true,
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
}, delta);
remaining -= delta;
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
return;
}
}
}
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
if (expectedTailText) {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(0);
}
}
export async function expectNearBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function expectDetachedFromBottom(page: Page): Promise<void> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
): Promise<ScrollMetrics> {
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight;
})
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}

View File

@@ -1,16 +1,19 @@
import { expect, type Page } from '@playwright/test';
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === '6767') {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
@@ -20,67 +23,58 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
} catch {
return true;
}
}, { expectedEndpoint, expectedServerId });
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: any) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
)
)
return true;
return !connections.some(
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
);
} catch {
return true;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) {
return;
}
const nowIso = new Date().toISOString();
const daemon = buildSeededHost({
serverId: expectedServerId,
endpoint: expectedEndpoint,
nowIso,
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ expectedEndpoint, nowIso, expectedServerId }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem(
'@paseo:daemon-registry',
JSON.stringify([
{
serverId: expectedServerId,
label: 'localhost',
connections: [
{
id: `direct:${expectedEndpoint}`,
type: 'direct',
endpoint: expectedEndpoint,
},
],
preferredConnectionId: `direct:${expectedEndpoint}`,
createdAt: nowIso,
updatedAt: nowIso,
},
])
);
localStorage.setItem(
'@paseo:create-agent-preferences',
JSON.stringify({
serverId: expectedServerId,
provider: 'claude',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
},
})
);
localStorage.removeItem('@paseo:settings');
({ daemon, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ expectedEndpoint, nowIso, expectedServerId }
{ daemon, preferences },
);
await page.reload();
@@ -91,80 +85,131 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0];
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
if (
Array.isArray(connections) &&
connections.some(
(c: any) =>
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
)
) {
throw new Error(
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
);
}
if (!snapshot.prefsRaw) {
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
}
}
export const gotoHome = async (page: Page) => {
await page.goto('/');
export const gotoAppShell = async (page: Page) => {
await page.goto("/");
await ensureE2EStorageSeeded(page);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole("textbox", { name: "Message agent..." });
if (
!(await composer
.first()
.isVisible()
.catch(() => false))
) {
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
const newAgentButton = page.getByText("New agent", { exact: true }).first();
await expect
.poll(
async () =>
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 },
)
.toBe(true);
if (await addProjectCta.isVisible().catch(() => false)) {
await addProjectCta.click();
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
await addProjectSidebar.click();
} else {
await newAgentButton.click();
}
}
await expect(composer.first()).toBeVisible({ timeout: 30000 });
};
export const openSettings = async (page: Page) => {
// Navigate directly to settings page
await page.goto('/settings');
await expect(page).toHaveURL(/\/settings$/);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
};
export const setWorkingDirectory = async (page: Page, directory: string) => {
@@ -173,22 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const input = page.getByRole('textbox', { name: '/path/to/project' });
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
const worktreePicker = page.getByTestId("worktree-attach-picker");
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
const closeBottomSheet = async () => {
const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' })
.first();
const bottomSheetHandle = page
.getByRole('slider', { name: 'Bottom sheet handle' })
.first();
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!(await bottomSheetBackdrop.isVisible())) {
return;
}
await bottomSheetBackdrop.click({ force: true });
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(200);
}
if (await bottomSheetBackdrop.isVisible()) {
@@ -208,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
return;
}
const attachToggle = page.getByTestId('worktree-attach-toggle');
const attachToggle = page.getByTestId("worktree-attach-toggle");
if (await attachToggle.isVisible()) {
await attachToggle.click({ force: true });
await page.waitForTimeout(200);
@@ -218,47 +260,74 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeWorktreeSheetIfOpen();
if (!(await input.isVisible())) {
const pickerInputVisible = async () =>
(await directorySearchInput.isVisible().catch(() => false)) ||
(await legacyInput.isVisible().catch(() => false));
if (!(await pickerInputVisible())) {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
if (!(await input.isVisible())) {
if (!(await pickerInputVisible())) {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect(input).toBeVisible();
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, '');
await input.fill(trimmedDirectory);
await input.press('Enter');
const trimmedDirectory = directory.replace(/\/+$/, "");
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
const useOption = page.getByTestId('working-directory-custom-option').first();
if (await useOption.isVisible().catch(() => false)) {
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
await useOption.click({ force: true });
await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
.first();
if (await plainOption.isVisible().catch(() => false)) {
await plainOption.click({ force: true });
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press("Enter");
}
} else {
// Legacy path picker fallback.
await activeInput.press("Enter");
}
// Wait for the sheet to close after clicking "Use"
await expect(input).not.toBeVisible({ timeout: 10000 });
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) {
if (trimmedDirectory.startsWith("/var/")) {
directoryCandidates.add(`/private${trimmedDirectory}`);
}
if (trimmedDirectory.startsWith('/private/var/')) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
await expect.poll(async () => {
const text = await workingDirectorySelect.innerText().catch(() => '');
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
}, { timeout: 30000 }).toBe(true);
await expect
.poll(
async () => {
const text = await workingDirectorySelect.innerText().catch(() => "");
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
},
{ timeout: 30000 },
)
.toBe(true);
};
export const ensureHostSelected = async (page: Page) => {
@@ -276,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId;
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
@@ -300,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
if (await input.isEditable()) {
return;
}
const selectHostLabel = page.getByText('Select host', { exact: true });
const selectHostLabel = page.getByText("Select host", { exact: true });
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText('localhost', { exact: true }).first();
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
.first();
if (await localhostOption.isVisible()) {
await localhostOption.click();
@@ -327,18 +400,90 @@ export const ensureHostSelected = async (page: Page) => {
};
export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable();
await preferFastThinkingOption(page);
await input.fill(message);
await input.press('Enter');
await input.press("Enter");
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
// The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here.
await expect(page).toHaveURL(/\/(workspace|agent|new-agent)(\/|$|\?)/, { timeout: 30000 });
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
};
async function preferFastThinkingOption(page: Page): Promise<void> {
const providerTrigger = page
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first();
if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
if (!/codex/i.test(providerText)) {
return;
}
}
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return;
}
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
.trim()
.toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
return;
}
await thinkingTrigger.click();
const menu = page.getByTestId("agent-thinking-menu").first();
if (!(await menu.isVisible().catch(() => false))) {
return;
}
const preferredLabels = ["low", "minimal", "off", "medium"];
let selected = false;
for (const label of preferredLabels) {
const option = menu
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
.first();
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true });
selected = true;
break;
}
}
if (!selected) {
const options = menu.getByRole("button");
const count = await options.count();
for (let index = 0; index < count; index += 1) {
const option = options.nth(index);
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
if (!label) {
continue;
}
if (label.toLowerCase() === currentThinkingLabel) {
continue;
}
await option.click({ force: true });
selected = true;
break;
}
}
if (!selected) {
await page.keyboard.press("Escape").catch(() => undefined);
return;
}
await expect(menu).not.toBeVisible({ timeout: 5000 });
}
export interface AgentConfig {
directory: string;
provider?: string;
@@ -348,54 +493,124 @@ export interface AgentConfig {
}
export const selectProvider = async (page: Page, provider: string) => {
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
const normalizedProvider = provider.trim();
if (!normalizedProvider) {
throw new Error("Provider must be a non-empty string.");
}
const option = page.getByText(provider, { exact: true }).first();
const providerTrigger = page
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first();
if (
await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
) {
return;
}
if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click();
} else {
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
}
const dialog = page.getByRole("dialog").last();
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider);
}
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click();
};
export const selectModel = async (page: Page, model: string) => {
const modelLabel = page.getByText('MODEL', { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
const normalizedModel = model.trim();
if (!normalizedModel) {
throw new Error("Model must be a non-empty string.");
}
const modelTrigger = page
.locator(
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
)
.first();
if (
await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
) {
return;
}
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
// Wait for the model dropdown to open
const searchInput = page.getByRole('textbox', { name: /search model/i });
const searchInput = page.getByRole("textbox", { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models
await searchInput.fill(model);
await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
const dialog = page.getByRole("dialog");
const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first();
await expect(option).toBeVisible({ timeout: 30000 });
await option.click({ force: true });
const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) {
await exactOption.click({ force: true });
} else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm.
await searchInput.press("Enter");
}
// Wait for dropdown to close
if (await searchInput.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => undefined);
}
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
};
export const selectMode = async (page: Page, mode: string) => {
const modeLabel = page.getByText('MODE', { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
const modeTrigger = page
.locator(
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
)
.first();
if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click();
} else {
const modeLabel = page.getByText("MODE", { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
}
// Wait for the mode dropdown to open
const searchInput = page.getByRole('textbox', { name: /search mode/i });
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to filter modes
await searchInput.fill(mode);
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
const dialog = page.getByRole("dialog");
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click({ force: true });
@@ -423,26 +638,36 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
await createAgent(page, config.prompt);
};
export const createAgentInRepo = async (
page: Page,
config: Pick<AgentConfig, "directory" | "prompt">,
) => {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, config.directory);
await createAgent(page, config.prompt);
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId('permission-request-question').first();
const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId('permission-request-accept').first();
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId('permission-request-deny').first();
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
@@ -459,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
// If no specific message, just wait for the button to disappear
});
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });

View File

@@ -0,0 +1,266 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
};
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId?: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
type ArchiveTabDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: ArchiveTabDaemonClientConfig,
) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "default",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
);
}
return {
id: created.id,
title: input.title,
cwd: input.cwd,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
await page.goto("/");
}
export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.goto("/");
}
export async function openWorkspaceWithAgents(
page: Page,
agents: [ArchiveTabAgent, ArchiveTabAgent],
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function expectWorkspaceArchiveOutcome(
page: Page,
input: { archivedAgentId: string; survivingAgentId: string },
): Promise<void> {
await expectWorkspaceTabHidden(page, input.archivedAgentId);
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
await waitForWorkspaceTabsVisible(page);
}
export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
}
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
): Promise<void> {
const row = getSessionRowByTitle(page, input.title);
await expect(row).toBeVisible({ timeout: 30_000 });
const box = await row.boundingBox();
if (!box) {
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
await expectSessionRowArchived(page, input.title);
}

View File

@@ -0,0 +1,39 @@
export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
return {
id: `direct:${endpoint}`,
type: "directTcp" as const,
endpoint,
};
}
export function buildSeededHost(input: {
serverId: string;
endpoint: string;
label?: string;
nowIso: string;
}) {
const connection = buildDirectTcpConnection(input.endpoint);
return {
serverId: input.serverId,
label: input.label ?? TEST_HOST_LABEL,
connections: [connection],
preferredConnectionId: connection.id,
createdAt: input.nowIso,
updatedAt: input.nowIso,
};
}
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
};
}

View File

@@ -0,0 +1,27 @@
import WebSocket from "ws";
type WebSocketLike = {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
export type NodeWebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> },
) => WebSocketLike;
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
return (url: string, options?: { headers?: Record<string, string> }) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
}

View File

@@ -0,0 +1,237 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
type TerminalPerfDaemonClientConfig = {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
async function loadDaemonClientConstructor(): Promise<
new (
config: TerminalPerfDaemonClientConfig,
) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(__dirname, "../../../../");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
function buildWorkspaceUrl(cwd: string): string {
return buildHostWorkspaceRoute(getServerId(), cwd);
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
await page.waitForURL(
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
{ timeout: 15_000 },
);
// Wait for daemon connection (sidebar shows host label)
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 15_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.scrollIntoViewIfNeeded();
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}

View File

@@ -0,0 +1,52 @@
import { expect, type Page } from "@playwright/test";
export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator('[data-testid^="workspace-tab-"]');
const count = await tabs.count();
const ids: string[] = [];
for (let index = 0; index < count; index += 1) {
const testId = await tabs.nth(index).getAttribute("data-testid");
if (testId && !ids.includes(testId)) {
ids.push(testId);
}
}
return ids;
}
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
timeout: 30_000,
});
}
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
if (!(await toggle.isVisible().catch(() => false))) {
return;
}
const isExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
if (isExpanded) {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false", {
timeout: 10_000,
});
}
}
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {},
): Promise<string[][]> {
const durationMs = options.durationMs ?? 2_500;
const intervalMs = options.intervalMs ?? 50;
const snapshots: string[][] = [];
const start = Date.now();
while (Date.now() - start <= durationMs) {
snapshots.push(await getWorkspaceTabTestIds(page));
await page.waitForTimeout(intervalMs);
}
return snapshots;
}

View File

@@ -0,0 +1,74 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith("/var/")) {
candidates.add(`/private${trimmed}`);
}
if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, ""));
}
return Array.from(candidates);
}
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
const ids = candidateWorkspaceIds(workspacePath).map(
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
);
return page.locator(ids.join(",")).first();
}
export async function switchWorkspaceViaSidebar(input: {
page: Page;
serverId: string;
targetWorkspacePath: string;
}): Promise<void> {
const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000,
});
}
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string },
): Promise<void> {
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,
});
await expect(subtitleLocator.first()).toHaveText(input.subtitle, {
timeout: 30_000,
});
}
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(marker);
await input.press("Enter");
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}

View File

@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
path: string;
@@ -9,26 +9,29 @@ type TempRepo = {
};
export const createTempGitRepo = async (
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
): Promise<TempRepo> => {
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, 'remote.git');
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
}
return {

View File

@@ -1,42 +0,0 @@
import { test, expect } from './fixtures';
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
});
await page.goto('/');
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
await page.getByText('Direct connection', { exact: true }).click();
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect', { exact: true }).click();
// First-time connection prompts for an optional label.
const nameModal = page.getByTestId('name-host-modal');
const showedNameModal = await nameModal
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (showedNameModal) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
});

View File

@@ -1,109 +0,0 @@
import { test, expect } from './fixtures';
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const seededServerId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!seededServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
const extraPort = Number(daemonPort) + 1;
const extraEndpoint = `127.0.0.1:${extraPort}`;
const nowIso = new Date().toISOString();
const extraDaemon = {
serverId: 'srv_e2e_extra_daemon',
label: 'extra',
connections: [
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
],
preferredConnectionId: `direct:${extraEndpoint}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const seededTestDaemon = {
serverId: seededServerId,
label: 'localhost',
connections: [
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
// Add a second host once (fixtures seed the primary host on every navigation).
await page.addInitScript(
({ daemon, seededTestDaemon, seedOnceKey }) => {
if (localStorage.getItem(seedOnceKey)) {
return;
}
const raw = localStorage.getItem('@paseo:daemon-registry');
let parsed: any[] = [];
if (raw) {
try {
parsed = JSON.parse(raw);
} catch {
parsed = [];
}
}
const list = Array.isArray(parsed) ? parsed : [];
localStorage.setItem(seedOnceKey, '1');
const next = [...list];
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
if (!hasSeeded) {
next.push(seededTestDaemon);
}
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
if (!alreadyPresent) {
next.push(daemon);
}
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
},
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
);
await page.goto('/settings');
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
await page.getByTestId('remove-host-confirm').click();
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
await page.waitForFunction(
(serverId) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
} catch {
return false;
}
},
extraDaemon.serverId,
{ timeout: 10000 }
);
// Prevent the fixture from overwriting storage on reload; verify persistence.
await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
});
await page.reload();
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
});

View File

@@ -1,118 +0,0 @@
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome } from './helpers/app';
test('new agent auto-selects the previous host', async ({ page }) => {
await gotoHome(page);
await ensureHostSelected(page);
await gotoHome(page);
// The selected host should be restored after a full reload without manual selection.
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent respects serverId in the URL', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
// Ensure this test's storage is deterministic even under parallel load.
const nowIso = new Date().toISOString();
const testDaemon = {
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
serverId: testDaemon.serverId,
provider: 'claude',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
},
};
await page.goto('/settings');
await page.evaluate(
({ daemon, preferences }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon, preferences: createAgentPreferences }
);
await page.reload();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const testDaemon = {
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.goto('/');
await page.evaluate(
({ daemon }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon }
);
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
// Host should be auto-selected (no manual selection required).
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});

View File

@@ -1,17 +0,0 @@
import { test, expect } from "./fixtures";
import { gotoHome } from "./helpers/app";
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
await gotoHome(page);
await page.getByTestId("menu-button").first().focus();
await page.keyboard.press("Shift+/");
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(content).toBeVisible({ timeout: 10000 });
await expect(content).toContainText("Show keyboard shortcuts");
await expect(content).toContainText("Toggle left sidebar");
});

View File

@@ -1,58 +0,0 @@
import { test, expect } from './fixtures';
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
// Override the default fixture seeding for this navigation (must run before app boot).
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.goto('/settings');
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Direct connection', { exact: true }).click();
const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible();
await input.fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect', { exact: true }).click();
const nameModal = page.getByTestId('name-host-modal');
await expect(nameModal).toBeVisible({ timeout: 15000 });
await nameModal.getByTestId('name-host-skip').click();
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
await page.waitForFunction(
({ port, serverId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
return (
entry?.serverId === serverId &&
Array.isArray(entry?.connections) &&
entry.connections.some(
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
)
);
} catch {
return false;
}
},
{ port: daemonPort, serverId },
{ timeout: 10000 }
);
});

View File

@@ -1,68 +0,0 @@
import { test, expect } from './fixtures';
import { Buffer } from 'node:buffer';
function encodeBase64Url(input: string): string {
return Buffer.from(input, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
// Override the default fixture seeding for this test.
await page.goto('/settings');
await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.reload();
const offer = {
v: 2 as const,
serverId: 'e2e-server-123',
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'),
relay: { endpoint: 'relay.local:443' },
};
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Paste pairing link', { exact: true }).click();
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible();
await input.fill(offerUrl);
await page.getByText('Pair', { exact: true }).click();
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await page.waitForFunction(
({ expected }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
const relayId = `relay:${expected.relay.endpoint}`;
return (
entry?.serverId === expected.serverId &&
Array.isArray(entry?.connections) &&
entry.connections.length === 1 &&
entry.connections[0]?.id === relayId &&
entry.connections[0]?.type === 'relay' &&
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
);
} catch {
return false;
}
},
{ expected: offer },
{ timeout: 10000 }
);
});

View File

@@ -1,93 +0,0 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test, expect } from './fixtures';
import {
createAgentWithConfig,
waitForPermissionPrompt,
allowPermission,
denyPermission,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
const FILE_CONTENT = 'Hello from permission test';
function buildWriteCommand(filePath: string): string {
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
}
test.describe('permission prompts', () => {
test('allow permission creates the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-allow-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
directory: repo.path,
model: 'haiku',
mode: 'Always Ask',
prompt,
});
await waitForPermissionPrompt(page, 30000);
await allowPermission(page);
// Wait for file to be created
await expect
.poll(() => existsSync(filePath), {
message: `File ${filePath} should exist after allowing permission`,
timeout: 30000,
})
.toBe(true);
// After allowing, the file should be created successfully
// The tool call count might still be 1 if the UI updates quickly
const fileContent = await readFile(filePath, 'utf-8');
expect(fileContent.trim()).toBe(FILE_CONTENT);
} finally {
await repo.cleanup();
}
});
test('deny permission does not create the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-deny-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
directory: repo.path,
model: 'haiku',
mode: 'Always Ask',
prompt,
});
await waitForPermissionPrompt(page, 30000);
await denyPermission(page);
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
timeout: 30_000,
});
expect(existsSync(filePath)).toBe(false);
} finally {
await repo.cleanup();
}
});
});

View File

@@ -1,25 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
const promptText = `Test prompt that should be preserved ${Date.now()}`;
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, nonExistentDir);
// Enter prompt text
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(promptText);
// Try to submit - this should fail with an error about the directory not existing
await input.press('Enter');
// Verify error message is displayed (error includes the path)
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
// Verify the prompt text is still preserved in the input
await expect(input).toHaveValue(promptText);
});

View File

@@ -1,42 +0,0 @@
import { test, expect } from './fixtures';
test('connects via relay when direct endpoints fail', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
serverId,
label: 'relay-daemon',
connections: [
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
],
preferredConnectionId: 'direct:127.0.0.1:9',
createdAt: nowIso,
updatedAt: nowIso,
};
// Override the default fixture seeding for this test.
await page.goto('/settings');
await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
// Should eventually connect through the relay connection.
const card = page.getByTestId(`daemon-card-${serverId}`);
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
});

View File

@@ -1,57 +0,0 @@
import { test, expect } from './fixtures';
test('relay connection stays stable across multiple tabs', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
serverId,
label: 'relay-daemon',
connections: [
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
],
preferredConnectionId: 'direct:127.0.0.1:9',
createdAt: nowIso,
updatedAt: nowIso,
};
// Use relay by making the direct endpoint intentionally fail.
await page.goto('/settings');
await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
const card = page.getByTestId(`daemon-card-${serverId}`);
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
const page2 = await page.context().newPage();
await page2.route(/:(6767)\b/, (route) => route.abort());
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
});
await page2.goto('/settings');
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
// Stability window: keep both tabs open and ensure they remain online.
await page.waitForTimeout(30_000);
await expect(card.getByText('Online', { exact: true })).toBeVisible();
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
});

View File

@@ -1,23 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
const repoA = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repoA.path);
await createAgent(page, 'Agent A: respond with exactly A');
await expect(page).toHaveURL(/\/agent\//);
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/);
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
} finally {
await repoA.cleanup();
}
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
await gotoHome(page);
await openSettings(page);
const menuButton = page.getByRole('button', { name: /menu/i }).first();
await expect(menuButton).toBeVisible();
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
await menuButton.focus();
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();
// Tooltip should also appear on hover.
await menuButton.blur();
await expect(tooltip).toHaveCount(0);
await menuButton.hover();
await expect(tooltip).toBeVisible();
const triggerBox = await menuButton.boundingBox();
const tooltipBox = await tooltip.boundingBox();
expect(triggerBox).not.toBeNull();
expect(tooltipBox).not.toBeNull();
if (!triggerBox || !tooltipBox) return;
// side=right => tooltip starts to the right of the trigger.
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
// Keep it reasonably close (should be ~trigger.right + offset).
const expectedX = triggerBox.x + triggerBox.width + 8;
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
expect(tooltipBox.width).toBeLessThanOrEqual(500);
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
expect(tooltipBox.height).toBeLessThanOrEqual(80);
// align=center => centers should be roughly aligned (allow some clamping tolerance).
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
});

View File

@@ -1,621 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import {
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
const pathname = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const modernMatch = pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
if (modernMatch) {
return {
serverId: decodeURIComponent(modernMatch[1]),
agentId: decodeURIComponent(modernMatch[2]),
};
}
const legacyMatch = pathname.match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (legacyMatch) {
return {
serverId: decodeURIComponent(legacyMatch[1]),
agentId: decodeURIComponent(legacyMatch[2]),
};
}
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
}
async function openAgentFromSidebar(page: Page, serverId: string, agentId: string): Promise<void> {
await gotoHome(page);
const row = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect
.poll(
() => {
try {
const parsed = parseAgentFromUrl(page.url());
return `${parsed.serverId}:${parsed.agentId}`;
} catch {
return "";
}
},
{ timeout: 30000 }
)
.toBe(`${serverId}:${agentId}`);
}
async function openNewAgentDraft(page: Page): Promise<void> {
await gotoHome(page);
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await expect(
page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({
timeout: 30000,
});
}
async function openTerminalsPanel(page: Page): Promise<void> {
let header = page.locator('[data-testid="explorer-header"]:visible').first();
if (!(await header.isVisible().catch(() => false))) {
const toggle = page.getByRole("button", {
name: /open explorer|close explorer|toggle explorer/i,
});
if (await toggle.first().isVisible().catch(() => false)) {
await toggle.first().click();
}
}
header = page.locator('[data-testid="explorer-header"]:visible').first();
await expect(header).toBeVisible({ timeout: 30000 });
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
timeout: 30000,
});
}
async function openFilesPanel(page: Page): Promise<void> {
const filesTab = page.getByTestId("explorer-tab-files").first();
await expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click();
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
timeout: 30000,
});
}
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
return await page.evaluate(() => {
const raw = localStorage.getItem("panel-state");
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
state?: { desktop?: { agentListOpen?: boolean } };
};
const value = parsed?.state?.desktop?.agentListOpen;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
await expect
.poll(async () => await tabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(2);
await tabs.last().click();
}
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
await expect(firstTab).toBeVisible({ timeout: 30000 });
const value = await firstTab.getAttribute("data-testid");
if (!value) {
throw new Error("Expected terminal tab test id");
}
return value;
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function runTerminalCommandWithPreEnterEcho(
page: Page,
command: string,
expectedText: string
): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await expect(surface).toContainText(command, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
await expect
.poll(
async () =>
await page.evaluate((target) => {
const terminal = (window as any).__paseoTerminal;
if (!terminal?.buffer?.active?.getLine || !terminal?.buffer?.active?.getNullCell) {
return false;
}
const buffer = terminal.buffer.active;
const nullCell = buffer.getNullCell();
const lineCount = buffer.length ?? 0;
const cols = terminal.cols ?? 0;
for (let y = 0; y < lineCount; y += 1) {
const line = buffer.getLine(y);
if (!line) continue;
const lineText = line.translateToString(true);
const index = lineText.indexOf(target);
if (index === -1) continue;
for (let x = index; x < index + target.length && x < cols; x += 1) {
const cell = line.getCell(x, nullCell);
if (!cell) continue;
if (!cell.isFgDefault()) {
return true;
}
}
}
return false;
}, marker),
{ timeout: 30000 }
)
.toBe(true);
}
test("Terminals tab creates multiple terminals and streams command output", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminals-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Reply with exactly: terminal smoke");
await openTerminalsPanel(page);
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
timeout: 30000,
});
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
await runTerminalCommandWithPreEnterEcho(
page,
`echo ${preEnterEchoMarker}`,
preEnterEchoMarker
);
const ansiMarker = `ansi-red-${Date.now()}`;
await runTerminalCommand(
page,
`printf '\\033[31m${ansiMarker}\\033[0m\\n'`,
ansiMarker
);
await expectAnsiColorApplied(page, ansiMarker);
const markerOne = `terminal-smoke-one-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
await page.getByTestId("terminals-create-button").first().click();
await selectNewestTerminalTab(page);
const markerTwo = `terminal-smoke-two-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerTwo}`, markerTwo);
} finally {
await repo.cleanup();
}
});
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
await runTerminalCommand(
page,
"for i in $(seq 1 12000); do echo reattach-$i; done",
"reattach-12000"
);
for (let attempt = 0; attempt < 4; attempt += 1) {
await openFilesPanel(page);
await openTerminalsPanel(page);
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
timeout: 30000,
});
}
const marker = `reattach-health-${Date.now()}`;
await runTerminalCommand(page, `echo ${marker}`, marker);
} finally {
await repo.cleanup();
}
});
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
for (let iteration = 0; iteration < 40; iteration += 1) {
await page.keyboard.press("Enter");
}
const markerAfterEnters = `echo-visible-${Date.now()}`;
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterEnters, {
timeout: 30000,
});
const longSuffix = "x".repeat(120);
await page.keyboard.type(`echo ${longSuffix}`, { delay: 0 });
for (let iteration = 0; iteration < longSuffix.length; iteration += 1) {
await page.keyboard.press("Backspace");
}
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterBackspace, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal remains interactive after alternate-screen enter/exit", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-alt-screen-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(
"printf '\\033[?1049h\\033[2J\\033[HALT\\033[?1049l\\n'",
{ delay: 0 }
);
await page.keyboard.press("Enter");
const marker = `post-alt-screen-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal tab is removed when shell exits", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal exit flow");
await openTerminalsPanel(page);
const exitedTabTestId = await getFirstTerminalTabTestId(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("exit", { delay: 1 });
await page.keyboard.press("Enter");
await expect(page.getByTestId(exitedTabTestId)).toHaveCount(0, {
timeout: 30000,
});
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
timeout: 30000,
});
const nextTabTestId = await getFirstTerminalTabTestId(page);
expect(nextTabTestId).not.toBe(exitedTabTestId);
} finally {
await repo.cleanup();
}
});
test("terminals are shared by agents on the same cwd", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent one");
const first = parseAgentFromUrl(page.url());
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent two");
const second = parseAgentFromUrl(page.url());
expect(first.serverId).toBe(second.serverId);
expect(first.agentId).not.toBe(second.agentId);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await page.getByTestId("terminals-create-button").first().click();
await selectNewestTerminalTab(page);
await openAgentFromSidebar(page, second.serverId, second.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
const sharedMarker = `shared-terminal-${Date.now()}`;
await runTerminalCommand(page, `echo ${sharedMarker}`, sharedMarker);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal key combo capture");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("cat -v", { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText("cat -v", { timeout: 30000 });
await page.keyboard.press("Escape");
await expect(surface).toContainText("^[", { timeout: 30000 });
await page.keyboard.press("Control+C");
await expect(surface).toContainText("^C", { timeout: 30000 });
await page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 });
const marker = `terminal-key-capture-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(false);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
return typeof terminal?.rows === "number" ? terminal.rows : 0;
});
}
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
await page.evaluate((nextPadding) => {
const container = document.querySelector<HTMLElement>(
'[data-testid="explorer-content-area"]'
);
if (!container) {
return;
}
container.style.boxSizing = "border-box";
container.style.paddingBottom = nextPadding + "px";
}, padding);
}
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (
window as {
__paseoTerminal?: {
buffer?: { active?: { baseY?: unknown; viewportY?: unknown } };
};
}
).__paseoTerminal;
const baseY = terminal?.buffer?.active?.baseY;
const viewportY = terminal?.buffer?.active?.viewportY;
if (typeof baseY !== "number" || typeof viewportY !== "number") {
return 0;
}
return Math.max(0, baseY - viewportY);
});
}
test("terminal viewport resizes and uses xterm scrollback", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-viewport-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Viewport and scrollback test");
await openTerminalsPanel(page);
const initialViewport = page.viewportSize();
if (!initialViewport) {
throw new Error("Expected a viewport size");
}
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThan(0);
const initialRows = await getTerminalRows(page);
await setExplorerContentBottomPadding(page, 220);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await setExplorerContentBottomPadding(page, 0);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const reducedHeight = Math.max(520, initialViewport.height - 220);
await page.setViewportSize({
width: initialViewport.width,
height: reducedHeight,
});
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await page.setViewportSize(initialViewport);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const scrollbackMarker = `scrollback-${Date.now()}`;
await runTerminalCommand(
page,
`for i in $(seq 1 180); do echo ${scrollbackMarker}-$i; done`,
`${scrollbackMarker}-180`
);
const surface = page.getByTestId("terminal-surface").first();
await surface.hover();
await page.mouse.wheel(0, -3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeGreaterThan(0);
const distanceAfterScrollUp = await getTerminalScrollbackDistance(page);
await surface.hover();
await page.mouse.wheel(0, 3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeLessThan(distanceAfterScrollUp);
} finally {
await repo.cleanup();
}
});

View File

@@ -0,0 +1,165 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(
page,
(text) => text.includes(sentinel),
THROUGHPUT_BUDGET_MS + 15_000,
);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(
elapsedMs,
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
).toBeLessThan(THROUGHPUT_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});

View File

@@ -1,6 +1,7 @@
{
"cli": {
"version": ">= 14.0.0"
"version": ">= 14.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
@@ -18,6 +19,9 @@
"channel": "production",
"env": {
"APP_VARIANT": "production"
},
"android": {
"autoIncrement": "versionCode"
}
},
"production-apk": {
@@ -33,6 +37,9 @@
"production": {
"ios": {
"ascAppId": "6758887924"
},
"android": {
"releaseStatus": "draft"
}
}
}

View File

@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
ignores: ["dist/*"],
},
]);

View File

@@ -2,6 +2,10 @@
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
import "expo-router/entry";

2
packages/app/maestro/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Maestro takeScreenshot artifacts
*.png

View File

@@ -0,0 +1,31 @@
appId: sh.paseo
---
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- swipe:
direction: LEFT
duration: 300
# Small pause for close animation
- takeScreenshot: 00-sidebar-closed
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
- swipe:
start: "5%,50%"
end: "80%,50%"
duration: 300
# Verify sidebar opened
- assertVisible:
id: "sidebar-sessions"
- takeScreenshot: 01-sidebar-opened
# Close sidebar via swipe left
- swipe:
direction: LEFT
duration: 300
- takeScreenshot: 02-sidebar-closed-again

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Verification loop for sidebar theme bug.
#
# Maestro can't toggle iOS appearance, so this script bridges the gap:
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
# still works. Runs N iterations to catch intermittent failures.
#
# Usage:
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
ITERATIONS="${1:-3}"
WAIT_SECS="${2:-1}"
mkdir -p "$OUT_DIR"
echo "=== Sidebar Theme Bug Verification ==="
echo "Output dir: $OUT_DIR"
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
FAILURES=0
for i in $(seq 1 "$ITERATIONS"); do
echo ""
echo "========== Iteration $i / $ITERATIONS =========="
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
echo "Current appearance: $CURRENT"
if [ "$CURRENT" = "dark" ]; then
xcrun simctl ui booted appearance light
echo "Switched to light mode"
else
xcrun simctl ui booted appearance dark
echo "Switched to dark mode"
fi
echo "Waiting ${WAIT_SECS}s..."
sleep "$WAIT_SECS"
ITER_DIR="$OUT_DIR/iter-$i"
mkdir -p "$ITER_DIR"
# Run maestro from the output dir so takeScreenshot artifacts land there
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
echo " -> PASS (iteration $i)"
else
echo " -> FAIL (iteration $i) — bug reproduced!"
FAILURES=$((FAILURES + 1))
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
fi
done
# Restore to dark mode
xcrun simctl ui booted appearance dark
echo ""
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
echo "Output: $OUT_DIR"

View File

@@ -0,0 +1,86 @@
const { getDefaultConfig } = require("expo/metro-config");
const { resolve } = require("metro-resolver");
const fs = require("fs");
const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.trim()
.replace(/^\./, "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
const escapedAppSrcRoot = appSrcRoot
.split(path.sep)
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
.join("[\\\\/]");
const pathSeparatorPattern = "[\\\\/]";
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
react: path.join(appNodeModulesRoot, "react"),
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
};
config.resolver.blockList = new RegExp(
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
);
function isLocalModuleImport(moduleName) {
return (
moduleName.startsWith("./") ||
moduleName.startsWith("../") ||
moduleName.startsWith("@/") ||
path.isAbsolute(moduleName)
);
}
function resolveWithCustomWebOverlay(context, moduleName, platform) {
const shouldResolveCustomWebVariant =
platform === "web" &&
customWebPlatform.length > 0 &&
customWebPlatform !== "web" &&
isLocalModuleImport(moduleName);
if (shouldResolveCustomWebVariant) {
const overlayContext = {
...context,
// Resolve only "<custom-platform>.<ext>" variants in overlay mode.
sourceExts: context.sourceExts.map((ext) => `${customWebPlatform}.${ext}`),
preferNativePlatform: false,
};
try {
return defaultResolveRequest(overlayContext, moduleName, null);
} catch {
// Ignore overlay misses and continue with normal web resolution.
}
}
return defaultResolveRequest(context, moduleName, platform);
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
const origin = context.originModulePath;
if (
origin &&
(origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
moduleName.endsWith(".js")
) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {
return resolveWithCustomWebOverlay(context, tsModuleName, platform);
}
}
return resolveWithCustomWebOverlay(context, moduleName, platform);
};
module.exports = config;

View File

@@ -1,45 +0,0 @@
const { getDefaultConfig } = require("expo/metro-config");
const { resolve } = require("metro-resolver");
const fs = require("fs");
const path = require("path");
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// This app imports TypeScript sources from sibling workspaces (server/relay).
// Metro's default hierarchical lookup won't find hoisted deps when resolving
// from those out-of-tree source files, so point Metro at the monorepo root.
config.watchFolders = Array.from(
new Set([...(config.watchFolders ?? []), monorepoRoot])
);
config.resolver.nodeModulesPaths = Array.from(
new Set([
...(config.resolver.nodeModulesPaths ?? []),
path.join(projectRoot, "node_modules"),
path.join(monorepoRoot, "node_modules"),
])
);
config.resolver.resolveRequest = (context, moduleName, platform) => {
const origin = context.originModulePath;
if (
origin &&
(origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
moduleName.endsWith(".js")
) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {
return defaultResolveRequest(context, tsModuleName, platform);
}
}
return defaultResolveRequest(context, moduleName, platform);
};
module.exports = config;

View File

@@ -1,16 +1,18 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.6",
"version": "0.1.53",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:prod": "npm run android:production",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
@@ -20,27 +22,20 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
"build:web": "npm run build:workspace-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.6",
"@getpaseo/expo-two-way-audio": "0.1.53",
"@getpaseo/highlight": "0.1.53",
"@getpaseo/server": "0.1.53",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/python": "^1.1.18",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -48,8 +43,15 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
@@ -79,8 +81,8 @@
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.4",
"react-dom": "19.1.4",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
@@ -88,7 +90,7 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "^0.30.0",
"react-native-nitro-modules": "0.33.8",
"react-native-permissions": "^5.4.2",
"react-native-popover-view": "^6.1.0",
"react-native-reanimated": "~4.1.1",
@@ -106,12 +108,15 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.59.1"
"wrangler": "^4.59.1",
"ws": "^8.20.0"
}
}

View File

@@ -1,29 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
// This allows multiple test runs in parallel across different worktrees
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
// E2E tests share a single daemon/relay/metro stack from global setup.
// Running tests concurrently causes cross-test contention and non-deterministic failures.
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
reporter: [["list"]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
],
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs

View File

@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "Desktop Firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});

View File

@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "Desktop Safari",
use: { ...devices["Desktop Safari"] },
},
],
});

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="%LANG_ISO_CODE%">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
html,
body {
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {
html, body {
background-color: #181B1A;
}
}
/* These styles make the root element full-height */
#root {
display: flex;
height: 100%;
flex: 1;
}
</style>
<style>
button,
a,
input,
textarea,
select,
[role='button'],
[role='link'],
[role='textbox'],
[role='combobox'],
[role='tab'],
[role='switch'],
[role='checkbox'],
[role='slider'],
[role='menuitem'],
[tabindex],
[contenteditable='true'] {
-webkit-app-region: no-drag !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

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