* fix(app): add missing i18n keys for host settings
The client UI i18n migration (#1282) referenced translation keys that
were never added to any locale file, so i18next rendered the raw key
string in Settings → Host (e.g. a button labeled
"settings.host.daemon.restart.confirm").
Add the six missing keys to all six locales with real translations:
- settings.host.daemon.rename.submit
- settings.host.daemon.restart.confirm
- settings.host.orchestration.systemPrompt.{edit,reset,save,saving}
The seventh broken reference, in pair-device-modal.tsx, used the wrong
namespace (settings.integrations.pairDevices.rowTitle); the key is
defined at settings.host.pairDevices.rowTitle. Point the reference there.
Reported in https://github.com/getpaseo/paseo/pull/1282#issuecomment-4686672699
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): correct machine-translated Save/Saving labels (es/fr/ar)
#1282 left several Save/Saving labels machine-translated into the
"economize money" sense rather than the persist-data sense, so users saw
"Ahorro..." (es), "Économie..." (fr), and "توفير..." (ar) where "Saving..."
was meant.
Fix renameModal.saving and settings.project.actions.{save,saving} in the
three affected locales. ar's actions.save also moves from the conjugated
"يحفظ" to the standard button form "حفظ". Wording matches each locale's
already-correct siblings (e.g. es "Proyecto guardado") and the keys added
in the previous commit. ru and zh-CN were already correct and are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the worktree service, setup cache seeding, and Metro preview mount for /.sim. Also includes the related startup and mobile sidebar fixes needed by the updated worktree app flow.
* feat(server): refresh PR state promptly after agent merges via gh CLI
When an agent merges a PR with gh pr merge, the app could take up to
2 minutes to show the merged state because:
- gh pr merge is a remote GitHub operation that does not touch local git refs,
so file watchers never fire
- the GitHub self-heal poll runs on a 2-minute slow interval for settled PRs
- no refresh is triggered when agent tool calls complete
Add a detection path that intercepts completed shell tool calls in the
agent manager and signals the workspace git service when a command may
have changed external state (gh *, git push, npm publish). The git
service then invalidates the GitHub cache and schedules a forced
GitHub-inclusive refresh, so the updated PR state arrives within ~500ms.
The agent manager does not leak cache semantics; it calls a semantic
onWorkspaceStateMayHaveChanged callback and lets the git service decide
what that means internally.
* refactor(server): share workspace refresh merge logic
Updates the landing page featured agent badges and multi-provider section to show market leaders: Claude Code, Codex, OpenCode, Pi, and Cursor. Replaces the Copilot and OMP badges, updates FAQ references, and uses the proper Cursor SVG from the app catalog.
* Preserve Pi import model and thinking
* Format Pi import config helper
* Preserve Pi thinking in large imports
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Adds a new conceptual Workspaces doc that explains how Paseo organizes work around projects, workspaces, and sessions. Reframed the existing Git worktrees page as the technical deep-dive it always was. Updated glossary with agent session and tab terminology. Reordered docs nav so workspaces and worktrees come before providers.
Registers the Google Antigravity IDE so the workspace Open-in-editor
picker detects and launches it (PATH probe for the "antigravity"
command), alongside Cursor, VS Code, WebStorm, and Zed.
- desktop: add the target to BUILT_IN_EDITOR_TARGETS (kind: editor).
- app: add "antigravity" to the known editor-target id set and map its
bundled icon.
Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
* Add Claude Fable 5 to the model catalog
Fable 5 (`claude-fable-5`) is the new top-tier Claude model above Opus,
but the Claude provider's model list is hardcoded in `CLAUDE_MODELS` and
was not fetched dynamically, so it never appeared in the selector.
- Add `claude-fable-5` to `CLAUDE_MODELS` (non-default; Opus 4.8 stays the
default). Uses the Opus extended-thinking effort levels (low/medium/high/
xhigh/max). No `[1m]` variant: Fable 5 is natively 1M context, unlike the
Opus/Sonnet entries whose `[1m]` opts a 200K-default model into 1M.
- Teach `normalizeClaudeRuntimeModelId` the Fable family. Its version is a
single segment (`fable-5`) rather than the `{major}-{minor}` scheme, so it
needs its own match to map dated runtime IDs back to the catalog ID.
- Update the model-list and normalization test expectations, plus the CLI
provider e2e catalog fixture.
Fast Mode is correctly unaffected — its allowlist is Opus 4.6/4.7/4.8 only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Simplify Fable runtime-ID normalization
Drop the unused `[1m]` capture from the Fable match branch. There is no
`claude-fable-5[1m]` catalog entry — Fable 5 is natively 1M context, so
there is no 200K-default model to opt into 1M — which made that path dead
code (and an untested one). Keep the `[-_ ]+` separator class for
consistency with the sibling opus/sonnet/haiku regex right below it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: lumingjun <lumingjun@bytedance.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Provider listing now returns picker rows only. Importing a selected provider session hydrates its timeline before the daemon publishes the Paseo agent.
* ci: stabilize Electron dependency installs
Skip unused Electron binary downloads in non-desktop CI jobs and retry Electron installs in desktop tests to avoid external CDN 504 failures during npm ci.
* ci: preserve npm retry exit code
Preserve the final npm ci exit code in the non-Windows Electron retry path so CI diagnostics retain the original failure code.
* ci: preserve retry exit code under errexit
Keep npm ci inside the retry conditional so bash errexit does not bypass retries, while preserving the final npm exit code for diagnostics.
Automatic desktop update checks still respect rollout admission. Manual checks carry an explicit intent through the app and desktop updater service, and the up-to-date state now shows when the last check completed.
FilePreviewMarkdownLink was using Linking.openURL directly, which on
web/Electron performs a plain navigation that opens inside the app
window. Use openExternalUrl instead so desktop builds route through
the preload bridge to shell.openExternal.
Replace the modal Add Provider flow with an inline Add provider section
below the configured providers list. Makes new providers discoverable
without opening a modal.
- Remove add-provider-modal component
- Add provider-catalog-list component with search + Add button
- Remove duplicate ACP catalog entries for built-in providers
- Add spacing between the two sections
The ACP catalog kimi entry still referenced the legacy Python kimi-cli. Moonshot's actively maintained agent is now the TypeScript Kimi Code CLI (@moonshot-ai/kimi-code), which exposes the same kimi acp server. Update title, description, version, and install link to match; the launch command is unchanged.
* Support imports from Pi-compatible providers
* Add OMP as a built-in provider
* Address Pi session scanner review
* Restore Pi session import discovery
* Update provider test for disabled OMP
Teach daemon stop to use lifecycle shutdown when the API is reachable even if the owner pid is stale, then wait for the API to disappear and clean the stale pidfile.
Launch desktop-managed daemons detached from desktop stdio, preserve stale reachable daemon ownership for version checks, and allow desktop stop/restart to use the CLI recovery path.
Add supervisor heartbeats so supervised workers shut down when their supervisor disappears instead of surviving as orphaned reachable daemons.
Use a checkout-local .dev/paseo-home for root and worktree dev flows so development daemons do not collide with the packaged app home.
Split server, app, and desktop dev entrypoints, seed worktree homes from the source checkout metadata, and keep desktop dev on its own user-data directory and Expo port.
Renders as a bottom sheet on compact native instead of returning null.
The hook's window.addEventListener keydown is gated behind isWeb; native
uses TextInput onKeyPress and onSubmitEditing through a shared
handleKeyEvent callback.
* fix(claude): preserve alwaysLoad on MCP server configs
Paseo's Claude provider normalizes McpServerConfig via toClaudeSdkMcpConfig
before passing it to claude-agent-sdk. The function copied type/command/args/
env (stdio) and type/url/headers (http, sse) but dropped the alwaysLoad
field for all three transports.
Without alwaysLoad making it to the SDK, every MCP server's tools were
deferred behind tool search. In practice the agent never discovered them
via search and would respond 'no chrome-devtools tools available' even
with the server fully running and connected.
Add alwaysLoad to the McpServerConfig types and zod schemas, copy it
through in toClaudeSdkMcpConfig for stdio/http/sse, and add unit tests
covering all three transports plus the default-undefined case.
Other providers ignore the field, consistent with the existing pattern
where each provider normalizes only what its CLI/SDK supports.
* test(claude): drop ternary in toClaudeSdkMcpConfig assertion
The 'alwaysLoad' field is now declared on every branch of the
discriminated union returned by toClaudeSdkMcpConfig, so the in-operator
guard is no longer needed. Direct property access expresses the intent
without embedding a conditional in the assertion.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The daemon's bearer middleware gated every route except /api/health behind the daemon password (PASEO_PASSWORD), including /api/files/download. That route already carries a single-use, 60s-TTL, crypto-random download token that is only ever issued over the authenticated WebSocket, so the token is the capability for the route.
Requiring the daemon password on top of the token broke browser and Electron downloads: those trigger the download via an anchor navigation, which cannot attach an Authorization header. The cross-platform download store (packages/app/src/stores/download-store.ts) therefore sent no bearer, and the daemon returned 401 whenever a password was configured.
Add /api/files/download to the bearer-auth bypass list. The endpoint still rejects requests without a valid token (400 missing / 403 invalid), so it stays authenticated; it just no longer demands the password a second time. This also fixes every already-released client without an app update.
* fix(claude): respect profile models for built-in claude provider
The built-in Claude provider was the only one in the registry whose
profile `models` array was treated as additive on top of the hardcoded
first-party catalog. Every other built-in provider (and every custom
provider) replaces the runtime model list when `models` is set, which
matches the documented behavior in docs/custom-providers.md.
For users pointing Claude Code at a third-party Anthropic-compatible
gateway (Z.AI, Alibaba/Qwen, MiniMax, custom proxies, …) and curating
the picker with `agents.providers.claude.models`, the old behavior
leaked the nine first-party Claude models into the dropdown, making it
impossible to ship a curated list. This is issue #1299.
The fix flips the built-in Claude entry in `provider-registry.ts` to
match the other providers, so `models` replaces the runtime catalog
(including the settings.json-discovered entries surfaced by
getClaudeModelsWithSettings). The existing `additionalModels` field
keeps its additive semantics for anyone who still wants to append
entries on top of the first-party list.
- provider-registry.ts: drop the claude-only `profileModelsAreAdditive`
branch and align with the rest of the registry.
- provider-registry.test.ts: update the "append to runtime models"
expectation for built-in Claude to "replace runtime models" and add a
regression test that mirrors the issue scenario (hardcoded catalog +
configured profile models, expect only the profile models).
- agent.test.ts: make the "returns hardcoded claude models" hermetic by
pointing CLAUDE_CONFIG_DIR at an empty temp dir; the test was reading
the host's real ~/.claude/settings.json (which now contains MiniMax
env vars from the issue report) and leaking that into the assertion.
- custom-providers.md: correct the note about Claude profile models
being additive and document the new replace semantics alongside
additionalModels.
Closes#1299
* test(claude): drop explanatory comments from new tests
* refactor(claude): inject configDir into ClaudeAgentClient for test hermeticity
Greptile flagged the previous hermeticity fix on agent.test.ts: mutating
process.env.CLAUDE_CONFIG_DIR inside a test leaks the redirection into
any concurrent test in the same process for the duration of the try
block.
Thread a `configDir` option through ClaudeAgentClient ->
getClaudeModelsWithSettings -> readClaudeSettingsModels ->
resolveClaudeConfigDir instead. The constructor follows the same
injection pattern as `resolveBinary`, so the test passes an empty temp
dir via the option and no shared global state is touched.
- models.ts: add an optional `configDir` to
getClaudeModelsWithSettings, readClaudeSettingsModels, and
resolveClaudeConfigDir. Env var remains the fallback for production
callers.
- agent.ts: add `configDir?` to ClaudeAgentClientOptions, store it on
the instance, and forward it to getClaudeModelsWithSettings from
listModels.
- agent.test.ts: drop the process.env save/mutate/restore dance and
pass `configDir: emptyConfigDir` to the constructor instead.
Refs #1311
* fix(app): make markdown links tappable on iOS
A markdown link's label renders as nested React elements
(link > textgroup > text -> MarkdownInheritedText spans). The link's
onPress was placed on the wrapping UITextView span, but
react-native-uitextview only attaches onPress to the *string* children
it converts into tappable RNUITextViewChild nodes - element children
pass through untouched, so the handler never reached a node the native
tap recognizer could fire. Link text stayed visible and selectable but
dead to taps; web links never opened.
Thread the link's press handler down to the leaf text spans via a new
AssistantLinkPressProvider so it lands on the real tappable string
nodes. MarkdownInheritedText consumes the context and forwards onPress
to its MarkdownTextSpan; null outside a link, so ordinary text is
unaffected. Gated to iOS - Android forwards onPress through nested
<Text> already, and web uses the <a> path.
* refactor(app): export useAssistantLinkPress from module index
Import it through the assistant-file-links public surface instead of
reaching into the internal link-press-context file, matching the rest of
message.tsx. Addresses Greptile review feedback on #1334.
---------
Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
* feat(server): make provider refresh timeout configurable
Provider refresh probes (isAvailable + listModels/listModes) were hardcoded
to a 30s timeout. With many providers and MCP servers configured, cold
starts of agents like Copilot can exceed this on the first probe, surfacing
'Timed out refreshing Copilot after 30000ms' even though a manual retry
succeeds (warm caches).
Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the
ceiling without rebuilding. The explicit constructor option still wins
over the env var, and invalid values fall back to the 30s default.
* refactor(server): address review feedback on refresh timeout config
- Use Number() instead of Number.parseInt() so scientific notation like
'6e4' parses as 60000 instead of being silently truncated to 6.
- Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the
suite and avoid manual process.env save/restore.
Resolve markdown UITextView styles to plain React Native text styles before crossing the third-party boundary. react-native-uitextview flattens parent and child styles internally, so Unistyles metadata must not be carried into that handoff.
* test: cover Windows provider snapshot cwd updates
* fix: normalize Windows provider snapshot paths
* fix: preserve Claude history path candidates
* fix: match Claude project paths on Windows
Forward selection state into native draggable lists so virtualized workspace rows update when the active route changes.
Keep floating caller styles array-shaped and strip Unistyles metadata from owned inline geometry to avoid runtime style warnings.
Move mobile sidebar gesture gating to shared visual panel state so swipes can open and close sidebars before React state catches up. Keep the sidebar list scroll simultaneous with the close pan so vertical scrolling stays immediate.
* Add status grouping to the workspace sidebar
* Mark workspaces as read from the sidebar
* Clean package builds before release checks
* Fix sidebar status follow-up checks
* feat: open active files in editors and file managers
* fix: open the workspace folder alongside the active file in editors
Editor targets received only the file path, so opening from the workspace
file explorer launched the bare file without the surrounding project. Thread
an optional workspace `cwd` through the open_in_editor request so editors
launch as `<workspace> <file>`, loading the project explorer plus the file.
File-manager targets keep their single-path behavior.
* fix: address PR review feedback on file-open path resolution
- Narrow the tilde guard so only genuine home-relative paths (~ or ~/…)
are rejected; filenames like ~env.ts are now treated as workspace-relative.
- Make the workspace-containment check case-insensitive for Windows drive
paths so case-only differences still resolve a relative path.
- Centralize the file-manager target id set in the protocol
(isFileManagerTargetId) so the server and app no longer duplicate it.
* feat(desktop): open workspace files from Electron
* test(desktop): avoid mocked Electron editor IPC
* fix(desktop): avoid Windows shell for editor executables
* test(app): mock desktop editor bridge in e2e
* fix(app): reuse resolved workspace open target path
* fix(app): stabilize active file memo deps
* fix(protocol): tag legacy editor RPC shims
* fix(desktop): allow custom editor target ids
* fix(desktop): open Windows editor paths with metacharacters
Quote metacharacter values for .cmd editor wrappers so cmd.exe preserves them across wrapper execution instead of passing literal carets through.
* fix(app): preserve reviewed file path handling
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Add configurable service proxy
* Fix service proxy URL used when opening scripts with a public base URL
When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.
* Add service proxy documentation
Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.
* Cap combined public service label and document hostname format
* Address service proxy review feedback
* Add configurable worktree root
Allow new Paseo worktrees to be created under a custom worktrees.root from config.json while preserving the existing PASEO_HOME/worktrees default. Thread the resolved root through create/list/archive/ownership flows, MCP/session paths, checkout metadata, schema, docs, and targeted tests.
* Address worktree root review feedback
Use the resolved Paseo worktree base when a custom PASEO_HOME is provided, and remove an unused worktreesRoot dependency from worktree list handling.
* Fix checkout worktree path test on Windows
Use platform-native paths in the custom PASEO_HOME regression test so Windows CI compares against the same resolved root shape as production code.
* Clarify optional worktrees config docs
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* fix(app): render bold/italic/strikethrough via UITextView span on iOS
strong/em/s had no custom render rule and fell back to
react-native-markdown-display's defaults, which wrap children in a plain
RN <Text>. On iOS the paragraph/textgroup are native UITextViews
(markdown-text.ios.tsx); a plain <Text> nested inside is not hoisted into
a UITextViewChild, so its content rendered invisibly — bold/italic/
strikethrough silently disappeared (regression from #1153, v0.1.84).
Route strong/em/s through MarkdownInheritedText -> MarkdownTextSpan, the
same path text/textgroup already use, so the styled run composes as a
UITextViewChild on iOS and stays visible + selectable. Web/Android use a
plain <Text> for MarkdownTextSpan, so behavior there is unchanged.
Fixes#1253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): render hard/soft line breaks via UITextView span on iOS
hardbreak/softbreak had no custom rule, so react-native-markdown-display
emitted a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
plain <Text> is dropped (same root cause as strong/em/s/link): on iOS
hard line breaks vanished, and a softbreak between words joined them
("one\ntwo" -> "onetwo") — the "adjacent words dropped" symptom from the
original report.
Route both through MarkdownTextSpan so the newline composes as a
UITextViewChild on iOS; web/Android keep the same "\n".
Refs #1253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(app): clamp split pane resize
Prevent right-edge drags from overflowing the Electron viewport.
* fix(app): isolate resize pointer state
Keep concurrent resize drags from clobbering each other.
Older clients still request canonical rows for catch-up. Honor that explicit request while leaving projected as the default and projected app sync path.
Update the pending 0.1.88 changelog with the fixes already on main.
The macOS compositor watchdog called webContents.setBackgroundThrottling(false)
unconditionally for the window's whole lifetime. That disables Chromium's idle
frame-rate throttling, so the GPU process's CADisplayLink never idles — pinning
ProMotion panels at their maximum refresh rate (120Hz) continuously, even while
the app is idle or occluded, which drains the battery.
The call was never needed: the probe already skips non-producing windows via its
screen-lock / isVisible / isMinimized / document.visibilityState guards, and the
freeze it targets happens while the window is visible and focused, where
background throttling never applies. Removing it lets the compositor idle and the
display drop to a low adaptive rate when nothing animates; freeze detection is
unaffected. A comment and a docs note keep it from being re-added.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AssistantMarkdownLink's native branch wrapped its children in a plain RN
<Text>. On iOS that <Text> sits inside the paragraph's native UITextView
and is not hoisted into a UITextViewChild, so link text (markdown links,
bare URLs, and inline-code path links) was silently dropped — the same
root cause as the bold/italic/strikethrough fix in this PR.
Route the native link through MarkdownTextSpan so it composes as a
UITextViewChild and stays visible + selectable. MarkdownTextSpan now
forwards onPress/accessibilityRole across platforms: tappable on
Android/web; on iOS react-native-uitextview nulls onPress on the root
native view, so reliable tap-to-open is still tracked by #21 — but
visible, selectable, copyable link text strictly beats an invisible one.
Refs #1253
Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep parsed chat and tool detail content in sync with appearance token changes. Hide font-family controls on native, use One as the default syntax theme, add Swift highlighting, and give Nord a distinct light palette.
Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
Reanimated entering animations can leave the measured menu surface with an inline height. Release that fixed height after opening and when measured content changes so service rows can grow in place.
* Add appearance settings for theme, fonts, and syntax highlighting
New Appearance section in Settings to configure the app theme, UI and
mono font family + size, and a syntax-highlighting theme chosen
independently of light/dark. Font family fields default to empty and
show the active system stack as the placeholder; a live split-diff
preview reflects the choices as they change. The theme picker moves
here from General.
Preferences are client-only (AsyncStorage) and applied at runtime by
patching every registered Unistyles theme via
UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint
with no per-component preference reads and no useUnistyles. No
daemon/protocol change.
* Expand syntax themes and move the preview under the chooser
Replace the small fixed syntax-theme set with eight popular themes — GitHub,
Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a
compact per-theme role palette. The app theme and syntax theme stay separate;
the only coupling is the light/dark axis: a theme with both variants uses its
light palette on a light app and its dark palette on a dark app, while the code
frame (gutter, line numbers, background) keeps following the app theme. Default
is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match).
Move the live preview directly under the syntax-theme chooser, drop the
"preview.ts" filename header, and use a clearer code snippet for it.
* Update settings e2e for the moved theme picker and Appearance section
The theme picker moved from General to the new Appearance section, so the
General-content assertion no longer finds "Theme" — point it at "Default send"
(which stays in General) and add Appearance to the e2e section map plus an
expectAppearanceContent check, and exercise the Appearance section in the
sidebar-navigation test.
* Render the appearance preview as a unified diff
Switch the live preview from side-by-side columns to a single unified diff:
unchanged context lines plus "-"/"+" rows for the change, matching how diffs
read elsewhere in the app. Same snippet, mono font, syntax colors, and live
code-font draft behavior.
* Fix code font size change shrinking all UI text
applyAppearance scaled the live (already-patched) theme ramp instead of the
authored FONT_SIZE ramp, so it was not idempotent: every appearance change
re-scaled the current sizes, compounding the whole-UI font scale. With any
non-default UI size, changing the code size — or any setting — shrank (or grew)
all app text, and it persisted into the theme. Build the ramp from the canonical
FONT_SIZE ramp each apply (code size still set absolutely), so applies are
idempotent and a code-size change never touches the UI ramp.
* Apply the interface font across the app on web
react-native-web stamps a default font onto every text element, so the interface
font only reached the few components that set a font explicitly. Inject one rule
that points all text at a CSS variable (updated live as the setting changes),
with high specificity (1,2,0) so it deterministically beats RN-web's base font
and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and
monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded),
so they keep their code font.
Web-only: React Native has no global font cascade, so native still applies the UI
font only where components opt in. Inline `code` within chat prose is a known
minor gap (its render paths aren't tagged yet).
* Keep preview code in the code font
* Apply code font settings to terminals
* Fix settings host picker import after rebase
* Format terminal webview bundle
* Flatten settings sidebar with a host picker
Settings had two implicit tiers: app-level sections as flat sidebar rows,
and per-host settings hidden behind a drill-in to one monolithic host page.
This flattens both into a single sidebar with an "App" group and a "Host"
group, where the host's settings are top-level rows scoped by a host picker.
- Split the monolithic HostPage into four section pages: Connections,
Orchestration (was the unnamed inject-tools + system-prompt block),
Providers, and Daemon (status, updates, restart, remove).
- Host picker reuses the canonical <Combobox> sidebar host switcher; defaults
to the local host so a single-local-host user never thinks about "the daemon".
- Host sections become real sub-routes (/settings/hosts/[id]/[section]); the
old /settings/hosts/[id] redirects to the connections section, so existing
deep links (pairing, workspace, open-project) keep working.
- "Open providers" from the project screen now lands on the Providers section.
* Fix e2e host-settings navigation for the flat sidebar
The flat-settings refactor moved host settings onto separate section pages,
but three e2e helpers still expected the old monolithic host page:
- openDesktopSettings: the daemon-lifecycle card now lives on the Daemon
section — navigate there before asserting it.
- acp catalog: "Add provider" now lives on the Providers section — open that
section before opening the add-provider modal.
- openAddHostFlow: "Add host" is now an item inside the host picker (Combobox),
not a standalone button — open the picker first.
Test-only; no product changes. Fixes the 8 playwright failures on this branch.
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.
getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
The "model selector can open, close, reopen, and close again" test still flaked
on loaded CI runners even after re-tapping the backdrop: the model-selector
sheet re-renders as its model list settles, and Gorhom drops backdrop presses
during that churn, so the tap never dismisses within the retry window.
Fall back to dragging the sheet handle down, which drives Gorhom's pan-to-close
directly and is unaffected by the backdrop churn (mirrors the resilient close in
e2e/helpers/app.ts). The backdrop tap remains the primary path, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged. Verified locally by forcing the fallback (backdrop tap
disabled): the handle drag closes the sheet on its own.
The "model selector can open, close, reopen, and close again" test fails on
loaded CI runners: a single backdrop tap that lands while the sheet is still
present-animating is dropped (Gorhom ignores backdrop presses until the sheet
settles at its snap point), so the sheet never dismisses and the backdrop stays
visible. The reopen behaviour itself is correct — the visibility tracker is
unit-tested and the spec passes locally (3x), so this is test-interaction
fragility, not a product regression.
Re-tap the backdrop until the sheet dismisses, mirroring the resilient close
loop already used in e2e/helpers/app.ts. It stays a pure backdrop dismissal (no
Escape/pan fallback) so the real close path is still exercised, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged.
Reanimated 4.1.x crashes on Android with a NullPointerException in
ViewGroup.dispatchDraw (ReactViewGroup.dispatchDraw) — a child view is
null mid-draw because Reanimated commits a ShadowTree change during the
Android draw pass, mutating the view hierarchy while it's being painted.
Fixed upstream in 4.3.0 (software-mansion/react-native-reanimated#9072).
4.3.x requires react-native-worklets 0.8.x, so worklets moves 0.5.1 ->
0.8.3 in lockstep. react-native is pinned to exact 0.81.5 so the
re-resolve can't pull 0.81.6 (which needs react ^19.1.4, conflicting with
the react 19.1.0 override), and reanimated/worklets are added to root
overrides to force a single version across the tree — two copies of the
worklets runtime throw a version-mismatch at startup.
Adds the /grok SEO landing page (and its auto-generated route/sitemap
entries), the /agents directory card, and the Grok line in the ACP
catalog docs, following the Grok Build provider added to the in-app
catalog.
Verified the ACP entrypoint against a real install (grok 0.2.11): the
command is `grok agent stdio`, and an initialize handshake returns a
valid ACP response (protocolVersion 1, model grok-build). Running
prompts requires a SuperGrok or X Premium+ login.
The letter-on-grey placeholder is replaced by a fill whose color is a
deterministic hash of the project key, so each project keeps a stable
color across the sidebar, projects list, and settings header. Mid-
saturation tones keep the white letter legible in both themes.
v2 is English-only; v3 covers 25 European languages with automatic
language detection. The archive shares v2's filenames, architecture,
sample rate, and feature dim, so no runtime loader changes are needed.
Document that the `language` config field and PASEO_*_LANGUAGE vars
only steer the OpenAI STT provider — the local Parakeet models ignore
them, so multilingual local dictation means selecting the v3 model.
Highlighting reuses the existing code-block tokenizer and LRU cache, runs
lazily on expand, and falls back to plain text above a size cap so a large
Read can't stall the main thread. The diff highlighter reconstructs the old
and new file text by position rather than from @@ line ranges, so diffs that
arrive without real hunk headers still highlight.
Read content is normalized server-side: the cat -n line-number gutter some
providers emit is stripped so content is uniformly raw source, with the first
line number surfaced as offset for the client to rebuild the gutter.
A client command and a provider command can share a name (e.g. clear).
Both produced the same option id, which became a duplicate React key —
React then left stale rows and scrambled ordering, so /clear showed
twice and ranking broke as the query narrowed. Drop the colliding
provider command and keep the client one, which has extra affordances.
* feat(git): add manual refresh button to diff controls
Adds a checkout_refresh RPC that forces a hard re-read of the git and
GitHub snapshot plus the diff, bypassing polling. The diff controls now
show a refresh button (feature-gated) that triggers it and surfaces
errors via a toast — an escape hatch when the polled state goes stale.
* refactor(git): address review — dotted RPC name + withUnistyles
- Rename checkout_refresh RPC to the dotted convention
(checkout.refresh.request/response) per docs/rpc-namespacing.md.
- Replace the banned useUnistyles() call in DiffRefreshButton with
withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md.
* refactor(git): move refresh into the checkout actions store
Per the feature audit: the manual refresh was the one checkout action
whose UI logic lived inline in the component instead of alongside its
siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into
the store so there's one home for "UI triggers a checkout RPC" — it now
reuses runCheckoutAction's pending/success status and query invalidation.
The component just reads status and shows an error toast.
* fix(git): size refresh loader to the icon to stop layout shift
ActivityIndicator size="small" renders ~20px regardless of platform,
wider than the 14px refresh icon, so swapping to it grew the button and
shifted the controls row. Use SyncedLoader at the same iconSize — it
renders into a size×size box, so both states share one footprint.
* fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner)
Mirror the file-explorer pane's refresh button exactly: RotateCw icon,
LoadingSpinner while refreshing, both centered in a fixed icon-sized box
so the spinner can't grow the control or shift the row. Replaces the
ad-hoc RefreshCw + SyncedLoader pairing.
* Move local speech work out of daemon
Run local speech models in a lazily spawned worker so native model memory and blocking synthesis/transcription work stay out of the daemon process. The worker exits after idle time so local speech RSS can be reclaimed.
* Update supervisor stop test for process title
The documented parakeet-tdt-0.6b-v3-int8 doesn't exist in the model
catalog; only the v2 variant is accepted. Copying the doc value into
config produced an "invalid model id" error on daemon restart.
The keyboard shift was applied only to the input box inside the
composer, so the subagents track and the draft import-session pill —
rendered as siblings above the composer — stayed behind the keyboard.
Lift the shift to the wrapper that holds the full composer column and
add an opt-out prop so the composer skips its own shift when a parent
owns it.
* chore(e2e): begin Playwright test-quality pass
* test(e2e): migrate terminal-performance spec onto TerminalE2EHarness
Replace the hand-rolled daemon-client lifecycle (connectTerminalClient +
openProject + createTerminal/navigateToTerminal/killTerminal) with the shared
TerminalE2EHarness, matching terminal-keystroke-stress.spec.ts. The spec now
seeds the workspace and terminals through the harness vocabulary instead of
duplicating setup, cutting the file by ~40 lines with no behavior change.
* test(e2e): extract shared mock-agent workspace helper
Five specs reimplemented the same setup: seed a temp repo, open it as a
project, create a mock-provider agent, then navigate to its workspace route.
Extract seedMockAgentWorkspace + openAgentRoute into helpers/mock-agent.ts and
migrate the rewind-menu and user-message-contract UI-contract specs onto them,
dropping their local getServerId/openAgent/inline-seed duplication.
* test(e2e): migrate terminal tab rename spec onto TerminalE2EHarness
Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createTerminal + navigateToTerminal + manual cleanup) with the
shared TerminalE2EHarness and withTerminalInApp helpers, matching the other
terminal specs. Behavior and assertions are unchanged.
* test(e2e): share openProjectViaDaemon across sidebar specs
Both sidebar-workspace and sidebar-workspace-rename rolled their own
identical openProjectViaDaemon helper against the workspace-setup daemon
client. Promote a single shared helper into helpers/workspace-setup.ts
(returning id/name/workspaceDirectory) and have seedProjectForWorkspaceSetup
delegate to it, removing the duplicated open-project logic.
* test(e2e): migrate client-slash-commands spec onto shared mock-agent helper
Replace the spec's hand-rolled openProject/createReadyMockAgent/
openActiveAgentTab/getServerId setup with seedMockAgentWorkspace and
openAgentRoute from helpers/mock-agent, matching the rewind-menu and
user-message-contract specs. Drops ~60 lines of duplicated daemon-client
wiring; the test bodies now read as domain intent.
* test(e2e): converge remaining workspace-setup specs onto shared daemon helpers
Replace the last inline openProject + null-check blocks with the shared
openProjectViaDaemon helper in the file-explorer-collapse and pr-pane specs,
and route workspace-setup-runtime project registration through
seedProjectForWorkspaceSetup. Behavior is identical; the seeding vocabulary now
lives entirely in helpers/workspace-setup.ts.
* test(e2e): migrate composer-autocomplete spec onto shared mock-agent helper
Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createAgent + route building + bespoke cleanup) with
seedMockAgentWorkspace + openAgentRoute from helpers/mock-agent, matching the
client-slash-commands migration. Drops the local getServerId, openProject, and
cleanupWithin helpers.
* test(e2e): migrate codex-plan-approval spec onto shared mock-agent helper
Replace the hand-rolled daemon client, project open, agent creation, and
manual route building with seedMockAgentWorkspace + openAgentRoute. The spec
now reads as intent: seed a mock agent, open it, approve the plan, assert the
panel clears.
* test(e2e): converge daemon-client bootstrap onto one shared factory
The five e2e helpers each rolled their own daemon-client connect logic —
duplicating the ws-url resolution, node WebSocket factory, client
construction, and connect call, plus a redeclared config interface.
Extract a single connectDaemonClient factory in daemon-client-loader.ts
that each helper delegates to with its own typed client interface. This
also propagates the port-6767 safety guard (previously only in two
helpers) to all of them, so no test client can target the developer
daemon. Specs are untouched; behavior is preserved.
* test(e2e): promote shared daemon seed client out of terminal-perf helper
The general-purpose E2E daemon client (workspace/agent/terminal seeding and
driving) had grown inside terminal-perf.ts under the name
TerminalPerfDaemonClient, even though most consumers — mock-agent, composer,
rewind-flow, and the launcher/title-handoff specs — have nothing to do with
terminal performance. Move the interface and its connect factory into a
neutrally-named helpers/seed-client.ts (SeedDaemonClient / connectSeedClient)
and point every consumer at it. terminal-perf.ts keeps only its terminal page
helpers. Pure rename/relocate; no behavior change.
* test(e2e): converge E2E_SERVER_ID lookup onto one shared helper
The server-id env accessor was copy-pasted as a local `getServerId`
function in six helpers and five specs (plus a `requireServerId` twin in
the sidebar helper). Extract a single `helpers/server-id.ts` accessor and
route every caller through it, so a new spec imports the vocabulary
instead of re-deriving it. Pure refactor — identical lookup behavior.
* test(e2e): finish converging server-id lookup onto the shared helper
The previous pass extracted helpers/server-id.ts but only routed callers
that wrapped the env read in a local function. Eight specs still re-derived
the lookup inline — some as their own copy-pasted getSeededServerId/
getE2EServerId functions, some as bare `process.env.E2E_SERVER_ID` blocks
inside test bodies. Route them all through getServerId() so a new spec
imports the vocabulary instead of re-deriving it. Pure refactor — identical
lookup behavior; daemon-port reads are left untouched.
* test(e2e): converge workspace seeding onto a shared seedWorkspace helper
Extract the repeated temp-repo + seed-client + openProject bootstrap into
seedWorkspace() in helpers/seed-client.ts, returning a {client, repoPath,
workspaceId, cleanup} handle. seedMockAgentWorkspace and the agent-title-handoff
spec now build on it instead of re-rolling the trio, so the open-project error
handling and teardown live in one place.
* test(e2e): converge launcher-tab spec onto the shared seedWorkspace helper
Replace the hand-rolled createTempGitRepo + connectSeedClient + openProject
trio in beforeAll with seedWorkspace(), and drop the redundant second seed
client in the terminal-title block in favor of the shared workspace.client.
* test(e2e): converge file-explorer-collapse onto the shared seedWorkspace helper
Teach seedWorkspace to forward createTempGitRepo options (files/branches/
remote) so file-seeding specs can drop their hand-rolled
createTempGitRepo + connect + openProjectViaDaemon trio. Migrate
file-explorer-collapse onto it, removing its bespoke WorkspaceSetup client setup.
* test(e2e): converge non-git project setup onto a shared workspace helper
Promote the non-git createTempDirectory out of sidebar-workspace.spec.ts
into helpers/workspace.ts next to createTempGitRepo, and dedupe the
copy-pasted temp-root resolution (workspace.ts, with-workspace.ts, and the
spec) into one shared resolveTempRoot(). The spec drops its low-level
node:fs imports and reads in domain terms.
* test(e2e): converge agent-tab-rename spec onto the shared seedWorkspace helper
Drop the bespoke daemon-client + temp-repo + manual-cleanup trio in
workspace-agent-tab-rename and seed through seedWorkspace, matching the
other converged specs. createIdleAgent now takes a minimal structural
client interface so it accepts either the archive-tab client or the
shared seed client (type-only; the existing archive-tab callers are
unchanged). Verified by running the spec on Desktop Chrome.
* test(e2e): converge pane-remount spec onto the shared seedWorkspace helper
Drop the bespoke archive-tab daemon client + temp-repo + manual-cleanup
trio in workspace-pane-remount and seed through seedWorkspace, matching
the other converged specs. createIdleAgent already accepts the shared
seed client structurally, so the only behavior change is teardown now
goes through workspace.cleanup(). Verified by running the spec on
Desktop Chrome.
* test(e2e): converge sidebar-workspace-rename onto the shared seedWorkspace helper
Expose workspaceName and workspaceDirectory on SeededWorkspace (sourced from
the open-project response) so branch-rename specs can read the resolved branch
name and checkout directory without a bespoke client. Migrate
sidebar-workspace-rename off its hand-rolled connectWorkspaceSetupClient +
createTempGitRepo + openProjectViaDaemon trio, dropping the per-test
client/repo cleanup in favor of seedWorkspace's single cleanup handle.
* test(e2e): converge sidebar-workspace list onto the shared seedWorkspace helper
Migrate all five "Sidebar workspace list" tests off their hand-rolled
connectWorkspaceSetupClient + createTempGitRepo/createTempDirectory +
openProjectViaDaemon trio onto seedWorkspace, collapsing each test's
client/repo cleanup into the single seedWorkspace cleanup handle and dropping
the spec-local setGitHubRemote/execSync machinery.
Two small helper additions make the full file converge:
- seedWorkspace gains a `git: false` option that seeds a plain non-git
directory (via createTempDirectory) instead of a git repo, so the non-git
project test gets the same single-handle treatment.
- createTempGitRepo's configureRemote now relabels origin to a display URL
when both `withRemote` and `originUrl` are given: it sets up the local
tracking remote, pushes, then `git remote set-url` to the GitHub URL. This
reproduces the prior withRemote + setGitHubRemote git state exactly (real
local tracking refs, GitHub origin URL for project grouping) in one fixture
call, so the GitHub-remote tests are behavior-preserving.
* test(e2e): converge projects-settings onto the shared seedWorkspace helper
Replace the per-fixture connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio with seedWorkspace(), and expose the daemon's
projectId/projectDisplayName on SeededWorkspace so fixtures can read the
project label directly. All 9 specs pass.
* test(e2e): converge composer-attachments onto the shared seedWorkspace helper
Replace the inline connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio in the "composer is locked while new workspace agent
is being created" test with a single seedWorkspace() call, dropping the manual
client.close()/repo.cleanup() teardown in favor of workspace.cleanup(). The
test still passes against a real daemon.
* test(e2e): converge settings-toggle-tab-regression onto the shared seedWorkspace helper
Both tests rolled their own daemon client + temp git repo + manual agent
archive cleanup. Replace that trio with seedWorkspace(), drive idle agents
through workspace.client, and route off workspace.repoPath, leaving cleanup
to workspace.cleanup(). Matches the pattern already used by
workspace-pane-remount and workspace-agent-tab-rename.
* test(e2e): converge workspace-navigation-regression onto the shared seedWorkspace helper
Replace the bespoke connect/openProject/createTempGitRepo/archive trios in
the reconnect, cold-URL, and sidebar-navigation tests with seedWorkspace(),
matching the other migrated specs. Cleanup collapses to workspace.cleanup().
* test(e2e): drop orphaned dead code from agent-bottom-anchor helper
The agent-bottom-anchor spec was removed in a prior cleanup, but its
helper kept a private daemon-client interface and connect fn (duplicating
the shared seed client), seedBottomAnchorAgent, the reply-message builders,
and several scroll helpers that no spec references anymore. Only
readScrollMetrics, expectNearBottom, and waitForContentGrowth are still
used (by agent-stream.ts); keep those and delete the rest.
* test(e2e): converge archive-tab daemon client onto the shared seed client
Fold archiveAgent and fetchAgentHistory into the canonical SeedDaemonClient
and route the archive-tab and sessions-empty specs through connectSeedClient,
deleting the bespoke ArchiveTabDaemonClient wrapper. Both specs only need
general-purpose agent seed/drive operations, so they now share one client
interface instead of re-declaring their own.
* test(e2e): derive workspace-setup daemon client from the real client type
Replace the hand-rolled WorkspaceSetupDaemonClient interface with a
Pick<InternalDaemonClient, ...> over the real daemon client, matching the
pattern already used by the new-workspace helper. The 45-line re-declaration
of RPC method signatures could silently drift from the protocol; deriving it
from the source of truth keeps the test client honest and shrinks the helper.
* test(e2e): converge duplicated escapeRegex onto one shared helper
Seven byte-identical copies of escapeRegex lived across three specs and four
helpers. Extract a single helpers/regex.ts and route every caller through it,
so the suite has one regex-escaping primitive instead of re-declaring the same
pure function per file.
* test(e2e): converge E2E_DAEMON_PORT resolution onto one shared accessor
The isolated test daemon's port was re-read from the environment in ~10
places — three local helper functions in specs, two inline blocks, and
several helper modules — each repeating the "throw if unset" check and,
in the safety-critical paths, the "refuse the developer daemon (6767)"
guard.
Add helpers/daemon-port.ts exporting getE2EDaemonPort(), mirroring
getServerId(), and route every reader through it. The 6767 guard now
applies everywhere: the test port is never legitimately 6767, so
refusing it uniformly keeps every spec off the developer daemon.
While here, route the two inline port-regex escapes through the existing
escapeRegex helper instead of hand-inlining the same pattern.
* test(e2e): capture create-agent cwd via shared WS-frame helper
workspace-cwd's draft-agent test rolled its own request recorder by
monkeypatching WebSocket.prototype.send and stashing frames on a
window global, then reading them back through page.evaluate. Replace
that with captureWsSessionFrames — the same outbound-frame helper four
other specs already use for create_agent_request — so the assertion
reads the cwd directly and the spec drops the browser-side internals
reach-around. No behavior change; the three cwd cases still pass.
* test(e2e): converge daemon WS-route regex onto one shared helper
Five sites rebuilt the Playwright routeWebSocket matcher for the E2E
daemon inline as `new RegExp(`:${escapeRegex(getE2EDaemonPort())}\b`)`
(new-workspace, project-settings, composer-autocomplete, and two in
workspace-navigation-regression), and startup-dsl rolled the same regex
for arbitrary blocked test-host ports. Add daemonWsRoutePattern() and
wsRoutePatternForPort(port) to daemon-port.ts — whose docstring already
promised route patterns live here — and point every site at them.
The emitted regex is byte-identical, so interception behavior is
unchanged; composer-autocomplete still passes. Drops the now-unused
escapeRegex/getE2EDaemonPort imports and the redundant daemonPort locals.
Opus 4.8 (and its 1M-context variant) become the default Claude
models, pushing Opus 4.6 off the default slot. Opus 4.7 stays in
the list as the previous release.
* Archive agents on worktree archive; clean up schedules on archive
Worktree archive used to hard-delete every agent inside it (storage
removed, agent_deleted emitted), losing history along with the worktree.
It now archives those agents instead, so they remain in storage with an
archivedAt timestamp and stay visible in the archived list.
Schedules targeting an archived agent were left as dead records that the
schedule service rejected at run time. AgentManager now fires an
onAgentArchived callback from every archive path (live, stored snapshot,
cascade-to-children), and bootstrap wires it to
ScheduleService.deleteForAgent so those schedules are cleaned up
automatically.
* Push agent_state when archiving stored-only agents; tolerate schedule delete errors
Greptile review on PR #1206 flagged two issues:
1. archiveSnapshot persisted archivedAt to storage but did not emit an
agent_state event for stored-only agents. Connected clients would
keep showing them as active until a full reload. Mirror the dispatch
logic from markRecordArchived: notifyAgentState for live agents,
dispatchArchivedStoredAgent for off-memory non-internal agents.
2. ScheduleService.deleteForAgent used Promise.all, which short-circuits
on the first store error and abandons the rest. Switch to
Promise.allSettled, count fulfilled deletions, and log rejections.
* Tighten agent-manager archive tests after /audit-tests review
- Split "fires onAgentArchived for live, stored, and cascaded archives"
into two single-behavior tests with independent fixtures, so the assertion
array isn't reset mid-test.
- Strict equality on the cascade hook assertion (no more arrayContaining).
- "archiveSnapshot dispatches archived state" now captures the full agent
state event, asserts a dispatch happened, asserts the dispatched agent
id, and asserts the lifecycle is closed.
The app must not import @getpaseo/server. Moving the slug validation
and slugify helpers into @getpaseo/protocol (already a shared dep of
app, client, server) gives every package a legitimate home for the
import. Drops the corresponding ./utils/branch-slug subpath from
@getpaseo/server's exports map.
Unblocks the Deploy App workflow, which only builds protocol/client/
audio for the app — server's dist never exists in that pipeline.
The release has two steps: preparation, which the agent does locally and
reversibly, and go-ahead, which only the user authorizes. Last-minute
changes always need approval, code changes never bundle into the
changelog or release commit, and a sanity-check finding is information
for the user — not a directive for the agent to act on.
* Align MCP provider controls with app provider state
* Deepen provider snapshot routing
* Make provider snapshots the daemon authority
* Move provider shutdown behind a generic AgentClient seam
OpenCodeServerManager is now owned entirely by the OpenCode provider.
ProviderSnapshotManager.shutdown() and provider-registry.shutdownProviders()
materialize enabled clients and call an optional shutdown() per client; the
OpenCode client forwards to its runtime. Other providers ignore it.
Also wires providerSnapshotManager into the remaining Session-constructing
tests that were missing it (server-tests CI failure).
* Use platform-native cwd in change-event test
The seven Map-based caches in WorkspaceGitServiceImpl had no eviction —
the 15s TTL only marked entries stale before refetching in place. Long-
lived daemons accumulated entries for every ephemeral worktree cwd that
ever ran, with checkoutDiffCache holding multi-MB highlighted diffs.
Switch to LRUCache. checkoutDiffCache caps at 64 (heavy values); the
other six aux caches cap at 256. Stale-in-place TTL semantics are
unchanged.
* docs(testing): require ports-and-adapters unit tests or real e2e — no in-between
State the end-state explicitly so the test-suite cleanup has a written bar.
* Make useProjects a ports-and-adapters unit test
Extract the per-host workspace aggregation out of useProjects into a
pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime
adapter. The hook becomes a thin useQuery shim.
The test loses jsdom, @testing-library/react, QueryClientProvider, and
all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the
real aggregation against an in-memory ProjectsRuntime adapter.
* Make useLoadOlderAgentHistory a ports-and-adapters unit test
Extract the load-older sequence into a pure async function that takes
its client, in-flight tracker, toast, and logger as injected
dependencies. The hook reads the session store at call time and wires
the real adapters in. Tests now drive the pure function with typed
fakes — no JSDOM, no @testing-library/react renderHook, no console
spy.
* Make useAgentHistory a ports-and-adapters unit test
Drop the dead __private__ namespace from use-agent-history.ts, expose
fetchAgentHistoryPage and an AgentHistoryClient port as normal exports.
The hook keeps its useInfiniteQuery wiring; the page fetcher is now
testable in isolation.
The test loses jsdom, @testing-library/react, QueryClientProvider, the
vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing
loops. It exercises the page fetcher directly against an in-memory
AgentHistoryClient adapter that records each call.
* Make usePrPaneData a ports-and-adapters unit test
Extract the timeline fetch into a pure fetchPrPaneTimelinePage that
takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry
port. Extract the rest of the hook's wiring into pure exports:
extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The
module-level unsupported-tuple Set becomes a swappable in-memory
registry; production still wires the same module-level default.
The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime,
QueryClientProvider, createRoot, act, focusManager/onlineManager
toggling, and a hand-rolled waitForExpectation polling loop. It drives
each pure function and the timeline fetcher directly against typed
fakes and asserts on recorded state.
* Make useProvidersSnapshot a ports-and-adapters unit test
Extract the network and cache work into pure functions taking a
typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot,
refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and
selectorOpenRefetchDecision. The hook still drives them through
react-query and the host-runtime websocket subscription; production
wires the same DaemonClient as before.
The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, act, and a
hand-rolled listener-bucket trap that reached into the spied
client.on subscriber. It drives each pure function directly against
a typed FakeProvidersSnapshotClient and a real QueryClient.
* Make useSettings a ports-and-adapters unit test
Extract the pure load/save logic into use-settings.pure.ts taking a
typed SettingsDeps adapter: a KeyValueStorage port and a
DesktopSettingsBridge port. The hook file keeps the public 0-arg API
(loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings,
saveAppSettings) by wrapping the pure functions with productionDeps that
wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and
migrateLegacyDesktopSettings as before.
The test loses three vi.mock blocks (async-storage, @/desktop/host,
@/desktop/settings/desktop-settings), two vi.hoisted blobs, and the
vi.resetModules + await import("./use-settings") per-test pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage and a FakeDesktopBridge that records applied
migrations, asserting on observable storage state rather than mock
function calls.
* Make useAgentCommandsQuery a ports-and-adapters unit test
Extract the daemon call into fetchAgentCommands, a pure async
function taking a typed AgentCommandsClient adapter. The hook still
wraps it in useQuery and resolves the host-runtime client; production
wires the same DaemonClient as before.
The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, and
waitFor. It calls fetchAgentCommands directly against a typed
FakeAgentCommandsClient.
* Make useChangesPreferences a ports-and-adapters unit test
Extract the AsyncStorage-touching load and save logic into
use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter.
The hook file keeps the public API (useChangesPreferences,
loadChangesPreferencesFromStorage) by wrapping the pure functions with a
productionStorage wired to AsyncStorage as before.
The test loses vi.hoisted + vi.mock of
@react-native-async-storage/async-storage, vi.resetModules, and the
per-test dynamic await import("./use-changes-preferences") pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage, asserting on observable storage entries rather
than mock function calls. Adds coverage for saveChangesPreferences,
including the no-prior-cache fallback path.
* Make useArchiveAgent a ports-and-adapters unit test
Extract the queryClient-only pure helpers (toArchiveKey,
selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the
queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The
hook file keeps the React surface (usePendingArchiveAgentIds,
useArchiveAgent, applyArchivedAgentCloseResults) by importing from the
pure module, and the __private__ reach-around export is gone.
The test loses @vitest-environment jsdom, @testing-library/react, the
renderHook/act/waitFor imports, and the __private__ reach-around. It
calls the pure helpers directly against a real QueryClient and the real
session store. The renderHook test of usePendingArchiveAgentIds is
dropped — it asserted on react-query's subscription mechanics rather
than on our logic.
* Make useSidebarWorkspacesList a ports-and-adapters unit test
Extract the pure pieces — types, applyStoredOrdering,
appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new
computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into
use-sidebar-workspaces-list.pure.ts. The hook reads its persistent
sidebar order via the pure helper inside useEffect and derives its
loading state via the pure helper, instead of inlining the logic across
two effects. createSidebarWorkspaceEntry stays in the hook file since
it pulls selectPrHintFromStatus, but the hook re-exports it for
existing callers.
The test loses @vitest-environment jsdom, react-dom/client,
@testing-library/react, and the three Probe components that mounted
React just to assert on effect mechanics. It calls
computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with
no module mocks or hoisted globals. The "does not subscribe while
disabled" assertion is dropped — it was testing useSyncExternalStore's
subscribe gate, not our logic.
* Make useAgentInitialization a ports-and-adapters unit test
Extract ensureAgentIsInitialized and refreshAgent — the entire
imperative bodies of the hook's two callbacks — into
use-agent-initialization.pure.ts, taking setAgentInitializing as an
injected port. Add createSetAgentInitializing as a factory that binds
serverId to the zustand setInitializingAgents action. The hook itself
collapses to ~20 lines of useMemo + useCallback bindings.
The test drops @vitest-environment jsdom, @testing-library/react,
renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent
directly with a bound setAgentInitializing fake. No React mounting, no
module mocks, no hoisted globals.
* Make useClientActivity a ports-and-adapters unit test
Extract the activity-tracker state machine — lastActivityAt bookkeeping,
heartbeat throttling, app-visibility transitions, system-idle monotonic
update, and focused-agent change handling — into
use-client-activity.pure.ts. createClientActivityTracker takes the
heartbeat client, deviceType, and a now() port; the hook wires DOM /
AppState / Electron-idle listeners to tracker methods.
The test drops @vitest-environment jsdom, react-dom/client mounting,
act, and four module mocks (@/constants/platform, @/desktop/electron/idle,
react-native, @getpaseo/client/internal/daemon-client). It calls the
tracker directly with a fake heartbeat client and a test clock; the
fake records emitted heartbeats as observable state.
* Make useHoverSafeZone a ports-and-adapters unit test
Extract the safe-zone state machine — wasInside dedupe, bridge-rect
geometry between trigger and content, and inside/outside transitions —
into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes
getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone;
the hook just wires document pointermove, window pointerout, and
window blur listeners to tracker methods.
The test drops @vitest-environment jsdom, react-dom/client mounting,
act, @testing-library/react renderHook, the vi.mock("@/constants/platform")
shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect
patching helper. It calls the tracker directly with fake rect getters
and asserts on recorded enter/leave counts.
* Make useArchiveSubagent a ports-and-adapters unit test
* Make openImagePathsWithDesktopDialog a ports-and-adapters unit test
Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead
of reaching for getDesktopHost() inside the function. useImageAttachmentPicker
passes getDesktopHost()?.dialog at the call site; the native sibling matches
the new signature.
The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a
typed in-memory fake dialog that records the options it was called with. No
global module substitution, no spies — the test reads the fake's recorded
state.
* Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test
* Make UpdateCalloutSource a ports-and-adapters unit test
Extract resolveUpdateCalloutDescriptor as a pure function that maps
updater state to a structured callout descriptor. UpdateCalloutSource
becomes a thin React shim that materializes the descriptor's icon and
description as ReactNodes before registering with the sidebar callout
API.
Replaces a 259-line JSDOM + react-dom/client test that mocked five
modules (unistyles theme, lucide icons, async-storage, openExternalUrl,
useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to
assert deterministic title/description/action/dismissal-key derivations.
The new test exercises the resolver directly with zero React, zero DOM,
zero mocks.
* Delete dead useWorkspaceNavigation hook + collapse re-export indirection
The hook had zero production callers — only its own test, which used
vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to
verify a useCallback wrapper. The file also re-exported navigateToWorkspace
from the navigation store, so five production importers and two sibling
tests reached the store through a hook-module path that had nothing to
do with hooks.
Retargets every importer to @/stores/navigation-active-workspace-store
directly and removes the indirection module + its slop test.
* Make useCheckoutStatusQuery a ports-and-adapters unit test
Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a
sibling checkout-status-cache.ts so both pure functions operate on an
injected QueryClient and CheckoutStatusClient, with no React or host
runtime imports. useCheckoutStatusQuery becomes a thin shell that
composes useQuery + useEffect and delegates the cwd-filter + cache write
to applyCheckoutStatusUpdate.
Replaces a 318-line JSDOM + react-dom/client + fake-timers test that
mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component
to read the hook's data, and captured the subscription handler in a
hoisted Set. The new test exercises both functions directly against a
real QueryClient — zero React, zero DOM, zero mocks, zero fake timers.
* Make workspace-navigation a ports-and-adapters unit test
* Make navigateToAgent a ports-and-adapters unit test
* Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test
* Make openProjectDirectly a ports-and-adapters unit test
* Make navigation-active-workspace-store a ports-and-adapters unit test
* Make desktop-attachment-store a ports-and-adapters unit test
* Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test
Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and
take the desktop IPC invoker as a parameter. The single caller in
use-client-activity.ts now imports invokeDesktopCommand directly and
passes it; the test wires a typed fake invoker.
Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four
vi.spyOn(console, "warn") log assertions. Tests now assert the
documented behaviour (returns ms, or null) against an in-memory fake.
* Make useDesktopAppUpdater a ports-and-adapters unit test
Extract the check/install state machine from the React hook into a pure
createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/
checkForUpdates/installUpdate. The hook now wires real production deps
into the runtime and bridges its snapshot via useSyncExternalStore;
React-driven concerns (pending-update interval, initial silent check)
stay in the hook.
Drops JSDOM, @testing-library/react, renderHook, the three vi.mock
calls and the vi.hoisted state shim from the test. The new test wires
a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls
and exposes deferNextCheck/failNextCheck/nextInstallResult so the
behaviour assertions read as plain English (status transitions through
checking, available, pending, up-to-date, error; race cancellation drops
older results; install errors get reported once).
* Make sidebar-collapsed-sections-store a ports-and-adapters unit test
* Make session-store-hooks a ports-and-adapters unit test
* Make panel-store a ports-and-adapters unit test
* Make workspace-tabs-store a ports-and-adapters unit test
* Make desktop-preview-url a ports-and-adapters unit test
* Make client-id a ports-and-adapters unit test
* Make browser-store a ports-and-adapters unit test
* Make local-file-attachment-store a ports-and-adapters unit test
* Make draft-store a ports-and-adapters unit test
* Make rich-clipboard a ports-and-adapters unit test
* Make desktop-daemon-transport a ports-and-adapters unit test
* Make image-attachment-picker.native a ports-and-adapters unit test
Extract the pure normalize logic into image-attachment-picker.native.pure.ts
and take the PNG exporter as a port. The .native.ts entry wires the real
expo-image-manipulator adapter; the test wires a fake exporter and asserts
on its recorded uris.
Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator
stub from the test. Production callers of normalizePickedImageAssets are
unchanged.
* Make tool-call-icon a ports-and-adapters unit test
Split the pure icon-identity decision into tool-call-icon-name.ts and
keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The
resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...);
componentForToolCallIcon does the React component mapping; the existing
resolveToolCallIcon is the composition.
Drops vi.mock("lucide-react-native") from the test and the brittle
"expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't
evaluate lucide-react-native, which is why the mock existed in the
first place. The test now asserts on string identifiers and imports
only the pure module.
buildToolCallPresentation's resolveIcon port is unchanged; the single
caller (components/message.tsx) keeps passing resolveToolCallIcon and
gets the same component back.
* Make desktop-permissions a ports-and-adapters unit test
The test was the heaviest globalThis-juggling test in the app package:
vi.doMock("react-native") + vi.resetModules() per case to swap the
platform, ensureWindow/setNavigator/restoreGlobals to swap
globalThis.Notification, globalThis.navigator, and window.paseoDesktop.
That whole setup existed because the production module reached into
four ambient sources (Notification, navigator, getDesktopHost(),
isWeb/isNative) without a port.
Extract a DesktopPermissionEnvironment interface — { isWeb,
getDesktopHost, getNotification, getNavigator } — and rebuild the
module around a createDesktopPermissions(env) factory. The real
environment binds to the actual globals at module load and the
existing top-level exports (shouldShowDesktopPermissionSection,
getDesktopPermissionSnapshot, requestDesktopPermission) are thin
references onto it, so the only caller (use-desktop-permissions.ts) is
unchanged.
The test now constructs a fakeEnvironment per case and calls into
createDesktopPermissions directly — no JSDOM, no vi.doMock, no
vi.resetModules, no globalThis writes. The eight behaviors are
preserved.
* Make provider-icons a ports-and-adapters unit test
Split the pure provider→icon-identity decision into provider-icon-name.ts
and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts.
resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}),
and getProviderIcon composes it with the catalog/builtin component maps.
Drops vi.mock("lucide-react-native") from the test — the previous
"expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit
project couldn't evaluate lucide-react-native. The new test asserts on
discriminated-union identifiers and imports only the pure module.
The 13 outside callers of getProviderIcon are unchanged.
* Make crypto polyfill a ports-and-adapters unit test
* Delete redundant JSDOM Index route test
packages/app/src/app/index.test.tsx mounted the Index component through
JSDOM + createRoot + @testing-library/react, mocked five modules
(expo-router, _layout, desktop-daemon, startup-splash-screen,
navigation-active-workspace-store), and asserted that <Redirect> got
rendered with the right href across six scenarios.
Each of those six scenarios is a one-to-one duplicate of a pure case
already covered in host-runtime-bootstrap.test.ts, which tests the same
two decision functions (resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection) directly. The component is pure
wiring: it reads four hooks, calls the two decision functions, and
renders the result. There is no logic in Index to verify that the pure
tests do not already cover.
Drop the JSDOM file; the wiring is covered by app E2E.
* Make review-draft-store a ports-and-adapters unit test
* Make new-workspace-empty a ports-and-adapters unit test
* Extract shared drag-reorder state machine for web sortable lists
* Extract pure subagents track presentation helpers
Move formatHeaderLabel and resolveRowLabel out of track.tsx into a
colocated track-presentation.ts. Six header-copy tests that previously
mounted React under JSDOM with eight vi.mocks now run as plain unit
tests against the pure helpers.
* Make terminal-file-drop a ports-and-adapters unit test
* Extract pure import-session-sheet view-model helpers
Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries,
sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle,
getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a
colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise
provider resolution, dedupe/sort, error label fallback, title fallback, and
the empty-state state machine directly — no JSDOM, no vi.mock.
The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next
ticks can replace its status-message and empty-state cases with the pure
coverage.
* Make subagents track a ports-and-adapters unit test
Move the row-presentation data builder from track.tsx into the colocated
pure track-presentation.ts and cover statusBucket, titleState, and label
in plain node tests. Delete the 235-line JSDOM track.test.tsx with its
eight vi.mocks and createRoot harness — the remaining behaviours (empty
returns null, useState toggle, onPress wiring) are React idioms covered
by the framework, not domain logic.
* Make isolated-bottom-sheet-modal a ports-and-adapters unit test
* Stop UI from leaking through subagents barrel
The subagents/index.ts barrel re-exported SubagentsTrack (the React
component) alongside pure logic like selectSubagentsForParent. Any pure
consumer of the barrel transitively pulled in lucide-react-native and
react-native-unistyles, forcing tests that only touch pure logic to
declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider
icons just to get the import graph to load.
Drop SubagentsTrack from the barrel. The one external caller
(panels/agent-panel.tsx) deep-imports from @/subagents/track instead.
The workspace-subagents-integration test loses 56 lines of pretend-UI
mocks; only the AsyncStorage mock remains because workspace-layout-store
uses it directly through persist middleware.
* Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts
The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`,
`.native.ts`, `.test.ts`) without being one. The file exports a single
tracker; rename it after its role. Test sits next to its subject by name.
* Promote use-settings to a directory module
* Promote sidebar-collapsed-sections-store to a directory module
* Promote navigate-to-agent to a directory module
The `.pure.ts` filename suffix mimics tooling-resolved variants
(`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure
resolver and its wired wrapper into `utils/navigate-to-agent/`, where
the directory carries the domain and `resolve.ts` names the role. The
pure function is renamed `resolveNavigateToAgent` so it no longer
collides with the wrapper's exported `navigateToAgent`.
* Collapse use-client-activity.pure.ts into client-activity-tracker.ts
* Collapse open-project.pure.ts into open-project.ts
* Promote use-changes-preferences to a directory module
Replaces the .pure.ts / .test-utils.ts double-suffix with a
directory home that mirrors hooks/use-settings/:
hooks/use-changes-preferences/
index.ts ← React hook; wires AsyncStorage
storage.ts ← pure load/save
storage.test.ts ← tests against storage
fakes.ts ← in-memory KeyValueStorage adapter
External callers continue importing @/hooks/use-changes-preferences
unchanged (resolves to index.ts).
* Promote draft-store to a directory module
* Collapse use-archive-agent.pure.ts into use-archive-agent.ts
* Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts
* Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts
* Inline use-agent-initialization.pure.ts into use-agent-initialization.ts
* Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts
* Rename review/store.pure.ts to review/state.ts
* Promote browser-store to a directory module
* Promote navigation-active-workspace-store to a directory module
* Promote panel-store to a directory module
* Promote session-store-hooks to a directory module
* Promote workspace-tabs-store to a directory module
* Rename use-archive-subagent.pure.ts to archive-subagent.ts
* Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts
* Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts
* Extract client SDK package
* Polish SDK client identity defaults
* Build client before dependent CI jobs
* Restore daemon client server export
* Extract protocol and client SDK packages
* Fix provider override schema validation
* Fix app test daemon client imports
* Simplify workspace build targets
* Fix CLI test server build bootstrap
* Run SDK package tests in CI
* Fix rebase package split drift
* Restore lockfile registry metadata
* Update SDK config test for prompt default
* Move terminal stream router test to client package
* Fix rebase drift for protocol imports
* Fix SDK agent capability fixture
* Restore legacy server client exports
* Fix server export compatibility test
* Advertise custom mode icon client capability
* Remove server daemon-client exports
* Format rebased mode control import
* Fix rebase drift for protocol imports
Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split
shared/ paths that this branch moves into @getpaseo/protocol. Redirect
those imports to the protocol package so typecheck stays green after the
rebase.
The selector rework now synthesizes a "Default" row for ready enabled
providers without explicit models, so they share the same `kind: "models"`
shape as providers with real models. Updated the stale assertion that
still expected the old `providerDefault` kind.
opencode's Edit tool sends camelCase input keys (filePath, oldString,
newString) and a plain-string success ack on output. The shared edit
schemas expect snake_case input and an object output, so calls fell
through to the generic "unknown" renderer (raw JSON Input/Output).
Normalize both at the opencode boundary so the quirk does not leak into
the cross-provider schema.
Tests stubbed the old dep set and asserted on call signatures
that no longer match after the getCheckoutSnapshotFacts work was
threaded through refresh/observation/getPullRequestStatus.
Surface all enabled providers in the model selector with inline loading
and error states instead of silently hiding non-ready ones. Tap any row
to drill in; loading and error states show in the drill-down body, with
a Retry button on error. Providers with no models render a synthetic
"Default" row so every provider's drill-down has symmetric structure.
Redesign the per-provider settings sheet: search lives in the header,
discovered and custom models share one scrolling list, and the footer
hosts action buttons. Diagnostic and "Add model" each open as their own
sub-sheet, so neither expands inline or shifts the surrounding layout.
Snapshot refresh now keeps cached data visible: setQueryData replaces
the cache atomically and sibling-scope caches are invalidated (not
removed), so refreshing a provider no longer blanks its row. Search
inputs across the app are uncontrolled — onChangeText drives parent
state, and resetKey remounts the field when an explicit reset is needed
— to avoid RN's controlled-input flicker.
Adds a dev-only mock-slow provider whose discovery never resolves, so
the 30s snapshot timeout produces a real error and lets loading and
timeout-error UI be exercised end to end.
* Extract permission response command
* Extract create agent command
* Extract agent lifecycle commands
* refactor(server): extract worktree archive command
* refactor(server): share worktree create list commands
* test(server): update close items lifecycle fakes
* Fix MCP command stack CI regressions
* Restore MCP update_agent no-op success semantics
After the rebase, MCP `update_agent` was returning `success: false` for empty/no-op calls (no name, no labels, no settings). Origin/main returned `success: true` unconditionally. The audit flagged the change as out of scope for this stack — restore the old behavior at the MCP boundary.
Session WS path keeps its accepted/rejected semantics (it surfaces an error when nothing was provided so the client can prompt the user).
* Fix agent metadata delegation and mode persistence
* Update MCP update_agent test for metadata delegation
* Fix stored agent metadata timestamps
Wildcard listen addresses are bind targets, not client endpoints. Inject loopback for agent MCP URLs and surface OpenCode MCP add failures returned in data payloads.
Promote the brief Codex env-var note into a dedicated section next to
the Z.AI and Qwen examples, showing exactly which env vars Paseo wires
into Codex's model_providers thread config and how base_url, wire_api,
env_key, and requires_openai_auth are derived.
Old clients pinned AgentModeIcon to a closed enum and rendered
undefined for unknown values, crashing the route in production
Hermes with no stack trace. Loosen the type to a plain string,
let the client skip rendering when an icon is unregistered, and
add the customModeIcons capability so the daemon downgrades
non-legacy icons to ShieldCheck for clients that pre-date the
open-string contract.
Wait for the initial background turn to actually start before create returns a snapshot, without waiting for completion.
Freshly created agents now surface start failures as create failures, and both the WS/client and MCP surfaces reflect the shared create-agent lifecycle helper.
Treat undefined, blank, whitespace-only, and /__omit__ cadence values as absent at the MCP boundary for schedule create and update.
That keeps placeholder inputs from tripping cadence validation while preserving the real invalid cases for conflicting or missing cadence values.
Keep the desktop controls row alive when desktop extras exist, so draft mode chips still render even when a model has no thinking options.\n\nRestore the replacement draft e2e assertion for the mode chip now that it renders again.
After the composer refactor the replacement draft's mode picker
stopped rendering because the draft's provider snapshot does not
surface modes for the running provider. The model still carries
over correctly; only the mode picker UI is missing. Comment out
the assertion with a TODO so CI is unblocked while the picker bug
is tracked separately.
The composer refactor changed the agent-mode button's accessibility
label from the raw mode id ("load-test") to the formatted label
("Load test"). The user-facing change is intentional. Bring the test
in line with the new label.
handleSelect only ran setIsOpen(false) on the success path. When the
rewind action failed, the catch block swallowed the error but the menu
stayed open, blocking subsequent interactions with the page.
Close in finally so success and error both dismiss the menu.
The tab testID was reading from tabId, which intentionally stays as the
draft id after a draft -> agent handoff so the panel React key remains
stable and the component does not remount. The data-testid was riding
along on that identity and stayed workspace-tab-draft_xxx forever, even
though the tab semantically became an agent. Tests that look for
workspace-tab-agent_* timed out.
Read testIDs from tab.target via buildDeterministicWorkspaceTabId so the
testID reflects what the tab represents. React key is unchanged.
System-injected prompts (finish notifications, schedule fires) are wrapped
in a <paseo-system> envelope and dispatched as regular user messages to
the provider. The app was showing them as user-message turns. Detect the
envelope at the manager boundary and skip them during live runs and
history replay so only real user input renders.
Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
config.title is now strictly the caller's creation intent and is never
mutated. The prompt-derived placeholder lives only in record.title at
creation, so the metadata generator's upfront gate (hasExplicitTitle) is
the only one and writes unconditionally when it runs. Drops the two
projection fallbacks to record.config.title that were the second
hidden path for the prompt to surface as a title.
Move optimistic user message reconciliation into the stream reducers and keep draft-created agents in a handoff lifecycle until authoritative history lands.
Replace the full-screen rewind sheet with a dropdown anchored to the
trigger, with a muted warning header and inline pending state on the
chosen action. Also realign the user message action row so timestamp,
rewind, and copy share a single 24px baseline — the copy button was
inheriting assistant-footer paddings that pushed the row taller than
the icons.
decodeURIComponent throws on '%' not followed by two hex digits. The two
calls inside parseAssistantFileLink and normalizeFileUrlPath had no
try/catch, so any timeline message containing strings like '100% packet
loss' (ping output), '%PATH%' or '0% off' could escape into the React
tree and unmount the agent route, leaving the renderer blank.
Wrap both calls in a local safeDecodeURIComponent that returns the input
unchanged when decoding fails, mirroring the helper already used in
host-routes.ts.
Closes#1148
OpenCode emits session.error with MessageAbortedError when the user
interrupts a running turn. Paseo was mapping every session.error to
turn_failed, which set the agent lifecycle to error and appended a
raw JSON system error message to the timeline.
Now MessageAbortedError translates to turn_canceled (same as an
explicit cancel), so the agent returns to idle with no error state.
Real errors still produce turn_failed.
* Add Pi extension-launch plumbing for in-process tree primitives
* Add Rewind controls for agent sessions
* Preserve SDK checkpoint env var so Claude file rewind works
The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.
Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.
* Update OpenCode history replay tests for rewind state
* Show Rewind actions in an adaptive sheet
* Fix rewind semantics across providers
* Refactor Pi IDs through extension capture
* Restore user message to composer after rewind
* Fix Claude rewind tool result mapping
* Fix Claude rewind session switch roundtrip
* Fix rewind and optimistic message regressions
* Tighten rewind test discipline
* Fix OpenCode optimistic message reconciliation
* Reconcile optimistic user messages by marker
* Reorder user message actions
* Unify provider user message contract
* Add Claude rewind flow Playwright contract
* Add Codex rewind flow Playwright contract
* Add OpenCode rewind flow Playwright contract
* Add Pi rewind flow Playwright contract
* Remove real-provider rewind Playwright contracts
* Gate real-provider rewind specs from CI Playwright
Adds an llms.txt at the site root with a static product preamble so
LLMs can answer "what is Paseo" without following any links, plus a
curated index of docs, alternatives, and the 38 agent pages. Doc pages
now expose Copy/View as markdown buttons and a raw .md version at
<path>.md following the llms.txt convention.
Also skips the canonical-host redirect for localhost so the dev server
stops punting every request to production.
The popover was positioned `bottom: 100%` of its parent inside the composer
and worked on iOS/web; Android's hit-test by parent bounds drops touches on
children that overflow their parent, so taps and scrolls went through to the
chat behind it. Move the popover into a `<Portal>` at the app root and apply
the same Reanimated keyboard SharedValue as the composer so it tracks the
keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas
(Android hit-test, Portal lifecycle/transforms, status-bar offset, the
two-measurement flash) and the canonical files.
The model picker search input rendered text in the light-theme
foreground color on a dark surface, leaving typed characters invisible.
@gorhom/bottom-sheet mounts header subtrees before the sheet is visible
and keeps them mounted across theme changes, so any color read from
StyleSheet.create((theme) => ...) at the caller goes stale. Move text
color and placeholder color into AdaptiveTextInput itself via
withUnistyles, with the leaf's color appended last so callers cannot
re-introduce a stale read. Drop the now-redundant placeholderTextColor
props at the header search call sites.
Also ban useUnistyles() in docs/unistyles.md. The hook subscribes to
every runtime change and forces a re-render each cycle; new code uses
withUnistyles or StyleSheet.create instead.
The key matcher only consulted event.code when a binding explicitly
opted in via codeFallback. macOS rewrites event.key whenever Option
is held (Option+T -> "†", Option+[ -> "“", Option+Shift+W -> "„",
etc.), so the comparison against combo.key always failed and every
Alt-bound letter / bracket shortcut silently stopped firing on Mac:
Cmd+Alt+T (cycle theme), Alt+Shift+[/], Alt+[/], Alt+Shift+W.
Fall back to event.code when combo.alt is set. Keep the existing
key-first behaviour for non-Alt bindings so Dvorak users still get
logical-character matching (Cmd+V on physical Period keeps pasting
instead of triggering Cmd+. for sidebar toggle).
The Shortcut component computed its colors with a hex-only opacity
helper that received unistyles' CSS-var theme strings on web (e.g.
"var(--colors-foreground)"), fell through both regex branches, and
returned the rgba(255,255,255,...) fallback. Badges and chord text
rendered as near-white on white, so the keyboard shortcuts settings
page looked blank. Use the existing surface2 and foregroundMuted
tokens directly, which work in both themes without per-platform
color math.
Replace the Expo DOM-backed terminal on iOS and Android with a managed
WebView running xterm.js. Web and desktop continue to use the existing
DOM implementation.
Mobile mounted tabs switch from display:none to opacity:0 for the hidden
slot on purpose: terminals stay mounted under the LRU tab cache, and
keeping the WebView in the layer tree avoids cold-starting it every time
the user returns to a terminal tab.
EAS native builds rebuild the WebView bundle post-install so the
generated HTML stays in sync with the entry source.
This does not fix the WS 1006 / NSPOSIXErrorDomain Code=54 host-disconnect
symptom that prompted the investigation. After restoring the baseline,
that symptom could not be replicated; do not read this commit as its
root cause or fix.
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.
Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.
To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.
Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
The 0.1.79-beta.1 APK build failed on EAS because react-native-unistyles
3.2.4 (bumped in #1103) references JHybridObject::CxxPart, which only
exists in react-native-nitro-modules 0.35.x. We were still on 0.33.8 so
the Android NDK compile died with "no member named CxxPart". Web and
desktop builds didn't catch it because the unistyles C++ layer is
Android-only.
Bump nitro to 0.35.5 to match unistyles' expected API. While re-resolving
the tree, react-native 0.81.6 also tightens its react peer to ^19.1.4,
so bump react/react-dom alongside so npm install stays clean.
Verified with a local production-apk gradle build using EAS's exact
gradleCommand — assembleRelease completes and the APK lands.
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
Addresses the high-volume issues from the Ahrefs audit:
- Worker entrypoint 301s every request not on https://paseo.sh
(www subdomain and http variants), killing the duplicate-page
errors and the HTTP→HTTPS internal-link notices.
- pageMeta() emits <link rel="canonical"> and og:url per route;
every head() call now passes its canonical path.
- /blog stops rewriting itself to /blog?drafts=false; the search
param only appears when explicitly true.
- Sitemap includes /blog and each post.
- Trim homepage title under SERP truncation, rewrite /agents meta
description under 160 chars, pad short descriptions on
/privacy, /download, /cloud, /changelog, /docs.
- app.paseo.sh gets <meta robots=noindex,nofollow> and a
Disallow-all robots.txt so the SPA shell stops polluting the
crawl with H1-missing / no-OG / low-word-count warnings.
Every agent landing page now mentions both mobile and desktop, leads with
"open source", and uses a search-friendly hero title. Renames Pi to "Pi Agent"
and disambiguates other common-word names in titles.
cursor-agent publishes commands asynchronously via available_commands_update, so listCommands() returned an empty cache when called right after session/new. Enable waitForInitialCommands only on CursorACPAgentClient with a 10s timeout; other ACP providers keep the immediate-return default.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Run Pi through its RPC interface
Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules.
Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path.
* Keep Pi provider code in one module
* Add Pi session imports
* Enable Paseo MCP tools in Pi agents
Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients.
Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names.
* Fix Pi import symlink tests on Windows
Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior.
* Use native realpath for import cwd matching
Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data.
* Fallback realpath for Windows import matching
Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners.
* Add daemon system prompt support for Pi
* Fix Windows realpath import matching
* Remove Pi RPC migration plan doc
* Add rename for workspaces, terminals, and agent tabs
Surfaces rename via the sidebar workspace kebab (git branch rename with
client-side slugify), the terminal tab context menu (stops OSC 2 auto-title
overrides), and the agent tab context menu (locks the title against the
metadata generator). A shared RenameModal wraps AdaptiveModalSheet and
replaces the inline rename on the host page.
Auto-title races are closed from both sides: AgentManager gains
setGeneratedTitleIfUnset with an atomic per-agent write queue, and
TerminalSession replaces lockedTitle with a titleMode discriminated union
so user rename flips to manual and disposes the OSC subscription.
New WebSocket messages are additive: rename_terminal_request/response and
checkout_rename_branch_request/response (branch rename uses the
CheckoutError family). A new @getpaseo/server/utils/branch-slug subpath
export shares slugify + validateBranchSlug between server and app.
* Tighten rename feature and restore lost rebase wiring
Unslop pass on the rename commit plus two rebase artifacts:
- session.ts: collapse handleRenameTerminalRequest to a respond helper
with early returns; restore workspaceGitWatchTargets.set() in
syncWorkspaceGitObserver (lost during rebase onto main, which broke
onBranchChanged firing on branch rename).
- terminal.ts: remove DA1 query handler accidentally re-introduced by the
rebase (main intentionally removed it); restore conditional
onTitleChange registration under titleMode === "auto".
- rename-modal.tsx: drop unconfident optional-call on setNativeProps and
the unknown-cast HTMLInputElement narrowing dance.
- sidebar-workspace-list.tsx: remove unused branch field from rename
result; flatten validateRenameSlug into early returns.
- host-page.tsx: drop ?? "" fallback on a typed-string field.
* Fix typecheck after rebase: pass parsed config to getScriptConfigs
main refactored getScriptConfigs to take the parsed paseo.json config
instead of a repo path. spawnWorktreeScripts (added in the rename
feature) was still passing repoRoot. Read and parse the config first,
matching how spawnWorkspaceScript already does it.
* Resolve lint regressions and restore lost rebase fixes
Post-rebase cleanup: drop the await on syncWorkspaceGitObservers so
fetch_workspaces emits the response before any cold registration-
triggered git work fires (workspace.id is already on the descriptor —
no registry lookup needed). Restore the DA1 CSI handler that answers
\x1b[?62;4;22c on the daemon-side xterm so foreground apps like nvim
get a reply on stdin. Extract WorkspaceTabRenameModal/useWorkspaceTabRename
to drop WorkspaceScreenContent below the cyclomatic-complexity ceiling.
Switch session test internals to handleMessage so we exercise the public
dispatcher and avoid casting through any. Convert literal 'type' aliases
to interfaces and stop spawning callbacks inline so the codebase passes
the post-rebase oxlint rules.
* Fix typecheck after rebase: wire workspaceGitService and worker setTitle
Bootstrap was missing workspaceGitService when constructing
CreatePaseoWorktreeWorkflowDependencies after main's worktree workflow
refactor. Worker terminal manager needed setTitle on the session and
setTerminalTitle on the manager to satisfy the rename additions to
TerminalSession/TerminalManager interfaces.
* Format session.test.ts after rebase
* Remove stray auto-spawn of workspace scripts after bootstrap
The rebase brought in a spawnWorktreeScripts helper and a call inside
runWorktreeSetupInBackground that auto-started every configured workspace
script after worktree setup completed. main never auto-started scripts —
this regressed the workspace-setup-streaming Playwright test, which
expects the "web" script to be idle so the user can click Run.
Drops the helper, the call, and the workspaceGitService dep that only
existed to feed it.
* Fix checkout branch rename tests
* Fix sidebar checkout action store import
* Skip POSIX terminal tests on Windows
* Unslop the rename-entities feature
Six audit findings closed and ~450 net lines trimmed from the branch:
- Remove the duplicate "rename-branch" union member in
GitMutationRefreshReason.
- Fold dispatchStashMessage back into handleSessionMessage; the split was a
feature-first artifact of adding checkout_rename_branch_request, with no
documented rationale.
- Drop the protected beforeGeneratedTitleIfUnsetWrite test seam from
AgentStorage; rewrite the race test to exercise real Promise.all
concurrency against the existing per-agent write queue.
- Reshape useWorkspaceTabRename so the hook returns state and handlers
only; promote WorkspaceTabRenameModal to an exported component the
consumer renders directly.
- Rename RenameModal to AdaptiveRenameModal so it reads as a generic
primitive next to AdaptiveModalSheet, and update host-page,
sidebar-workspace-list, and the workspace tab rename hook to import it
by its new name.
- Trim duplicate matrix coverage and Zod self-tests across
rename-modal.test.tsx, terminal.test.ts, session.test.ts,
agent-storage.test.ts, messages.rename-entities.test.ts and the three
rename e2e specs; introduce packages/app/e2e/helpers/rename.ts to share
setup across the e2e specs without merging coverage.
Behavior of the rename feature is unchanged; targeted vitest, branch-wide
typecheck, and lint all green.
* Restore agentMetadataMocks dropped during rebase
5e6aeb2d removed the agentMetadataMocks hoisted definition and its
vi.mock wiring when it deleted the import describe block. The block
was kept (it belongs to main's import feature) but the mock support
was left behind.
* Fix terminal-manager tests using hardcoded /tmp on Windows
setTerminalTitle tests used cwd: "/tmp" which is not a valid directory
on Windows, causing node-pty error code 267 (ERROR_DIRECTORY). Use
realpathSync(tmpdir()) like the rest of the test file.
* Fix typecheck and lint after rebase onto main
AdaptiveModalSheet moved from a `title` string prop to a structured
`header: SheetHeader`; update AdaptiveRenameModal to memoize and pass
a SheetHeader. invalidateCheckoutGitQueriesForClient moved from
git/actions-store to git/query-keys. useWorkspaceTerminals now owns
the terminal query, so workspace-screen pulls queryKey from the hook
and re-declares queryClient via useQueryClient.
* Update agent metadata test mock to match setGeneratedTitleIfUnset rename
The branch renamed AgentManager.setTitle to setGeneratedTitleIfUnset
for the rename feature; the generateTitlePromptWithConfig helper still
mocked the old method, so eight prompt-byte tests crashed with
"setGeneratedTitleIfUnset is not a function" on both ubuntu and
windows server-tests jobs. Sister mocks in the same file were already
on the new name.
* Fix rename modal showing empty input by using controlled TextInput
AdaptiveTextInput (introduced on main by 29ce6653f) is intentionally
uncontrolled: it drops the `value` prop and seeds the native input
once via `initialValue`. AdaptiveRenameModal still passed `value=
draft` from the pre-rebase shape, so every rename modal opened with
an empty textbox and a disabled Save button. Three playwright e2e
specs (settings-host-page, sidebar-workspace-rename, workspace-agent-
tab-rename) failed for this reason.
Switch the rename modal to a plain controlled TextInput so the input
seeds with the current label and the slug transform (used by sidebar
workspace rename) reflects live in the textbox as the user types.
The unit test's old AdaptiveTextInput mock is replaced with a
react-native mock providing a controlled TextInput shim that captures
the same onChangeText/onSubmitEditing handlers.
* Revert "Fix rename modal showing empty input by using controlled TextInput"
This reverts commit 6283deae842b8a5fed63cb8bf06c78029f98386e.
* Seed rename modal input via AdaptiveTextInput's initialValue + resetKey
AdaptiveTextInput is intentionally uncontrolled — it drops `value` and
seeds the native input once with `initialValue`. The rename modal was
passing `value={draft}` from the pre-rebase shape, so every rename
modal opened with an empty textbox (three playwright specs failing).
Pass `initialValue={draft}` and bump `resetKey` only when the
transform rewrote what the user typed. That seeds the native input
with the current label on open, lets the user keep typing inside
text without cursor jumps (no remount when transform is a no-op),
and remounts the native input with the slug when the transform
diverges so live slugification keeps working in sidebar workspace
rename. Keeping AdaptiveTextInput preserves the BottomSheetTextInput
swap on mobile so the keyboard stays above the sheet — using a plain
TextInput would break that.
The unit test mock is updated to read `initialValue`/`resetKey` so it
mirrors production behavior instead of pretending the input is
controlled.
* Slugify branch rename at submit only, drop live transform
The rename modal's transform prop remounted the native input every
time the slug diverged from what the user typed, which lost focus
mid-edit on every uppercase letter or space in the sidebar workspace
rename. Live-rewriting what the user typed was also surprising — the
expectation is that you type a name, the daemon stores a slug.
Drop the transform prop from AdaptiveRenameModal entirely. Sidebar
workspace rename now slugifies once in handleSubmitRename before
calling renameBranch, and validateRenameSlug runs validateBranchSlug
against the slugified value so the user sees inline errors. The
playwright spec drops the live-slug assertion; it still verifies the
post-submit branch on disk and the rename request payload.
* Rename new rename RPCs to dotted convention
docs/rpc-namespacing.md says new RPCs use dotted names with the
direction as the final segment, and explicitly bans new flat snake_
case names. This PR introduced two flat ones; rename them in place
before the protocol ships:
- checkout_rename_branch_request → checkout.rename_branch.request
- checkout_rename_branch_response → checkout.rename_branch.response
- rename_terminal_request → terminal.rename.request
- rename_terminal_response → terminal.rename.response
Touched: the Zod literals in messages.ts, the discriminated union
entries, the session dispatcher and its tests, the daemon client
wrappers and their tests, the terminal session controller, and the
message-parsing rename-entities test. No callers exist outside the
server package — app and CLI go through the daemon-client wrappers,
which now emit the dotted names.
* Match rename modal Save button and input focus to app conventions
Save uses Button variant=default so it gets the same accent
background + white text as every other primary action in the app
(open-project, settings host save, pair-link confirm, etc).
The input previously fell back to the browser's blue focus outline
on web because the rename modal never set outlineStyle: none — every
other AdaptiveTextInput call site that wants accent feedback already
does this. Kill the browser outline, track focus state, and switch
borderColor to accent while focused.
* Theme the focus-visible outline color via AdaptiveTextInput
public/index.html paints a 2px :focus-visible outline on every web
element with a hard-coded #20744a (Paseo green). On the Claude theme,
the rename modal's input got that green ring instead of the theme's
brown accent — visible mismatch against every other accent-colored
control (primary buttons, etc).
Add an outlineColor entry to AdaptiveTextInput's stylesheet sourced
from theme.colors.accent. Unistyles' Babel plugin tracks the read and
updates the native ShadowTree on theme switch — no React re-render,
no useUnistyles() call (forbidden on this hot path per docs/unistyles.md).
The inline outline-color on the rendered DOM input overrides the
selector-level color from index.html; outline-width/style/offset
still come from the global rule.
Consumer style merges in after, so existing callers that pass
outlineColor (message-input, review/surface, question-form-card)
still win.
Also drop the half-baked isFocused/focusedBorderStyle local state
I added to rename-modal earlier — the AdaptiveTextInput fix removes
the need for it.
* Drop useUnistyles() from rename modal
The rename modal only read theme.colors.foregroundMuted to pass as
the TextInput's placeholderTextColor prop. The rest already went
through StyleSheet.create((theme) => ...). Per docs/unistyles.md the
hook is forbidden when an alternative exists — and this is exactly
the alternative the rest of the codebase uses (project-settings-screen,
add-host-modal, pair-link-modal, command-center): put the muted color
in a tiny StyleSheet entry and read .color off it for the prop.
* Default placeholderTextColor in AdaptiveTextInput
placeholderTextColor was being duplicated at every AdaptiveTextInput
call site (add-host-modal, command-center, pair-link-modal,
project-picker-modal, provider-diagnostic-sheet, combobox, the
sheet's own search inputs, etc.) — all passing the same
theme.colors.foregroundMuted. The shared input should own this.
Move it into AdaptiveTextInput as a default sourced from the same
StyleSheet.create(theme => ...) block as the accent outline. Consumers
still override via the prop if they need a different color. Strip
the redundant placeholderTextColor and local placeholderColor style
entry from rename-modal; other call sites can be cleaned up in a
follow-up.
* Update rename e2e specs to use dotted RPC type strings
Earlier commit (5d5624943) renamed the new rename RPCs to the dotted
convention but missed the two e2e specs that capture the WebSocket
frames by type. captureWsSessionFrames matched the old flat names, so
renameRequests / renameFrames stayed empty even though the rename
went through end to end — the sidebar updated and the branch was
renamed on disk. The toBeGreaterThan(0) assertion fired and the test
failed in playwright CI.
* fix: show resolved file paths in agent file-link tooltips
Bare filenames in agent messages now resolve to their full workspace
path on hover. Also raises the daemon's directory-suggestion scan
depth so files in deeper package layouts are reachable.
* fix(app): keep file-link wrapper stable to avoid layout shift on resolve
* fix(app): resolve file links on hover instead of at render time
useQuery was firing the daemon RPC for every ambiguous file reference
the moment a message rendered, fanning out a wave of requests on chat
scroll. Switch to enabled: false + prefetchQuery on hover so RPCs are
driven by user intent. Sync-resolvable refs (directFile, external)
still seed via initialData and render with no RPC.
Also memo on source primitives rather than identity so identical-
content sources constructed inline upstream don't bust the memo every
render.
* Redesign assistant file link resolution
* Stabilize assistant file link handlers
Claude's Agent SDK encodes a cwd into ~/.claude/projects/<dir> by
realpath + NFC normalizing the path, replacing every non-alphanumeric
character with "-", and (when the encoded result exceeds 200 chars)
appending a base-36 hash. paseo's existing sanitizer only replaces
five characters and skips realpath, so it misses the SDK's directory
whenever the cwd has a symlink (macOS /var → /private/var), spaces,
parens, NFD unicode, or exceeds the length cap.
This adds the resolver alongside the existing logic; wiring it into
agent.ts to replace resolveHistoryPath is the next step. Tests assert
parity by writing a session file at our computed path and asking the
SDK's getSessionInfo({ dir }) to find it — if both encoders agree,
the SDK finds it.
* feat(server): upgrade embedded Pi SDK
* fix: sync package-lock.json with package.json
The Pi SDK upgrade commit (63e18a9d) regenerated package-lock.json in a
way that dropped packages/website's react@19.2.6, react-dom@19.2.6, and
scheduler@0.27.0 (website depends on react ^19.1.4, which resolves to
19.2.6). This broke `npm ci` in CI, failing all 14 checks at the install
step with EUSAGE "package.json and package-lock.json not in sync".
Regenerated with `npm install`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Kiro CLI as an opt-in ACP provider catalog entry, plus a generic ACP extension-notification sink so unknown notifications no longer fail JSON-RPC dispatch.
* Restart the GPU process to recover from macOS compositor freezes
macOS display sleep can leave Chromium's GPU-process display link stuck
on a stale display, so the compositor stops producing frames and the
window looks frozen — unresponsive to clicks and keys — even though the
renderer stays alive.
setupDarwinCompositorWatchdog polls the renderer for frame production
and, on a sustained stall while the window is visible and unlocked,
restarts the GPU process so Chromium rebuilds the display link. This
replaces setupDarwinPaintRefresh, whose invalidate/resize nudges did
not address the dead display link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Give compositor watchdog a module home
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Big Pickle's models.dev metadata changed upstream to route via the
Anthropic SDK against the Zen OpenAI-compatible endpoint, which the
endpoint rejects. Swap the live-turn smoke tests to
openrouter/~openai/gpt-mini-latest and gate them on OPENROUTER_API_KEY
so fork PRs skip cleanly when the secret is absent. Also unpin
opencode-ai so CI tracks the latest CLI.
Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
Group the docs sidebar by directory so the new pages cluster
under a heading, add a hamburger menu for the mobile docs nav,
and let the docs main column shrink so wide code blocks and
tables scroll inside instead of pushing the page wide.
- Strip end-of-bullet periods from CHANGELOG.md (whole file)
- Tighten 0.1.77 TLS bullet to drop LAN/public explanation
- Release docs: drop release-mobile.yml references (workflow does not exist),
add EAS mobile-build babysitting section using the EAS CLI, clarify that
releases are always patch, clarify that "stable" means stable
- Changelog style guide: no trailing periods, one sentence per bullet, lead
with capability not mechanism
* feat: add independent TLS control for relay public endpoint
Add PASEO_RELAY_PUBLIC_USE_TLS env var and publicUseTls persisted
config to separately control TLS for the client→relay pairing offer,
falling back to relayUseTls when unset. The daemon→relay connection
continues to use PASEO_RELAY_USE_TLS alone.
Enables self-hosting behind a TLS-terminating reverse proxy where
the internal path is plain ws:// but public clients need wss://.
* fix: use public relay TLS in daemon status
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Drops the accentSubtle theme token in favor of an existing surface layer.
surface3 gives a clear step up from surface0 (chat bg) without the tinted
look the accent fill produced.
* Fix OpenCode slash command turns
* ci: install opencode in server-tests so real OpenCode e2e runs
Server tests previously only installed the Claude Code CLI, so every
opencode-*.real.e2e.test.ts self-skipped on CI. The custom-command and
turn-lifecycle real tests cover the OpenCode session SSE rewrite, so
add opencode-ai globally and let them run. Big Pickle is free so no
provider secrets needed.
* Fix OpenCode unit tests exposed by enabling opencode in CI
server-tests previously skipped every OpenCodeAgentClient test because
opencode-ai was not installed. Now that CI runs them, four real issues
surface:
- Drop unused mkdirSync import (was used on a code path 1812b1489
refactored away on main; the rebase merged imports without rechecking
usage). Fixes the lint job.
- Fix slash-command-timeout test passing four ctor args instead of three,
which silently dropped the TestOpenCodeRuntime and ran against the real
opencode runtime.
- Relax the listModels contextWindowMaxTokens assertion. Some providers
routed through OpenCode (OpenAI in particular) don't expose a numeric
context window; assert the type only when the field is present.
- Swallow late notifySubscribers calls once close() flips the closed
flag and clear subscribers up front. The session-lifetime SSE stream
introduced by the prior commit means a session.error arriving after
the owning test has moved on can rejectCompletion on a deferred no
one is awaiting, surfacing as an unhandled rejection in a downstream
test file.
* Fix structured diff parsing for no-prefix paths
Git can emit diff --git path path when diff.noprefix is enabled. The structured diff parser only handled the default a/path b/path form, so checkout diff assembly could lose hunks for affected files and render expanded rows empty.
Normalize optional a/ and b/ prefixes in both app and server parsers, and cover the no-prefix header form with regression tests.
* Fix no-prefix diff path parsing
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
User bubbles previously used surface2, which blended into the surrounding
chat surface. Add an `accentSubtle` theme token (accent darkened, slightly
desaturated, low alpha) and use it for the bubble fill so user messages
read as distinct without shouting.
Fixes#986
Adds Lezer-based highlighting to fenced code blocks in assistant messages,
with a hover/tap copy button. Colors move into the theme as
`theme.colors.syntax`, so the chat blocks, file preview, and diff viewer
share one StyleSheet path — token color updates flow through the Unistyles
native ShadowRegistry with no React re-renders on the chat hot path.
* fix(mcp): add missing schedule tools (update, logs, run-once)
Add three schedule MCP tools to close the parity gap between
CLI (`paseo schedule update/logs/run-once`) and MCP surface.
The WebSocket RPC handlers and ScheduleService methods already
exist (added in PR #694), but the MCP tools were never registered.
Tools added:
- update_schedule: update interval, prompt, name, maxRuns, etc.
- schedule_logs: retrieve run history for a schedule
- run_once_schedule: trigger immediate one-time execution
Includes 8 unit tests covering all three tools.
Fixes#1031
* Fix schedule MCP update handling
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The submit_review fastlane lane identified the just-uploaded build by
CFBundleVersion alone. In this project the production iOS profile resets
the build number per marketing version, so every release sits at build 2
and Spaceship::ConnectAPI::Build.all returns the whole TestFlight history.
The .find call picked an arbitrary match, which is how 0.1.76's binary got
attached to the 0.1.75 App Store version slot and submitted for review
under the wrong label.
The release workflow now forwards app_version and app_build_version from
the build_ios outputs into the fastlane step as env vars, and the Fastfile
filters builds by marketing version plus build number to pin the exact
binary. The standalone resubmit workflow takes the same identifiers as
optional workflow_dispatch inputs; with both unset it falls back to "most
recently uploaded iOS build" so manual reruns still work.
Pinning fastlane in the Gemfile to lock the Spaceship behavior this lane
depends on.
* Open chat file links in a side pane on cmd-click
* Add hover tooltips to file links showing cmd-click hint
* Collapse file-link open path to a single disposition seam
Replace the parallel "open in side" callback that was threaded
alongside the normal open callback at every layer between the chat
click handler and the workspace router. One callback now carries a
`OpenFileDisposition` ("main" | "side") so the modifier-key decision
lives at the leaf and the routing decision lives at the workspace
screen, with no branching in between.
* Give assistant file links a home
* fix(app): require long-press for sidebar dnd on mobile
* fix(app): right-edge open gesture for explorer sidebar
* fix(app): left-edge open gesture for workspace sidebar
* fix(app): allow gesture children to scroll on web
* fix(app): lock viewport zoom on compact web
* fix(app): don't submit on enter in compact web composer
---------
Co-authored-by: jon <nikuscs@gmail.com>
`inheritUserEnvironment` set the daemon's PATH to NixOS user/system
profiles only, missing `~/.nix-profile/bin` and
`~/.local/state/nix/profile/bin` where home-manager installs per-user
CLIs. Result: agents like claude/opencode installed via home-manager
were invisible to the daemon and provider checks failed with
"Provider 'X' is not available. Please ensure the CLI is installed."
Prepend both home-manager profile dirs to PATH when running as a real
user (cfg.user != "paseo"), and update the option doc accordingly.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix mobile sidebar web interactions
* fix(app): restore button role on sidebar project row
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* nix: share npmDeps FOD between paseo and paseo-desktop
The daemon and desktop derivations share package-lock.json, so their
npmDeps FODs contain byte-identical content. But Nix names the FOD
store path with the consuming pname prefix, so paseo-<v>-npm-deps and
paseo-desktop-<v>-npm-deps resolve to different store paths despite
identical bytes. Building both runs prefetch-npm-deps twice in
parallel; on a clean store this downloads the entire registry
(~1-2 GB of tarballs) twice over the wire.
Wire paseo through as a callPackage arg of the desktop drv and
inherit (paseo) npmDeps. One FOD, one fetch, one store path.
Override path becomes paseo.override { npmDepsHash = "..."; } — the
desktop drv picks up the overridden npmDeps transitively. Downstream
flakes that previously did paseo-desktop.override { npmDepsHash }
need to either drop the desktop-side override (recommended) or pass
the overridden daemon through as paseo-desktop.override { paseo }.
* nix: trace daemon runtime closure with @vercel/nft
The daemon Nix installPhase used to `cp -a node_modules $out/lib/paseo/`,
shipping every package in the hoisted root — Expo, React Native, Metro,
Electron, ML stacks, ~1700 third-party deps the daemon never `require()`s
at runtime. Result: ~2.6 GB store path for a daemon whose actual runtime
closure is a tiny fraction of that.
Replace it with static module-graph tracing via @vercel/nft (the same
library Vercel and Next.js use for serverless bundling). The new
installPhase runs `node scripts/trace-daemon.mjs`, which:
1. Calls nodeFileTrace on three entry points — cli/dist/index.js,
server/dist/scripts/supervisor-entrypoint.js, and the forked
server/dist/server/terminal/terminal-worker-process.js. The worker
needs to be a separate entry because nft does not follow fork()
process boundaries.
2. Unions the trace result with an explicit list of non-JS runtime
inputs nft does not detect: shell-integration assets read via
readFileSync, the Silero VAD ONNX model loaded by the sherpa
provider, .env.example, the CLI shebang script, and node-pty's
compiled prebuilt binary for the host platform.
3. Ignores cross-platform native variants we explicitly do not ship
(sherpa-onnx-*, @mariozechner/clipboard-*) and node-fetch's
optional `encoding` peer.
installPhase consumes the file list, copies each entry into
$out/lib/paseo/ preserving its repo-relative path, and wraps two bin
entries (paseo, paseo-server) via makeWrapper.
Verified end-to-end:
- daemon $out drops from ~2.6 GB to ~131 MB (≈95% reduction)
- paseo --version, paseo --help work from the new $out
- paseo-server boots, HTTP server reaches "Server listening" within
~40 ms, no missing-module errors in the bootstrap log
- speech features degrade gracefully when sherpa-onnx-* / model files
are absent (the documented behaviour for the Nix build, unchanged)
@vercel/nft added to root devDependencies (1.5.0); used only at
build time by the Nix derivation.
* fix(nix): gitignore result
* fix(nix): refresh npm deps hash post-rebase
The conflict resolution during rebase kept the trace-daemon hash, but
the merged package-lock.json combines upstream's lockfile updates with
the @vercel/nft addition, so the hash needed to be recomputed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lists previously had a static marginVertical that stacked with
paragraph.marginBottom, producing 20px gaps and oversized whitespace
around paragraphs sandwiched between lists.
Spacing is now context-aware via the render rule: list -> list keeps
the normal paragraph gap (back-to-back lists read as continuous
content) while p <-> list transitions get a slightly larger gap (lists
read as their own section). Static margins can't satisfy both because
RN doesn't collapse margins, so the bullet_list / ordered_list rule
inspects the next sibling type and picks marginBottom accordingly.
Also exclude package-lock.json from the format pre-commit hook glob —
oxfmt internally ignores lockfiles but exits with an error when given
no remaining targets, breaking lockfile-only commits.
Claude CLI's built-in slash commands (/voice, /usage, "Unknown command:
...") run client-side with no model turn — they arrive as a result with
output_tokens: 0 and the user-visible text carried in result.result, but
no preceding assistant_message in the stream. Surface that text as an
assistant_message so the turn doesn't end silently.
Also unify Codex skill-command text blocks to always carry the
"$skill-name" prefix in the text payload, matching the no-args path
which already did.
- Centralize horizontal alignment: StreamItemWrapper / contentWrapper own
the chat rail (paddingHorizontal: 8). Stream items stop setting their
own outer horizontal padding. Copy icons get negative margins so the
visible icon sits flush with the rail edges.
- Unify the inline working indicator and assistant turn footer into a
shared slot (minHeight: 24, paddingBottom: 24). No layout shift on
streaming -> idle, and both gain breathing room above the composer
and the next user bubble.
- Equalize the gap on both sides of an assistant message adjacent to a
tool call (20px each). Drop assistantMessageStylesheet.containerSpacing,
which was a redundant marginBottom that only fired in one direction
and duplicated getGapBetween's responsibility.
- Scale up the chevron via transform (size stays 12, viewBox padding no
longer dominates), iconBadge dimensions unchanged. Tool icon and
chevron now read as similar visual weight.
- Shared STREAM_METADATA_FONT_SIZE (13) for user message timestamp,
assistant footer duration/timestamp, and the live elapsed timer.
- formatDuration: one rule for static and live displays - 0-10s as
X.Xs, 10-60s as Xs, minutes/hours integers only. Drops the `mode`
parameter.
- formatMessageTimestamp: explicitly carry the resolved hourCycle so
the runtime applies an OS-level 24h preference when the platform
exposes it (e.g. AppleICUForce24HourTime on macOS).
The user message bubble had hover on a `Pressable` wrapping the bubble,
with the timestamp and copy button as siblings outside it — moving the
cursor onto them lost hover. Wrapping both in a single Pressable then
flickered, because `TurnCopyButton` is itself a Pressable and nested
Pressables fight over hover state.
Rewrite `UserMessage` to match the workspace row pattern: hover-tracking
on a plain outer `View` with `onPointerEnter`/`onPointerLeave`, nested
Pressables freely inside. Pointer events are non-bubbling DOM events,
so descendants can't disrupt the outer hover.
Rewrite `docs/hover.md` with the workspace row as THE canonical pattern,
not one option among several. Names the three failure modes that come
from skipping it — including "nested Pressables fight over hover" — and
explicitly bounds when `Pressable.onHoverIn`/`onHoverOut` is acceptable
(self-styling only).
Update `constants/platform.ts` so its hover guidance points at the doc
instead of recommending the broken pattern.
When the user invokes /paseo-advisor with another skill reference
(/unslop, /unslop-risk, $diagnose, etc.), the orchestrator should
instruct the advisor to load and invoke that skill against the current
task before doing anything else.
Thread an optional logger through runGitCommand and the CheckoutContext
helpers so workspace-git-service can emit trace-level traces around each
git invocation. Behavior is unchanged when no logger is supplied.
Add a daemon setting `autoArchiveAfterMerge` (default off). When on, the
daemon's existing GitHub poll triggers archive once the PR is merged,
but only if the worktree is clean (not dirty, not ahead of origin).
Skips silently otherwise.
* Auto-attach pasted GitHub PR/issue URLs in the composer
Paste a GitHub PR or issue link into any composer and it shows up
as an attachment pill within a debounce tick when the URL matches
the workspace's remote. On the new-workspace screen, an attached PR
also surfaces an inline "Check out PR #N?" hint next to the picker;
one click promotes it to the checkout target through the same
syncPickerPrAttachment flow the dropdown uses.
Unifies the new-workspace picker and composer attachment picker on
one shared GitHub search hook and cache key, so both consumers (and
the auto-attach lookup) share the React Query cache.
* Fix oxfmt formatting in new-workspace-screen import
* Add project rename so duplicates get distinguishable names
Two checkouts of the same remote collapse into a single project record
(same projectId), which left users unable to tell duplicates apart in
the UI. customName lives alongside the derived displayName as an
override that reconciliation never touches, so renames persist across
git-remote changes. The rename UI lives in project settings.
Closes#987 (rename half).
* Fix COMPAT(projectCustomName) version to v0.1.76
* Update useProjects shape test for new projectCustomName key
* Use sendCorrelatedSessionRequest for renameProject
Match the pattern used by checkoutPrMerge and other newer RPCs
instead of hand-rolling the select callback.
* Use project.rename.{request,response} dotted RPC names
Match the convention from docs/rpc-namespacing.md (added in 75a6f8277).
Schemas, type exports, daemon client, session handler, and tests all
move to the namespaced names. No back-compat shim needed since the RPC
hasn't shipped.
* fix: honor current branch in new workspace flow
* Use OpenCode global event stream (#916)
* Use OpenCode global event stream
* Use stable OpenCode model in initial prompt e2e
* Clean up OpenCode verification notes
* chore: changelog for 0.1.74
* chore(release): cut 0.1.74
* Fix custom Codex provider base URL routing (#915)
* Wire Copilot Allow All mode to ACP permissions (#935)
* Add trace logging and tighten daemon log defaults (#933)
* Add trace logging and tighten daemon log defaults
* Clean up daemon trace logging shape
* Clean up daemon trace logging
* Route provider turn-id checks through shared helper
* Fix supervisor log config test paths on Windows
* Expect resolved supervisor log path on Windows
* nix: declarative config, typed relay options, desktop packaging (#923)
* nix: expose npmDepsHash as a callPackage arg
Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.
Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: move npmDepsHash default to a sidecar file
Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.
No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: declarative config via services.paseo.settings
Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.
Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.
`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.
The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: typed services.paseo.relay options with auto-wired endpoint
Addresses #224 (option surface only).
Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.
Add a typed relay subtree:
- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
the new options answer "how is it configured?".
The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.
No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: package paseo desktop app for Linux
NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.
Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.
The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.
`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.
No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: copy full packages/ tree in desktop derivation
The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:
- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
(the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
(the CLI launcher script lives under bin/, not dist/)
npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: route desktop renderer through paseo:// protocol handler
When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.
main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: track nix/npm-deps.hash in nix-build commit step
The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.
* ci: push nix-build hash commits via paseo-ai[bot] App token
The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Normalize HEIC image attachments on the client (#934)
* Normalize HEIC attachments before persistence
* Preserve native JPEG and PNG picks
* fix: update lockfile signatures and Nix hash
* ci: skip App token on PRs in nix-build
Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.
* ci: skip CI on nix-build auto hash commits
The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.
* ci: drop unused contents: write override in nix-build
GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.
Addresses #365.
* ci: gate nix-build on same-repo PRs only
scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.
Closes#365.
* ci: split nix workflow into build (anywhere) + update-hash (main only)
build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.
update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.
Rename file to nix.yml since it's no longer just a build job.
* ci: split nix into two workflows
nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.
* Fix iPad sidebar safe area background (#937)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
* Fix desktop daemon stale PID startup (#913)
* Configure STT language from settings (#941)
* Configure STT language from settings
* Update websocket speech mock for language config
* Handle Windows shell wrappers in Codex command summaries (#931)
* fix(app): respect iPad safe area in settings sidebar (#922)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
* ci(nix): smoke test daemon boot (#939)
* Fix scheduled agent cleanup (#945)
* Fail Codex resume requests explicitly (#947)
* fix(copilot): preserve legacy autopilot mode alias
* docs(changelog): draft 0.1.75 entry
* chore(release): cut 0.1.75
* fix: update lockfile signatures and Nix hash [skip ci]
* docs(changelog): refine 0.1.75 entry
* fix: preserve worktree action literal types
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: Matan Bendix Shenhav <matan@shenhav.fyi>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Bolun Zhang <zhaggbl@mail.ustc.edu.cn>
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
Co-authored-by: Biao Ma <sigemaict@gmail.com>
Co-authored-by: ezra <ezra.ora.c@gmail.com>
* fix(server): include OpenCode console subscription providers in model list
Providers with source "api" (e.g. Pi coding agent via OpenCode Go
subscription) appear in providers.all but not in providers.connected,
which only lists env/config providers. Paseo was filtering exclusively
by connected, so subscription-managed providers were silently dropped
and users saw an empty model list or a misleading "set env vars" error.
Fix: treat source === "api" as accessible in both listModels() and the
context-window lookup helper. Also update the error message to mention
the console login option.
chore: add portless as a devDependency so npm run dev works without
a global install.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove portless dependency
* test(server): add unit tests for OpenCode api-source provider inclusion
Covers the source="api" logic added in 08e3c6bf: providers managed by the
OpenCode console/subscription must appear in listModels and the context window
lookup even when absent from the connected list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: drop unrelated package-lock.json churn
The lockfile changes were peer metadata additions from a newer npm
version, not actual dependency changes. Restore to match main.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Make the repo's paseo skills self-contained — drop mentions of
personal skills (unslop, think-harder, e2e-playwright, fix-build,
rebase) that aren't checked in here.
The reconciler updated project.kind when a directory became a git repo
but never updated the workspace.kind underneath, leaving the sidebar in
a split-brain state: an expandable git project containing a workspace
with no leading icon (workspaceKind stayed "directory" instead of
flipping to "local_checkout" or "worktree").
* Add Auto Review permission mode
* Align Codex Auto Review with auto permissions
* Defer Codex Auto Review to dedicated PR
* fix(app): drop unconditional snapshot refresh on mode selector open
Opening the mode selector was reusing the model selector refresh path, which forced a provider snapshot refresh every time. That made the agent status bar briefly lose its selected provider data and flash empty while the picker opened.
Keep the stale model refresh on the model selector only, and let the mode selector open from the existing snapshot state.
* refactor(claude): rename Claude auto permission mode label to "Auto mode"
Claude Code itself calls this permission setting Auto mode in its CLI/TUI, so match that label in Paseo. The supporting test names and transport eligibility errors now use the same terminology.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* Fix relay E2EE reconnect races
* fix(relay): use WebSocket protocol ping for daemon control keepalive
The daemon's control socket pinged the relay every 10s with a JSON
`{type:"ping"}` message. Every JSON message wakes a hibernated Cloudflare
Durable Object via `webSocketMessage`, so an idle daemon was billing
relay DO CPU 24/7 just to echo `pong`.
Switch to `socket.ping()` (RFC 6455 protocol ping frames). Cloudflare's
runtime auto-responds at the edge without waking hibernated DOs, so an
idle daemon-to-relay connection now costs zero DO CPU.
Liveness still works: the `ws` library emits a `pong` event on the
daemon when the edge replies, which refreshes `controlLastSeenAt`.
`markControlReady` stays tied to DO-originated messages (sync,
connected, disconnected), so we still detect a broken DO at connect.
The DO keeps its JSON ping handler tagged `COMPAT(relay-json-ping)` so
old daemons (< v0.1.76) continue to work.
* test(relay): assert protocol-ping pongs arrive in e2e idle test
Adds a `relay_control_pong_received` debug log to the daemon's pong
handler, then asserts in the existing idle-relay e2e test that:
- at least one pong arrived during the 12s idle wait (proving the
Cloudflare edge auto-responds to RFC 6455 protocol pings)
- no stale terminations fired (proving the keepalive path is healthy)
If the daemon ever regresses to JSON-level keepalive pings (which wake
the hibernated relay Durable Object and incur CPU billing), this test
will catch it: the `pong` event would never fire because the DO's reply
would arrive via `message`, not as a protocol pong frame.
The test's capturing logger is bumped to `debug` level so the assertion
can read the new debug log; production daemons stay at `info`.
Confirmed locally against wrangler-hosted relay: 2 pongs / 0 stale per
12s window.
* test(relay): catch dual-ping regression via DO-side legacy ping log
The existing idle-test assertion catches "revert to JSON ping only" via
the missing pong event, but misses the dual-ping regression where
someone adds `socket.send(JSON.stringify({type:"ping"}))` alongside
`socket.ping()`. Both paths would work, the pong assertion still
passes, and DO CPU billing silently returns.
Add a console.log in the DO's handleControlKeepalive (which only runs
when a JSON ping reaches the DO), then assert in the idle e2e test
that no such log appeared during the 12s idle window.
Verified the assertion catches the regression: temporarily injected a
`socket.send(JSON.stringify({type:"ping"}))` next to socket.ping(),
test failed with `expected 1 to be 0` and the relay logged
`legacy_json_ping_received`. Removed the injection.
Log noise during the COMPAT window doubles as useful telemetry: it
counts how many old daemons are still pinging, which informs the
removal date for the COMPAT shim.
* fix(app): auto-set ref picker from a single attached PR
Attaching a PR in the new-workspace composer signals "I want this PR's
context", and almost always also means "base the worktree on this PR".
Until now the two paths were independent: the ref picker stayed on the
default branch, so the worktree was branched off main even though the
sidebar chip looked like a PR checkout.
Auto-promote the picker when exactly one PR is attached and the user
hasn't manually picked a ref. Manual picker selections are preserved;
auto-promoted ones are cleared when their backing attachment is removed.
* refactor(app): derive auto-promoted picker selection instead of syncing
The previous version drove `pickerSelection` from a useEffect that
inspected attachments and overwrote state. That meant the shared
`PickerSelection` type had to grow a `source: "manual" | "attachment"`
discriminator just so the effect could tell its own writes apart from
manual picks. Classic effect-as-derived-state.
Split the state instead. `manualPickerSelection` holds explicit picks;
`autoPickerItem` is a useMemo over composer attachments; the effective
`pickerSelection` is `manual ?? auto` via a small precedence helper. No
effect, no `source` flag, and `PickerSelection` stays a screen-local
shape since nothing outside needs it.
build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.
update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.
Rename file to nix.yml since it's no longer just a build job.
scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.
Closes#365.
GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.
Addresses #365.
The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.
Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.
* nix: expose npmDepsHash as a callPackage arg
Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.
Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: move npmDepsHash default to a sidecar file
Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.
No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: declarative config via services.paseo.settings
Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.
Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.
`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.
The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: typed services.paseo.relay options with auto-wired endpoint
Addresses #224 (option surface only).
Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.
Add a typed relay subtree:
- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
the new options answer "how is it configured?".
The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.
No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: package paseo desktop app for Linux
NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.
Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.
The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.
`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.
No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: copy full packages/ tree in desktop derivation
The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:
- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
(the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
(the CLI launcher script lives under bin/, not dist/)
npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* nix: route desktop renderer through paseo:// protocol handler
When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.
main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: track nix/npm-deps.hash in nix-build commit step
The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.
* ci: push nix-build hash commits via paseo-ai[bot] App token
The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
OpenCode 1.14.42+ closes the /event SSE stream cleanly right after
server.connected, breaking the entire turn lifecycle: prompts get
queued and run, but Paseo never sees session.idle / message deltas /
tool calls / questions. Every turn either fails with a generic stream
EOF or hangs waiting for events that never arrive.
Switch the post-EOF recovery to the canonical messages REST endpoint
(via the upgraded SDK) and poll incrementally so tool calls and
clarifying questions surface live during the SSE gap. Bound the wait
with a completion cap and a separate liveness cap so silent rejections
fail fast instead of hanging until the cap. Cap session.abort similarly
so explicit cancels land within seconds.
The recovery path is gated on the for-await loop exiting without a
terminal event, so healthy turns never enter it - if upstream restores
SSE delivery, this code becomes dead and is mechanically removable via
the COMPAT(opencodeEofRecovery) and COMPAT(opencodeSlowAbort) tags.
Also tighten the type of projectSettingsRoute on WorktreeSetupCalloutPolicy
so router.navigate accepts it (was widened to string, broke app
typecheck on main).
Refs: getpaseo/paseo#861, anomalyco/opencode#26697,
anomalyco/opencode#26635
* fix(server): recover completed opencode turns after SSE EOF (#861)
* fix(server): wait for opencode completion after EOF
The original EOF recovery only checked OpenCode storage once after the SSE stream ended. That missed the real failure mode from #861: OpenCode can drop /event while the turn continues behind the scenes, then persist the assistant completion a little later.
Poll the persisted session for the active turn before failing EOF, while still requiring strong completion evidence and ignoring messages that predate the turn. This preserves failure behavior when there is no persisted completion.
Add behavioral coverage for delayed completion after EOF, partial streamed text plus persisted completion without duplication, stale old completions, and the no-evidence failure path.
---------
Co-authored-by: pluto <plutofog@proton.me>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
On web (browser/Electron), expo-crypto's randomUUID() and getRandomValues()
just forward to globalThis.crypto.* (see expo-crypto/src/ExpoCrypto.web.ts).
The previous polyfill installed `g.crypto.randomUUID = () => ExpoCrypto.randomUUID()`,
which on web reads back through the same `globalThis.crypto.randomUUID` it
just installed, recursing until the stack overflows. The same trap exists
for getRandomValues, but it rarely triggers because the native version is
almost always present.
Capture a bound reference to the native getRandomValues *before* installing
the polyfill, and generate UUID v4 in JS from 16 random bytes (RFC 4122
version + variant bits). The fallback path stays on ExpoCrypto.getRandomValues
(non-recursive on native), and ExpoCrypto.randomUUID is no longer used.
cancel-in-progress is gated to pull_request events so merge_group and
push runs always complete — only redundant CI from rapid PR pushes
gets cancelled.
Without merge_group on the workflow, PRs entering the merge queue would
fail because no required check ever reports a status against the
merge_group ref.
The matrix in CI / server-tests (ci.yml) covers what Server CI / test was
doing, on Linux and Windows instead of Linux only. Two workflows running
the same test suite on every server-touching PR is wasted runner time.
* test(codex): regression test for server-initiated JSON-RPC approval round-trip
Verified this test fails against the pre-#866 classifier shape from #758.
* test(codex): cover all server-initiated request methods in transport
* chore: add issue forms, PR template, contributor guidance
- bug report and feature request issue forms with structured fields for surface, version, provider, logs, screenshots
- issue config disables blank issues and routes casual questions to Discord
- single PR template with explicit verification section and AI-assisted contribution stance
- CONTRIBUTING.md: soften drive-by stance for objective bug fixes, codify expectations on AI-assisted reports and PRs (full logs over summaries, proof of testing over prose)
* chore: drop BDFL framing for one-person team
* chore: tighten CONTRIBUTING, drop duplication
* Patch production dependency advisories
* ci: harden dependency installs with npm ci --ignore-scripts
- Replace npm install with npm ci --ignore-scripts across all workflows
to enforce lockfile parity and block dependency postinstall scripts.
- Run npm run postinstall explicitly after install so our root patch
script still applies (the only legitimate postinstall use in this
repo); dep lifecycle scripts stay blocked.
- Add lockfile-lint and npm audit signatures to the lint job to catch
registry-host tampering and verify cryptographic signatures.
- Regenerate package-lock.json to match bumped package.json versions.
* ci: drop --ignore-scripts; rely on npm ci + lockfile-lint + audit signatures
Lavamoat allow-scripts only traverses root deps; it can't see workspace
deps like electron, esbuild, sharp. Without proper monorepo support, an
allowlist would be incomplete and electron's binary download breaks.
Keep the rest of the security stack:
- npm ci (strict lockfile parity)
- lockfile-lint (resolved-host check)
- npm audit signatures (cryptographic verification)
Real script-blocking would need pnpm 10+ migration. Tracking separately.
* ci(nix-build): use PR head SHA so fork checkouts work
The previous `ref: ${{ github.head_ref || github.ref }}` made
actions/checkout fetch a branch name that only exists on the fork,
not origin. Use the PR head SHA instead — origin mirrors PR commits
via refs/pull/N/head, so this works for fork PRs without changing
the push-to-main auto-commit behavior.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The toolbar archive deferred its redirect until the server confirmed
the archive, so users briefly landed on the "workspace not found" gate
and were then sent to the main checkout's workspace route. Mirror the
sidebar pattern: synchronously router.replace to the project's new
workspace screen up front and let archive reconcile only update the
store.
Fork PRs (e.g. #845) run without OPENAI_API_KEY and without local
speech models, so global-setup.ts:420 threw and blew up the entire
Playwright job before any test could run.
Soften the gate: when neither path is available, warn and run with
dictation/voice disabled. Daemon already honors PASEO_DICTATION_ENABLED=0
and PASEO_VOICE_MODE_ENABLED=0. No spec currently exercises dictation,
so nothing else needs to change; future dictation tests should gate on
PASEO_DICTATION_ENABLED.
agent.integration "creates an autonomous live turn" — the prompt asked
Claude to use a `Task` tool that doesn't exist in the SDK (Paseo
synthesizes Task entries from sidechain notifications, but Claude can't
invoke it). Haiku non-deterministically refused or complied. Switch to
Bash with run_in_background, matching the deterministic
claude-autonomous-wake-simple.real.e2e variant — same autonomous-wake
mechanism is exercised.
schedule/service "defaults new-agent modeId to provider's unattended
mode" — afterEach raced AgentStorage's background snapshot persist,
producing intermittent ENOTEMPTY rmdirs. Drain pending writes with
agentStorage.flush() before rm.
Auto-namer now probes refs/heads/<name> and appends -2, -3, … (up to 50)
when the desired slug is taken, instead of failing with "branch already
exists" and leaving the worktree on its placeholder name.
Structured-generation waterfall replaces opencode/gpt-5-nano with
opencode/minimax-m2.5-free and opencode/nemotron-3-super-free so users
without nano configured fall through to free models they actually have.
Reload was a no-op for imported agents whose on-disk session was mutated
externally. The server preserved the in-memory timeline + historyPrimed
flag, so hydrate short-circuited and the epoch never changed. The client
also fetched without a cursor on reload, so the server returned
reset:false and the reducer dropped the new-epoch rows against a stale
cursor — the chat stayed stale until refocus.
Server: reloadAgentSession gains rehydrateFromDisk (used by the refresh
RPC). When set, durable + in-memory timeline are wiped and historyPrimed
is reset, so register mints a new epoch and hydrateTimelineFromProvider
re-streams the freshly read provider history. Voice-mode reloads keep
the existing preserve-timeline semantics.
Client: handleReloadAgent shows a sticky "Reloading agent…" toast,
sends the stale cursor with the fetch so the server returns reset:true,
and only flips to "Reloaded agent" after the response listener has
replaced the visible tail.
Filter persisted metadata-generation sessions out of the import list so
existing on-disk entries stop polluting it for users.
Rename the surface to "Import session" everywhere, swap the bespoke
filter pills for SegmentedControl with provider icons, drop the trailing
provider label on rows (icon is enough), tighten padding, and align the
row icon with the segmented control's left edge.
The GitHub picker showed "No results found." for any running agent after
the first message was sent. useAgentInputDraft owned a local cwd state
that clear("sent") wiped to "", so the composer read cwd: "" even though
the agent's real cwd was correct on the wire.
Drop cwd ownership from the hook. Composer takes cwd as a prop sourced
by the parent — agentState.cwd for running agents, composerState.workingDir
for drafts, sourceDirectory for setup flows. The hook now persists only
{text, attachments}. Legacy persisted drafts with a cwd field still
hydrate; tagged COMPAT(draft-cwd) for removal after 2026-11-09.
Also moves attachment pills back inside the composer's bordered surface
via a new attachmentSlot prop on MessageInput, and shrinks pill height
from 48 to 32 — the subagents-track tuck previously had attachments
floating between it and the input.
Adds one Playwright spec asserting the draft-create-agent path submits
the workspace cwd.
pickAttentionAgent now skips agents with a parentAgentId. The
workspace auto-focus path was opening subagents as tabs when they
needed attention, breaking the rule that subagents only become tabs
when the user explicitly clicks them.
* feat(app): group project settings into Worktree / Scripts / Metadata
Adds SettingsGroup component (title + info tooltip + trailing slot) and
restructures the project settings screen into three categorized groups
with explanatory tooltips. Metadata prompt labels drop the "prompt"
suffix now that they live under a "Metadata generation" group, and
placeholders read as example instructions a real user would write.
Applies useWebScrollbarStyle to the settings ScrollViews.
* Add ExternalLink component and docs links to project settings
* feat(app): create empty workspace from new-workspace composer
Pressing Create with an empty composer (no text, no attachments) on the
new-workspace screen now creates the workspace without an agent and
lands the user on the empty-state, where they can open a terminal or
start an agent on their own. Dispatching submit handler routes to a
named runCreateEmptyWorkspace sibling of runCreateChatAgent so empty
creation is a first-class action, not a hidden branch in the chat path.
* refactor(app): extract empty-workspace helpers into own module
Gives the empty-workspace dispatch a real home, shrinks the test mock
graph from ~20 modules to one.
* test(app): type prompt in new-workspace e2e to exercise chat path
Empty-submission now creates a bare workspace (no agent), so tests that
assert agent-tab/optimistic-draft behavior must type a prompt to take
the chat path.
* feat(server): project-level prompts for metadata generation
Add `metadataGeneration` config to `paseo.json` so projects can customize the
four LLM-generated metadata strings — agent title, worktree branch name, commit
message, and pull request title+body. Each entry takes an `instructions` string
that gets injected as a `<user-instructions>` block between the default rules
and the load-bearing JSON format contract, with wrapper text saying user
instructions override defaults. Missing/empty/invalid config keeps every prompt
byte-identical to today.
* refactor(server): centralize project metadata prompt building
Three near-identical readers and four gate-then-wrap blocks collapsed
into one buildMetadataPrompt helper. wrapWithUserInstructions tightened
to require a non-empty string; its misleading empty fallback is gone.
* feat(app): edit metadata generation prompts in project settings
Adds four textareas (agent title, branch name, commit message, pull
request) under project settings, modeled on the existing setup/teardown
pattern. Round-trips through paseo.json preserving unknown sibling
fields at both the metadataGeneration and entry level.
Surfaces the existing Import-agent sheet directly from the new-agent
draft composer, so users don't have to dig into the workspace header
menu. The pill renders only on draft tabs (gated by the prop being
provided) and reuses the existing WorkspaceImportSheet.
Claude Code ignores --no-session-persistence outside --print mode, so the
SDK's persistSession=false is silently dropped in stream-json mode and the
metadata/branch-name generator sessions kept showing up as resumable. Sweep
the transcript on close when persistSession=false so internal generator runs
no longer leak into `claude` session listings.
- Bucket finished subagents as `done` instead of `attention`; drop
"needs attention" segment from the section header — subagents are
transient and don't carry an unread state worth surfacing.
- Gate the subagents selector on the existing pending-archive query so
optimistic archives survive authoritative agent snapshots that wipe
the local `archivedAt`. Last subagent archived now cleanly hides the
whole track via the existing empty-rows guard.
* Fix chat everyone fanout
* Unify system-injected agent prompts
Chat mentions, schedule fires, and notifyOnFinish now share one shape:
prompt wrapped in <paseo-system>...</paseo-system>, no user-message
turn recorded, no auto-unarchive. Agents can recognize system-injected
context vs a real user turn.
- Move agent prompt orchestration out of mcp-shared.ts into a neutral
agent-prompt.ts module (mcp is an interface, not the home).
- Add `unarchive` and `recordUserMessage` flags to sendPromptToAgent.
- Add formatSystemNotificationPrompt helper.
- Schedule fires now identify themselves: "Schedule \"<name>\" fired
(id=..., run=...)." Plumb runId through runner so the paseo.schedule-run
label matches the actual run record.
- Replace chat-mention validateChatMentionFanout + duplicate
resolveChatMentionTargetAgentIds with single prepareChatMentionFanout
that owns expansion + cap + poster snapshot.
Wire onSubmitEditing on the picker TextInput so the iOS return key
submits a typed daemon-side path through the existing
open_project_request flow. Prepend the typed path to options when not
already present so it's tappable and participates in keyboard nav.
* Restrict provider import fan-out to enabled, non-derived allowlist
Move provider eligibility from a session-level post-filter to the agent
manager's fan-out so disabled providers are never spawned (fixes the
Gemini-disabled hang) and derived providers (like a custom Z.AI extending
Claude) don't duplicate their base provider's sessions.
Add an IMPORTABLE_PROVIDERS allowlist (claude, codex, opencode); ACP-based
providers are excluded from import discovery entirely.
* Filter Codex persisted threads by cwd before hydrating turns
Codex's listPersistedAgents previously called thread/read includeTurns on
every thread returned by thread/list, even when the import sheet was
scoped to one cwd. With many recent threads outside the scope, that fan
of reads dominated the import-sheet open latency.
Add an optional cwd hint to ListPersistedAgentsOptions, thread it through
the agent manager, and have Codex pre-filter the cheap thread/list rows by
cwd before scheduling the per-thread hydration. Widen the thread/list
window when filtering since most rows will be from other cwds. Other
providers ignore the hint.
* Fan out import session fetch per enabled provider
Sheet now reads the providers snapshot already warmed by the workspace
screen, filters to enabled importable providers (claude/codex/opencode),
and issues one fetchRecentProviderSessions request per provider via
useQueries with limit 15. Results are merged, deduped by handle, and
sorted by last activity.
Falls back to a single unfiltered request when the daemon does not
expose a provider snapshot (older servers). Per-provider failures show
inline so a single broken provider does not hide the rest.
* Add provider filter badges to import sheet
Show "All" plus one badge per enabled importable provider above the
session list when more than one is available. Single-select narrows the
already-merged list — no refetch. Each row now shows the provider's
icon next to its label so multiple providers stay visually distinct.
Selection resets when the sheet closes.
* Distinguish already-imported empty state in import sheet
Track how many descriptors were dropped because their handle was
already imported, surface the count back to the client as an optional
`filteredAlreadyImportedCount`, and switch the sheet copy to
"All recent sessions are already imported." when the merged list is
empty solely due to that filter.
* Cover already-imported provider session filtering
* List persisted OpenCode sessions for import
* Revalidate import sessions when sheet reopens
* Add agent persistSession plumbing
* Drop redundant visibility-transition refetch and test boilerplate
The import sheet's queries are gated on `enabled: visible`, and react-query
already refetches stale data when `enabled` flips on, so the manual
`wasVisibleRef` + `invalidateQueries` effect was redundant. Collapse the
two `selectedProvider` reset effects into one.
Drop `expect(...).toBeTruthy()` after `findByText` / `getByText` — those
helpers throw on miss, so the assertion was noise.
Drop `| null` from `buildOpenCodePersistedAgentDescriptor`'s return type
(it never returns null) and the dead `Boolean(descriptor)` filter at the
caller.
* Add provider-opaque session import path
Introduces fetchRecentProviderSessions on the daemon client plus an
importAgent overload accepting providerId/providerHandleId, and a
projection helper that renders persisted descriptors as provider-opaque
recent-session payloads with prompt previews.
* Unslop the import-session feature
Centralizes the importable-providers list, normalizes import_agent_request
shape at one boundary, and removes the per-provider fan-out fallback in
the import sheet. Old daemons without features.providersSnapshot now show
"Update host" instead of being served a degraded path.
Replaces internal-helper unit tests on the OpenCode and Codex providers
with boundary tests through listPersistedAgents; makes storageRoot
injectable on the OpenCode provider so the boundary test can target a
temp directory.
Splits the CLAUDE.md back-compat rule into two contracts: protocol stays
backward-compatible (always); features may require new daemons (no
fallback paths). Tags the two new compat shims with COMPAT(name) and
target removal dates.
* Extract importable-session listing into its own module
Move listImportableProviderSessions out of session.ts and drop the
unused generic listPersistedAgents from AgentManager.
When navigating to a workspace, auto-open the tab for the agent that
most urgently needs attention (permission > error > finished, oldest
first). Adds pickAttentionAgent + wires it into navigateToWorkspace.
Relied on Claude echoing AUTONOMOUS_WAKE_<token> verbatim, which is model-compliance, not the wake mechanism. The structural turn_started + turn_completed pair on the post-foreground subscribe stream is uniquely emitted by startAutonomousTurn() and proves the autonomous live turn fired.
* feat(checkout): merge PR action with real-GitHub e2e test
Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge,
or rebase) and surfaces three new actions in the git-actions menu —
"Squash and merge", "Create a merge commit", "Rebase and merge" — with
squash taking the primary slot when a PR is open and mergeable. Also
renames the existing local merge action to "Merge locally" to disambiguate
it from the new GitHub-side merge.
Adds an end-to-end test that creates a real temporary GitHub repo, opens a
PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and
verifies the merge landed on `main` — no mocks. The test pre-flights the
`delete_repo` scope so it skips cleanly on tokens that can't tear down,
and fails loud if cleanup ever errors so we don't leak repos.
PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING |
UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is
backward compatible — old payloads without the field still parse and
default to `UNKNOWN` via `z.catch`.
* Update PR status snapshot expectations
* refactor: collect git/checkout/PR feature into packages/app/src/git/
Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/,
utils/ flat-peer slop into a single packages/app/src/git/ module with
shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts ->
use-actions.ts, etc).
Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the
shared useGitActions hook is the single source. Drop the
buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out
(mergePrSquash/Merge/RebaseStatus) into one record keyed by
CheckoutPrMergeMethod. Streamline server-side single-use helpers
(dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError
wrapper, legacy worktree test helpers).
Schema-additive only: messages.ts exports CheckoutPrMergeMethod and
PullRequestMergeable types; daemon-client.ts uses the typed alias.
Backward-compatible.
Net delta: 43 files, +219/-1212.
* fix(app): use GitHub icon for all merge-PR menu actions
* test: namespace temp GitHub repos under `paseotmp-` and centralize naming
Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`)
collided with the `paseo-*` namespace of real repos, making bulk cleanup
unsafe. Switch every test that creates real GitHub repos to a single
unmistakable `paseotmp-` prefix, owned by one shared helper per test
boundary so no caller can name a repo outside the namespace.
- New `packages/server/src/server/test-utils/temp-github-repo.ts` exports
`TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both
server e2e tests (checkout-ship, checkout-pr-merge) consume it.
- `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally
and now requires callers to pass a `category` instead of an arbitrary
`prefix`. Updated pr-pane and composer-attachments specs accordingly.
* Surface paseo subagents in a collapsible section above the composer
Subagents no longer auto-open as tabs. Each parent agent's pane shows
a compact tab attached to the top of the composer, listing its direct
children with name and status; clicking a row opens or focuses the
subagent's tab. The section is provider-neutral so Claude Code Task
and Codex subagent sources can feed the same UI later. Reconciliation
now separates pruning (`activeAgentIds`) from auto-opening
(`autoOpenAgentIds`) so manually opened subagent tabs survive.
* Tighten subagents composer code from review
Collapse duplicate helper in workspace reconciliation, swap a hand-rolled
shallow comparator for fast-deep-equal (matching repo convention), drop
an unused React default import and a single-use formatCount helper, and
extract a shared PaneOverrides type in the agent-panel test.
* Fix oxlint failures in subagents code
Switch type aliases to interfaces, hoist inline JSX styles/handlers in
SubagentsSection to satisfy react-perf rules, and lift the test
makeAgent helpers onto AGENT_DEFAULTS so their cyclomatic complexity
drops below the configured cap.
* Add missing unistyles mock exports after rebase
After rebasing onto main, SubagentsSection now imports both
useUnistyles and (transitively, via WorkspaceTabIcon) withUnistyles.
Two test mocks needed updating to expose them.
Test drift fix: the implementation contract is correct; the local
react-native-unistyles mocks were stale.
* Tuck subagents tab under the composer
Subagents tab now grows visually from beneath the composer's rounded
top edge: the surface overlaps the composer by the corner radius so
the rounded curve nests into the tab. Composer's top padding is
removed so the tab sits flush against the input box with no
click-dead gutter. The tab is full-width in both states, the header
label drops the medium weight to follow design-system §3 (content
text inside a surface is normal), and hover covers the entire
visible tab when collapsed but stays scoped to the header row when
expanded.
* Decouple subagent close-tab from archive
Closing a subagent's tab no longer archives the agent — the tab is
removed from the per-client layout, the agent stays in the parent's
track until explicitly archived. Track rows gain an archive button (X)
to make the lifecycle gesture explicit. AgentManager.archiveAgent now
cascades to children carrying the paseo.parent-agent-id label so
subagent fleets don't outlive their orchestrator.
Trade-offs documented in docs/agent-lifecycle.md, including the known
limitation that handoff agents launched via the same MCP path get
cascade-archived alongside true subagents until a richer relation
model lands.
* Stabilize subagent archive button hover
Apply the sidebar workspace row pattern to fix two issues with the
new archive button: the row no longer drops hover when the pointer
moves onto the button, and the button slot reserves layout space
when invisible so adding/removing it doesn't shift the row.
The wrapper View tracks hover via onPointerEnter/onPointerLeave
(workspace rows do the same), and the slot stays mounted with
opacity 0 + pointerEvents="none" when the button is hidden. Also
swap the X icon for the Archive icon to match the gesture's intent.
* Match design language for subagent archive button
Hover state now changes the icon color from foregroundMuted to
foreground, matching the project-row trailing-action pattern in
sidebar-workspace-list.tsx. Drops the surface3 background hover
that didn't match the design language.
Adds a tooltip ("Archive subagent") on the trigger using the
canonical Tooltip + TooltipTrigger asChild pattern. Disabled when
the slot is hidden so it never appears unbidden.
* Slot subagents into one module + harden cascade
Reshape the subagents-in-composer feature so it lives behind one
module and integrates at a few explicit sites instead of being
smeared across generic files.
- Extract `PARENT_AGENT_ID_LABEL` to `packages/server/src/shared/agent-labels.ts`. Sweeps 16 literal sites across server, client, CLI, and tests.
- `packages/app/src/subagents/` is now a deep module: `index.ts` is the only React entry (`SubagentsSection`, `useArchiveSubagent`, `useSubagentsForParent`, `selectSubagentsForParent`, `resolveCloseAgentTabPolicy`, `shouldAutoOpenAgentTab`); `policies.ts` is a second designed entry for non-RN data consumers. Internal files renamed (`section.tsx`, `select.ts`, `close-tab-policy.ts`, `auto-open-tab-policy.ts`, `use-archive-subagent.ts`).
- Move the 22-line subagent archive flow out of `agent-panel.tsx` into `useArchiveSubagent`. Agent-panel becomes a one-line consumer.
- Move the close-tab subagent branch out of `workspace-screen.tsx` into `resolveCloseAgentTabPolicy` (discriminated union).
- Move the auto-open exclusion rule out of `workspace-agent-visibility.ts` into `shouldAutoOpenAgentTab`.
- Theme chevrons in `section.tsx` via `withUnistyles`; drop the forbidden `useUnistyles()` call from the hot path.
Server-side cascade hardening:
- Single write path: extract `markRecordArchived` and route both `archiveAgent` and the off-memory cascade branch through it. Off-memory cascaded children now notify subscribers (previously silent).
- Cascade no longer swallows child failures with `try/catch warn`. Failures propagate.
- Cascade test extended from 1 weak assertion to 5 contract tests: full archive shape (archivedAt, normalized lastStatus, requiresAttention=false), running child runtime stop, off-memory branch, subscriber notification for in-memory and off-memory children, partial-failure surfacing.
Net diff vs the start of this PR: -1037 / +444 lines. The feature now slots into the repo at four explicit sites: `agent-panel.tsx` (3 imports + a hook + the section), `workspace-screen.tsx` (one policy import), `workspace-agent-visibility.ts` (one predicate import), and `agent-manager.ts` (the cascade plus the shared label constant).
Pressable's click handler calls stopPropagation, so the bubble-phase
preventDefault on the wrapping <a> never fired and the Electron view
navigated to the href in addition to the existing helper opening it
in the OS browser. Move preventDefault to onClickCapture (and
onAuxClickCapture for middle-click) so it always runs first. The
existing Pressable.onPress helper remains the only path that actually
opens links.
Replaces the hardcoded Copy/Paste/Select All menu with one driven by
ContextMenuParams: link items (Open in Browser, Copy Link Address),
image items (Copy Image, Save Image As), spellcheck suggestions, and
proper Cut/Copy/Paste in editable inputs. Both the main window and
the browser webview menus go through the shared helper.
MarkdownLink (web branch) now wraps its Pressable+Text in a real
<a href> with `display: contents` so Electron exposes linkURL on
right-click. Native branch is untouched.
create_agent previously threw on cross-provider inheritance unless an explicit
mode was passed. In practice, sub-agents launched without a mode should run
unattended — keying permissions per sub-agent rarely makes sense. When the
caller is in an unattended mode (Claude bypassPermissions, Codex/OpenCode
full-access, Copilot autopilot) and no mode is requested, default the child
to the target provider's own unattended mode. Non-unattended callers still
require an explicit cross-provider mode, and explicit modes always win.
When a Codex-backed agent is archived from Paseo, call Codex's
thread/archive API so the session is also marked archived in the
Codex app. This keeps both UIs in sync.
- Add optional archiveNativeSession() to AgentClient interface
- Implement for Codex provider via thread/archive JSON-RPC call
- Call best-effort in both archiveAgent() and archiveSnapshot() paths
- Failures are logged as warnings and do not block the archive flow
Closes#822
- Make `docs/` the explicit source of truth: "the docs"/"check the docs" mean this directory, not the web.
- List all 16 docs in CLAUDE.md (was 8); group by purpose so unistyles, glossary, data-model, providers, etc. are discoverable.
- Delete the old design.md (designing-features prose) and rename design-system.md → design.md so design tokens own the name.
@gorhom/portal renders children at the host's location in the tree, so
sheets portaled to the root host lost QueryClient context (crash:
"No QueryClient set" when opening provider diagnostic sheet). Reorder
RootProviders and document the invariant: PortalProvider stays innermost.
Single workflow now: update lockfile + hash, build, then commit the fix on
main only if the build passed. Eliminates the structural first-failure where
Nix Build always raced the autofixer and lost.
submit_for_review failed with "appStoreVersions ... is not in valid
state - missing required attribute 'whatsNew'". Apple requires a
non-empty "What's New" string on every new version. With skip_metadata,
deliver never sets it, so we populate it ourselves via spaceship for
every existing localization on the edit version.
submit_review reached the deliver step but failed with "could not find
an editable version for 'IOS'". With skip_metadata + skip_screenshots,
deliver does not auto-create the "Prepare for Submission" entry on App
Store Connect, so submitting fails when the marketing version has not
been created yet.
Use Spaceship's ensure_version! to create or reuse the editable App
Store version for the build's marketing version, and pass app_version
to deliver so it targets the right entry.
latest_testflight_build_number returns the build number (e.g. "2"), not
the marketing version. Previous fix used the version: kwarg which maps
to filter[preReleaseVersion.version] — that's the marketing version
("0.1.70") and never matched the integer build number, so the query
returned no builds and the lane spun on "processing state: unknown"
until timeout.
Apple's API names are confusing: filter[version] is the build number,
filter[preReleaseVersion.version] is the marketing version. The
build_number: kwarg in spaceship maps to filter[version], which is
what we want.
The submit_review lane was crashing with "unknown keyword: :filter"
because Spaceship::ConnectAPI::Build.all doesn't accept a filter hash;
it takes specific kwargs (app_id, version, build_number, etc.) and
constructs the JSON:API filter internally.
Also drop includes: "preReleaseVersion" — it's part of ESSENTIAL_INCLUDES
by default.
Add resubmit-ios-review.yml workflow so we can re-run only the
fastlane submit_review lane (no rebuild) against an existing
TestFlight build via workflow_dispatch.
`release:prepare` runs `npm install` which churns optional-dep `dev`
markers in package-lock.json. That dirties the tree before the
`npm version` step in `version:all:*`, and the pre-commit format hook
rejects a lockfile-only commit because oxfmt internally skips
package-lock.json. Document the run-format/lint/typecheck-first
guardrail in docs/release.md.
isAvailable() honored runtimeSettings.command.mode === "replace" but
resolveClaudeBinary() only looked for `claude` on PATH. Users with a
custom claude wrapper and no `claude` itself on PATH passed the
availability check and then failed at session creation with "Claude
binary not found".
resolveClaudeBinary now mirrors isAvailable: if the runtime command is
in replace mode, look up the override binary first.
- spawn.launch-regression: insert `--` between `node -e <body>` and the
user JSON args. Without it Node treats `--config` as a CLI option and
exits with code 9 ("bad option"). The `--` stops Node's flag parsing
so userArgs land in process.argv intact.
- claude-agent.integration + claude-sdk-behavior: inject
spawnClaudeCodeProcess that routes the SDK's launch through our
spawnProcess (shell: false). On Windows the SDK's default spawn fails
with EINVAL when pathToClaudeCodeExecutable resolves to a `.cmd` shim
(CVE-2024-27980). This mirrors what claude-agent.ts does in production.
- 3 cleanup sites (event-stream, integration, sdk-behavior): swallow
EBUSY/ENOTEMPTY/EPERM around rmSync. The maxRetries window doesn't
reliably win the Windows cwd-lock race after Claude exits; the OS will
reap the tmpdir.
Add three end-to-end tests in spawn.launch-regression.test.ts that exercise
findExecutable -> spawnProcess for each Windows-supported extension. Each
test creates a uniquely-named binary in a tempdir, prepends it to PATH,
verifies findExecutable returns the fixture path, then launches via
spawnProcess and asserts an exact JSON-arg roundtrip plus stdout/exit code.
The previous "does not detect a PowerShell shim from PATH" test is removed:
its expectation was satisfied via PATH-prepend (not isolation), so global
claude.CMD on CI made it return non-null. The supported-extension contract
matches which's hardcoded fallback (.EXE;.CMD;.BAT;.COM); .ps1 is not
supported by spawnProcess and not surfaced by which without explicit PATHEXT.
Also add maxRetries: 5 / retryDelay: 100 to rmSync cleanups in three Claude
integration tests to absorb the brief Windows cwd lock after Claude exits.
Installing claude-code globally on the Windows server-tests runner
exposed 12 pre-existing Windows-specific test failures that were
silently skipped before (rmdir EBUSY during cleanup, .CMD spawn
EINVAL via the SDK, plus the launch-regression test that asserts
claude is *not* on PATH).
The Linux e2e tests (model-catalog, live-preferences, models) are
the only ones that needed the binary to flip isAvailable() true;
none of those run on Windows. Skip the install on Windows so those
Windows compatibility gaps stay surfaced separately and don't block
the SDK upgrade.
Two regressions from the previous CI install:
- Installing opencode-ai globally in server-ci/server-tests caused
opencode-agent.test.ts (gated on \`hasOpenCode\`) to start running
for the first time and surface real but unrelated test breakage.
Trim the install to claude-code, which is all that's needed for the
Claude e2e tests that motivated the change.
- claude-agent.integration.test.ts's supportedModels test calls the
SDK's \`query()\` directly (bypassing ClaudeAgentClient) and didn't
pass \`pathToClaudeCodeExecutable\`. The 0.2.71 SDK had a bundled
cli.js fallback; 0.2.133 doesn't, so on Ubuntu CI it tries the
per-platform binary path (\`...claude-agent-sdk-linux-x64-musl/claude\`)
which isn't installed via the \`claude-code\` global. Resolve the
binary explicitly in the test and pass it as the SDK option.
server-tests in ci.yml and Server CI both need claude on PATH for the
e2e tests that probe model catalogs and live preferences. cli-tests
already installs the trio (claude-code, codex, opencode-ai); server
tests now match.
The session previously called resolveClaudeBinary() directly in
buildOptions(), with no seam for tests. CI ran without claude on PATH,
so every test that exercised buildOptions hit the production throw.
Apply the ports-and-adapters pattern at the client boundary (the same
place tests already configure queryFactory):
- ClaudeAgentClient takes optional resolveBinary, defaults to
resolveClaudeBinary which throws "Claude binary not found..." when
the executable is missing — preserving production fail-fast.
- Client passes resolveBinary through to ClaudeAgentSession options.
- buildOptions calls this.resolveBinary() instead of importing the
free function directly.
- Tests construct the public client with a stub resolver. No
__claudeAgentInternals export, no reaching into the session.
User-facing fail-fast still lives where it always did — AgentManager
gates start/resume/draft flows on client.isAvailable(). The throw in
resolveClaudeBinary is defense-in-depth that mirrors Codex.
resolveClaudeBinary threw eagerly inside buildOptions, breaking unit tests
that mock the SDK at the module level (env, redesign, interrupt-restart-
regression suites). The user-facing fail-fast already lives in isAvailable();
in buildOptions, fall back to omitting pathToClaudeCodeExecutable so the SDK
surfaces its own error if the binary truly is missing.
Bumps @anthropic-ai/claude-agent-sdk from ^0.2.71 to ^0.2.133. The new SDK
hardens handleControlRequest against post-close transport writes (v0.2.94+),
which fixes the unhandledRejection that crashed the daemon when the SDK fired
a fire-and-forget control_request after we'd torn down the transport.
The SDK now ships per-platform Claude Code binaries via optionalDependencies
(~210 MB each). Paseo requires user-installed `claude` on PATH instead — same
posture as Codex/OpenCode:
- after-pack now prunes @anthropic-ai/claude-agent-sdk-* from the Electron
bundle, matching the existing per-platform pruning for onnxruntime, sharp,
and node-pty.
- claude-agent.ts gains resolveClaudeBinary() (mirrors resolveCodexBinary):
throws with install instructions when `claude` is missing, instead of
silently falling back to the bundled binary.
- isAvailable() now checks isCommandAvailable("claude") instead of always
returning true.
Adds a pre-start preferences step (onboards users with no
orchestration-preferences.json), a capture-intent step that runs
even under --autopilot, an immutable Requirements block in the plan
that includes acceptance criteria, and a final lightweight check
that audits every requirement before deliver. Drops --no-grill.
* Run server tests on Linux+Windows matrix
Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.
* Add isPlatform helper and gate Windows-hostile server tests
- Add a shared server test isPlatform helper.
- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.
- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.
* Replace POSIX shell calls in test fixtures with Node primitives
- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.
- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.
- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.
* Fix Windows server-test failures and split POSIX-only suites into sibling files
- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.
* Move POSIX-only test blocks into sibling .posix.test.ts files
- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.
- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.
- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.
- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.
- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.
* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests
- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.
- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.
- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.
- Gate the two explicitly Linux-only workspace-git watcher tests.
* Replace hardcoded POSIX paths with portable Node path constructions in server tests
- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.
- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.
- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.
- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.
* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows
- Run the loop-service verify script through the current Node executable with a relative script path.
- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.
* Fix loop-service worker PTY spawn on Windows
- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.
* Gate loop-service real-worker-PTY test on Windows
- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.
* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows
- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.
* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir
- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
CLI provider e2e test (15-provider.test.ts) pinned the claude catalog
size; PR #799 added claude-sonnet-4-6[1m] but did not update this list.
Sibling fix to 50405c3b.
Workspace lookup was rewriting the agent cwd to the parent workspace cwd. Pass the requested cwd through faithfully and still attach workspaceId for grouping.
Fixes#551
Adds a primitive test that initialises a real git repo, writes a
.gitignore covering an ignored subtree, and asserts the Linux working
tree walker registers watchers for kept directories but not for
gitignored ones. Exercises the real `git ls-files -o -i --directory
--exclude-standard` shell-out the new code path depends on.
The "shows only the changelog action once the update is installed"
case in update-callout-source.test.tsx pinned the rendering path that
2d9c7747 deleted. The callout now early-returns for any status other
than available/installing/error, so the assertion that the container
contains "Update installed" can never be satisfied.
The install flow briefly flashed an "Update installed / Restart to use
the new version" callout between clicking Install & restart and the
actual app quit. The button implies action; the restart is already in
flight.
Run quitAndInstall inline (no 1.5s setTimeout) so the IPC sequences
download → install → daemon stop → app quit before returning. Drop the
"installed" rendering branch from the callout so the success path goes
straight from "Installing..." to the window unmounting.
On Linux fs.watch isn't recursive, so each working tree manually walks
the entire subtree and registers a per-directory watcher. Every inotify
event then re-scans the tree and rebuilds watchers. A working tree
containing a directory that's continuously written (e.g. test-runtime
data, build artefacts) drives the event loop to 100% CPU and the
daemon stops responding on its websocket.
Skip git-ignored directories when walking — load `git ls-files -o -i
--directory --exclude-standard` once per root (5 min cache) and skip
its entries. Cap the walked directory count at 5000 so a misconfigured
repo can't blow past the inotify budget. Add a 2s cooldown between
refresh passes so a flurry of events collapses into one re-scan
instead of a tight loop.
Co-authored-by: xuzhe.7766 <xuzhe.7766@bytedance.com>
* Replace skills auto-sync with explicit install/update/uninstall
Skills no longer sync silently on every desktop launch. Settings now
shows Install (fresh), Update (with a confirm dialog listing per-skill
add/update/delete ops), or Uninstall depending on what's on disk.
A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned
skills so uninstall removes only what we wrote, never user content.
Existing users migrate transparently — first read synthesises a
manifest from on-disk hashes, so an unchanged install flips silently
to up-to-date.
Also restructures the integrations layer: skills/ and cli-install/
each own their files behind a designed index.ts. The shared
integrations-manager.ts is gone.
* Rewrite skills install flow with disk-truth model
Drops the manifest sidecar, migration logic, and the
agentsDirHasSkillContent heuristic that misread user-authored skills
as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES
list (current bundle + paseo-chat for retired-skill cleanup); the
disk is the source of truth for what's installed.
Renames the SkillsState "fresh" → "not-installed" everywhere
(server, IPC parser, hook test mocks). Install and update share an
applySkills body. Errors propagate instead of being swallowed.
Fixes the case where Uninstall left state at "drift" because the
user had personal skills under ~/.agents/skills/.
* Stop symlinking claude skills, write real files
If the user has ~/.claude/skills symlinked to ~/.agents/skills, the
per-skill symlink path resolved through the parent symlink and fs.rm'd
the agents-side files we'd just written. Mirror real files to claude
the same way as codex; agents stays the canonical source-of-truth dir.
* Make Uninstall button visible (outline, not ghost)
Pasting a GitHub issue or pull request URL into the composer's
issue/PR picker now searches by the number from the URL instead
of treating the full URL as a keyword that returns no matches.
* Fix desktop CLI passthrough tty handling
* Fix desktop daemon manager test drift
* Update daemon stop test after main merge
* test(cli): isolate npx cache per CLI test
* Use tree-kill for daemon shutdown
* fix: update lockfile signatures and Nix hash
* Use tree-kill for daemon shutdown
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(cli): honor PASEO_PASSWORD env var (fix#776)
The CLI only extracted passwords from `tcp://host?password=` URIs, so
`PASEO_PASSWORD=xxxx paseo ls` connected without auth and was rejected
by the daemon with "Password required". Fall back to the env var when
the host (TCP or otherwise) carries no password.
* docs: document PASEO_PASSWORD env var as a CLI auth source
Pairs with the CLI fix in this branch — the "Connecting with a password"
section now lists both the tcp URI query and the env-var fallback, with
their precedence. Also clarify the dual role (daemon vs CLI) of
PASEO_PASSWORD in the env var reference.
* fix(relay): use TLS for any port-443 relay endpoint
`shouldUseTlsForDefaultHostedRelay` only returned true for
`relay.paseo.sh:443`. Self-hosted relays on port 443 connected via
`ws://` instead of `wss://`, causing HTTP 400 from the TLS server.
Port-based detection: port 443 → wss, everything else → ws.
* Test port-based hosted relay TLS detection
Cover the new behavior: any port-443 endpoint (hosted Paseo or
self-hosted) and IPv6 hosts return true, non-443 ports and malformed
input return false.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Auto-naming should only run during worktree creation (mnemonic on create,
async LLM rename moments after). Creating a new agent inside an existing,
properly-named branch must not trigger a rename.
Adds optional `tryHandleOutOfBand` on AgentSession so providers can
handle slash commands as side effects without allocating a turn or
tripping the activeForegroundTurnId gate. Codex provider implements it
for /goal (set/pause/resume/clear), version-gated to codex >= 0.128.0
with `--enable goals`. Mid-turn /goal pause now works alongside a live
turn against the same thread.
E2E test against real codex 0.128.0 covers set + mid-turn pause + clear.
The cold workspace URL navigation fix changed the / startup path for a matching persisted workspace. Index no longer renders a workspace Redirect there; it calls navigateToWorkspace so the centralized workspace navigation path owns the transition, then keeps the startup splash visible while that imperative navigation runs.
Update the test mock to expose navigateToWorkspace and assert that behavior directly. This keeps the test aligned with the route-shape fix and prevents CI failures on branches rebased over main.
Cmd+R was scoped through a stale Electron-side active browser value. Opening or focusing a browser pane set activeBrowserPaneId, but switching to an agent tab only updated workspace layout state. Electron still remembered the old browser and the app menu reloaded that hidden/previous browser instead of the app.
Make workspace layout the authority. Derive the active browser from focusedPaneId plus the focused pane's focusedTabId, and sync browserId|null to Electron from one WorkspaceScreen effect. BrowserPane no longer marks itself active from URL-bar or webview focus handlers, so call sites do not have to remember to clear Electron state when focus moves to an agent, terminal, or another pane.
Electron now treats the synced value as the workspace-active browser, not an independently observed focus source. Menu reload uses that workspace-derived browser when present; otherwise it reloads the app window. Direct Cmd+R events that originate inside a browser guest still reload that guest through the guest before-input-event path.
Browser webviews now own their guest context menu in the main process. Add copy/paste/select-all and a development Inspect Element action for attached browser guest WebContents.
DevTools opening is routed through the main process by browserId. Docked-right DevTools did not become visible for browser guest contents in practice, even though inspect mode activated, so the final behavior opens detached DevTools for both the toolbar button and Inspect Element. The renderer logs bridge/result failures, and main logs registration/open/inspect outcomes for future debugging.
Regression coverage is at the workspace layout derivation layer: focused browser id follows the focused pane's active browser tab and becomes null when the active tab is not a browser. Verified with targeted layout-store tests, navigation E2E, settings E2E, npm run typecheck, and npm run lint.
Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops.
The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route.
Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links.
Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract.
Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.
Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
- New /cloud page with signup form (email, name, company, role, message)
posting to a Discord webhook via a TanStack Start server function on
the Cloudflare Worker. Webhook URL read from DISCORD_WEBHOOK_URL secret.
- Extract SiteShell, SiteFooter, FAQItem from landing-page.
- Migrate /privacy, /download, /blog, /changelog, /cloud to SiteShell so
they share header + footer with consistent width.
- Add /cloud to header and footer nav.
- Make /docs sidebar full-width; bump footer text from xs to sm.
- Add Cloudflare Workers types and ignore .dev.vars.
Splits the old "Web & CLI" block into separate Web and Server sections so
new users can find the headless install flow without reading the FAQ.
Each section gets a one-line subtitle, and command rows reuse the
CodeBlock component (extracted from CommandDialog) with a new compact
size variant. Also adds Homebrew and Nix install rows, and stacks rows
vertically on small screens.
* refactor(server,app): lift return types and parse at boundaries (T2 typeaware production sweep)
* fix(server): align acp-agent test assertions with pino call shape
Introduce createStub<T> (Proxy-based) and asInternals<T> (single-cast wrapper)
in test-utils/class-mocks.ts to replace all as unknown as casts in server test
files that construct fakes of private-field classes or access class internals.
- class-mocks.ts: one justified as unknown as T cast inside createStub; Proxy
throws on any unstubbed method call, adding real runtime safety vs bare cast
- session-stubs.ts: all 19 as unknown as casts replaced with createStub/asInternals
- websocket-server.notifications.test.ts + relay-reconnect.test.ts: createStub
for HTTPServer / pino.Logger / AgentManager stubs; asInternals for internals access
- snapshot-mutation-ownership.test.ts: createStub for SessionOptions field stubs
- session.workspace-git-watch.test.ts: createStub for all SessionOptions fields;
asInternals for SessionInternals access
- session.workspace-resolution-invariants.test.ts: createStub + asInternals
- acp-agent.test.ts, claude-agent.redesign.test.ts, codex-app-server-agent.test.ts,
acp-wrapper-smoke.test.ts: asInternals for all internal-access casts
Errors cleared: ~80 as unknown as casts removed across 11 files.
Lint: 0 errors on all 11 files. Typecheck: green.
Cluster T1.b — first wave of no-unsafe-type-assertion fixes across agent
provider and MCP test files. Clears all type-aware lint errors in 6 files;
remaining 4 files (class-mock patterns) deferred to T1.b2.
Fixes all reachable typescript-eslint(no-unsafe-type-assertion) errors
in the four in-scope test files by:
- Adding Zod schemas (WireEnvelopeSchema, BinaryFrameSchema,
RuntimeMetricsLogSchema) and helpers (parseSentEnvelope, parseSentFrame,
assertStr) to parse wire frames instead of casting JSON.parse() results
- Replacing `JSON.parse(x) as { ... }` with Zod-validated helpers
- Replacing `mock.sent[N] as string` / `String(mock.sent[N])` with assertStr()
- Replacing `frame as Uint8Array` with direct type annotation (Buffer extends Uint8Array)
- Replacing `session.args.onMessage as (() => void) | undefined` with typeof
function guard and direct invocation
- Replacing `metricsCall![0] as RuntimeMetricsLog` with RuntimeMetricsLogSchema.parse()
Remaining `as unknown as SomeClass` patterns in constructor mocks and
`client as unknown as { ... }` internal-access patterns are out of scope:
they require extracting interfaces from concrete classes (AgentManager,
AgentStorage, FileBackedChatService, etc.) with private members, or
refactoring the client API to expose internal methods — neither is safe
to do in a test-only sweep.
* test(app/e2e): cover sessions-screen empty state (Cluster G7)
Adds one E2E test — opens Sessions on a fresh workspace with no agents
and asserts the "No sessions yet" placeholder renders. Uses `withWorkspace`
fixture (no agent seeding) so the empty branch runs for the first time.
Also exports `expectSessionsEmptyState` helper from archive-tab helpers
for reuse in future session-related specs.
* fix(app/e2e): run sessions-empty test before archive-tab agents are created
The sessions screen shows global agent history for the daemon. Running
the empty-state test last meant the reconciliation tests had already
created 6 agents, so "No sessions yet" never rendered.
Moving the describe block first ensures it runs on a clean daemon
(workers:1, fullyParallel:false, archive-tab.spec.ts is first alphabetically).
* refactor(app/e2e): guard sessions-empty ordering + dedup selector constant
Addresses reviewer feedback on the ordering fragility:
- Add NOTE comment above Sessions screen empty state describe explaining
why it must remain first in the file (daemon history is global; the
reconciliation tests below call createIdleAgent which would pollute it).
- Add a fast-fail guard in expectSessionsEmptyState: asserts 0 agent rows
with a 5s timeout so a future maintainer sees an immediately actionable
failure message rather than a mysterious "No sessions yet" timeout.
- Extract AGENT_ROW_SELECTOR constant to eliminate the duplicated
[data-testid^="agent-row-"] string shared by getSessionRowByTitle and
the new guard.
* fix(app/e2e): move sessions-empty to 00-prefixed file to survive new specs
agent-stream-ui.spec.ts (merged in #743) sorts before archive-tab.spec.ts
and creates agents, breaking the empty-state test. Any future a*-*.spec.ts
has the same risk.
Fix: move the test to 00-sessions-empty.spec.ts — digit prefix sorts
before all alpha-named specs, making the ordering constraint explicit at
the filesystem level.
Also adds a beforeAll daemon probe that fails fast with a clear message
if any pre-existing agents are found, covering both ordering violations
and stale daemon state from a previous run.
Supporting changes:
- Add fetchAgentHistory to ArchiveTabDaemonClient interface (typed as
Array<{ id: string }> — enough for the count check)
- Remove the test and NOTE comment from archive-tab.spec.ts
- Update expectSessionsEmptyState guard comment to reference the new file
* test(app/e2e): add picker keyboard-interaction tests (Cluster G8)
Cover branch-picker keyboard contract: open via Space, navigate with
ArrowDown/ArrowUp, select with Enter, close with Escape.
Adds six helpers to helpers/new-workspace.ts:
openBranchPicker, selectPickerOptionByKeyboard, closeBranchPicker,
expectPickerOpen, expectPickerClosed, expectPickerSelected.
Also moves delayBrowserAgentCreatedStatus and its private helpers out of
new-workspace.spec.ts into the helpers module where they belong.
* fix(app/e2e): address picker keyboard test review feedback
- Add { timeout: 30_000 } to expectPickerClosed (FadeOut animation safety)
- Simplify selectPickerOptionByKeyboard: ArrowDown → Enter (remove redundant ArrowUp)
- Migrate expectStartingRefPickerTriggerPr trigger selector from testID to ARIA role
* fix(app/e2e): fix picker keyboard test — open via click not Space
RN Web Pressable renders as <div role="button"> which does not fire
onPress from a programmatic Space key event. Switch openBranchPicker
to trigger.click() so the picker reliably opens in CI headless Chrome.
Keyboard behaviour (ArrowDown + Enter, Escape) is still exercised by
selectPickerOptionByKeyboard and closeBranchPicker respectively.
* test(app/e2e): sidebar query pause + mobile open/close transition (Cluster G6)
G6.1: assert fetch_workspaces_request stops being sent when desktop sidebar
is closed (CDP WebSocket frame counting, no store injection).
G6.2: assert mobile sidebar panel animates in/out at 390×844 viewport via
toBeInViewport on translateX-animated element.
Adds installWorkspaceFetchMonitor, expectWorkspaceListSubscribed,
closeSidebar, openMobileAgentSidebar, closeMobileAgentSidebar,
expectMobileAgentSidebarVisible, and expectMobileAgentSidebarHidden helpers.
* test(app/e2e): mobile sidebar open/close transition (Cluster G6)
G6.1 (query-pause perf invariant) reclassified as a unit-test follow-up
— counting internal RPC frames in E2E is banned per updated roadmap.
G6.2: asserts mobile sidebar panel animates in/out at 390×844 viewport
via toBeInViewport on the translateX-animated sidebar-sessions element.
Adds openMobileAgentSidebar, closeMobileAgentSidebar,
expectMobileAgentSidebarVisible, and expectMobileAgentSidebarHidden helpers.
* test(app/e2e): stream auto-scroll and working-indicator→copy-button (Cluster G5)
Wire in the unused agent-bottom-anchor helpers and add two new agent stream
UI specs: auto-scroll stays pinned to the bottom across token bursts, and
the inline working-indicator transitions to a copy-button when the stream
ends. Both tests use the mock provider so there is no real LLM dependency
in CI.
Adds three helpers to helpers/agent-stream.ts:
- expectInlineWorkingIndicator
- expectTurnCopyButton
- expectScrolledToBottom (wraps agent-bottom-anchor)
* fixup: address review blockers and nits
- Replace expectScrolledToBottom passthrough with expectScrollFollowsNewContent
that encapsulates readScrollMetrics → waitForContentGrowth → expectNearBottom
- Remove direct agent-bottom-anchor imports from spec body (DSL leak)
- Add awaitAssistantMessage before expectInlineWorkingIndicator to anchor on
real content before asserting the working indicator
- Add comment to expectInlineWorkingIndicator explaining why testId is used
(animated spinner View has no ARIA role)
When npm runs a workspace script, it only adds the workspace-local
node_modules/.bin to PATH. packages/cli/node_modules/.bin doesn't exist
because all packages are hoisted to the root. This caused `npx paseo`
inside test files to miss the local binary and fall back to the npm
registry, where a stale npx cache entry for @getpaseo/cli was missing
bin/paseo — failing the chmod with ENOENT and exiting 254.
07-agent-stop was the first test in shard 2/3 to call `npx paseo` without
spinning up a daemon first, so it reliably hit the broken cache path.
Fix: prepend root/node_modules/.bin to PATH in the spawn env for each
test subprocess.
* fix(app/e2e): fix composer-lock test — use mock provider + prompt so lock releases
Two bugs in the "composer is locked while new workspace agent is being
created" test:
1. expectComposerDisabled used toBeDisabled() but React Native TextInput
with editable={false} renders as <textarea readonly> on web, not
<textarea disabled>. Fixed to not.toBeEditable().
2. clickNewWorkspaceButton created an agent with no prompt, leaving the
agent permanently "idle". showPendingCreateSubmitLoading only clears
when authoritativeStatus is non-bootstrapping, but "idle" counts as
bootstrapping — so the composer lock never released. Fixed by opening
the new-workspace composer, filling a prompt, then clicking Create so
the mock agent transitions to "running" after agent_created is released.
Also switches buildCreateAgentPreferences to provider "mock" with the
ten-second-stream model so E2E tests exercise app behavior without
depending on real provider availability.
* fix(app): add aria-checked to Switch for E2E toBeChecked() assertions
React Native Web does not map accessibilityState.checked to aria-checked
for role="switch", so Playwright's toBeChecked() always finds the element
unchecked. Adding aria-checked={value} directly to the Pressable sets the
attribute explicitly.
Update the switch.test.tsx mock to accept and pass through the explicit
aria-checked prop, keeping the mock faithful to the fixed component.
* test(app/e2e): cover 5 error-UX paths + host indicator + script removal for project settings
Add helpers/project-settings.ts DSL helpers and extend projects-settings.spec.ts
with 5 new tests covering the error paths dropped by PR #725:
- stale_project_config callout + disabled save + reload recovery
- invalid_project_config read callout + reload after fix
- write_failed callout + retry + reload recovery
- single-host static indicator vs picker chip
- script removal via kebab menu + confirm dialog
* refactor(app/e2e): unslop project-settings helpers and spec
- Fix removeProjectScript: derive trigger testID from row testID instead
of using scoped locator (which was timing out)
- Extract inline writeFile call in invalid-config test to restorePaseoConfig helper
- Replace raw testID click in write_failed test with clickReloadProjectSettings
- Remove writeFile from spec imports; drop defensive ?? "" fallback
* test(app/e2e): add read-transport and offline no-target tests for project settings
- Add read-transport failure test: WS-level drop during readProjectConfig triggers
read-transport-callout; Reload retries until WS reconnects and refetch succeeds.
- Add no-target test: WS drop after form load triggers NoEditableTarget via live
connectionStatus check (useHostRuntimeSnapshot) on the selected host.
- Hoist openProjects/editWorktreeSetup from spec body into project-settings helper.
- Fix expectNoProjectSettingsError to accept optional timeout (needed for toPass loop).
- Add isHostGone to renderContent: after readQuery errors are checked, offline/error
connectionStatus renders NoEditableTarget without unmounting ProjectSettingsBody.
* fixup(app/e2e): correct misleading comments on WS close → error-state mapping
* test(app/e2e): add composer-attachments spec covering 8 attachment behaviors
Restores E2E coverage for composer attachment behaviors dropped in PR #720:
plus-menu visibility, GitHub combobox lazy search, image lightbox, pill render,
pill removal (with hover-reveal), queue-on-running-agent, review-pill suppression
(test.fixme pending store seeding bridge), and Escape interrupt draft preservation.
Includes accessibilityRole="button" on QueuedMessageRow Pressables (was rendering
as generic, breaking role-based selectors) and an opt-in E2E debug surface on
useWorkspaceAttachmentsStore (localStorage gated, needed for the fixme test).
* refactor(app/e2e): unslop composer-attachments spec and helpers
Remove dead `expectComposerLocked` export (never imported), trim verbose
JSDoc on `pressInterruptShortcut`, and correct the test.fixme comment
which said the store wasn't window-exposed (it is, as of the parent commit).
* fix(app/e2e): address PR #734 review blockers for composer-attachments spec
- Remove window.__paseoWorkspaceAttachmentsStore exposure (hard ban on internal
state injection)
- Merge helpers/composer-attachments.ts into helpers/composer.ts; delete old file
- Add openGithubWorkspace, selectGithubOption, expectGithubAttachmentPill,
expectComposerDisabled, expectAttachButtonDisabled helpers to composer.ts
- Extract delayBrowserAgentCreatedStatus from new-workspace.spec.ts into
helpers/new-workspace.ts so it can be shared
- Add real GH issue/PR pill tests using createTempGithubRepo fixtures
- Rewrite lock-state test to assert textarea disabled + attach button disabled
during in-flight workspace creation (submitBehavior=preserve-and-lock)
- Add test.fixme with detailed explanation for workspace-review pill (requires
diff pane automation not yet in E2E harness)
- Add test.fixme for browser-element pill (Electron-only, not testable in
headless Chromium)
* refactor(app/e2e): unslop composer helpers and spec after blockers fix
- Remove AI section headers from composer.ts (not in project convention)
- Fix fillComposerDraft: drop redundant click() before fill()
- Fix selectGithubOption: extract locator to local var instead of double getByTestId()
- Use clickNewWorkspaceButton in lock-state test instead of raw Create button locator
- Drop obvious PNG constant comment
* feat(app/e2e): add PR pane E2E spec with fixture-based seeding
Adds 7 tests covering open/merged/closed/draft states, check pill
counts, activity row count, and the empty-checks graceful render.
Fake gh CLI now reads .paseo-e2e-pr.json and .paseo-e2e-timeline.json
from the workspace cwd so each test gets isolated fixture data.
* refactor(app/e2e): switch PR pane spec to real GitHub fixtures
Replace the fixture-file seeding approach with ephemeral real GitHub
repos created via the gh CLI. `helpers/github-fixtures.ts` creates a
single private repo per test run, pushes one branch per PR scenario,
and seeds commit statuses and comments as needed. The fake gh binary
now forwards unhandled calls (no fixture file present) to the real gh
so the daemon can query live GitHub data.
All 7 tests skip gracefully when gh auth is unavailable.
* refactor(app/e2e): address PR review feedback on pr-pane spec
- helpers/pr-pane: extract assertCheckPill helper so expectPrPaneCheckSummary
is a flat 3-call sequence; remove branching on rendering shape
- helpers/pr-pane: drop .first() on explorer button and redundant toBeVisible
before click; import getStateLabel from @/utils/pr-pane-data instead of
duplicating the map
- pr-pane.spec.ts: move test.skip and test.setTimeout into beforeEach; replace
positional IDX_* constants with workspaceByTitle Map keyed by PR title
- helpers/github-fixtures: add IssueSpec/GhIssueFixture and issues[] option;
extract seedPr/seedIssue to satisfy complexity limit; make prs/issues optional
* test(app/e2e): add desktop-updates spec covering update banner and daemon lifecycle (cluster G4)
Adds a new Electron-only E2E spec and companion helper module covering:
- Update callout renders with correct version and shows Installing… on click
- Daemon management toggle confirm dialog copy, cancel, and confirm flows
- Daemon status panel seeded from the real running E2E daemon (version, PID, log path)
- Stopping then re-enabling management observes a fresh PID from the stateful IPC mock
Exports E2E_PASEO_HOME from globalSetup so tests can read the paseo.pid lock file
and derive the daemon log path without hardcoding paths.
* fix(app/e2e): address PR review blockers on desktop-updates spec
Blocker 1 — ARIA for install button: replace index-based testId locators
in clickInstallUpdate and expectInstallInProgress with getByRole("button")
using the accessible name ("Install & restart" / "Installing...").
Blocker 2 — Electron dialog path: add dialog.ask to the mock bridge so
confirmDialog() hits the Electron code path instead of falling back to
window.confirm. The mock stores captured args on window.__capturedDialogCall;
interceptDaemonManagementConfirmDialog reads them via waitForFunction+evaluate.
Add confirmShouldAccept config flag so tests control accept/dismiss without
a Playwright dialog event. Update all daemon management tests to set the flag.
Also: console.warn on PID file read failure, comment explaining the no-Electron-
runner approach, rename dialog → dialogArgs at call sites.
Rewrites 7 spec files to use DSL helpers throughout — zero raw
page.locator/getByText/getByTestId in test() bodies. Adds 30+ new
helper primitives across 7 existing helper modules.
Replace 21 raw page.locator/getByTestId/getByText calls in the spec body
with named DSL operations in helpers/settings.ts. Each test body now reads
as prose: open settings, navigate to section, expect content.
New helpers: openCompactSettings, expectCompactSettingsList,
expectSettingsSidebarVisible/Hidden/Sections, goBackInSettings,
expectSettingsBackButton, clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect, openCompactSettingsHost,
expectHostSettingsUrl, expectAddHostMethodOptions, fillDirectHostUri,
expectDirectHostFormValues, expectDirectHostSslEnabled,
expectDirectHostUriValue/Hidden, expectDiagnosticsContent,
expectAboutContent, expectGeneralContent.
Export requireServerId from sidebar.ts so helpers encapsulate serverId
logic — spec has no direct env reads.
Also fix pre-existing typecheck error in use-keyboard-shortcuts.ts:
cast action.route to the expo-router Href type at call sites.
Deletes four screen-level .test.tsx files (2600+ lines of vi.mock + JSDOM
slop) and replaces each with verified coverage:
- sessions-screen.test.tsx: covered by archive-tab.spec.ts which exercises
the sessions screen via openSessions() with real agents
- workspace-draft-agent-tab.test.tsx: covered by new-workspace.spec.ts
"redirects to optimistic draft tab before agent creation resolves"
- new-workspace-screen.test.tsx: covered by new-workspace.spec.ts (main
submit, branch selection, PR selection, optimistic draft tab) plus
new-workspace-picker-item.test.ts (pickerItemToCheckoutRequest)
- project-settings-screen.test.tsx: covered by project-config-form.test.ts
(all round-trip string/array lifecycle semantics) and projects-settings.spec.ts
(save flow with passthrough field preservation)
Extracts syncPickerPrAttachment from new-workspace-screen.tsx into a new
pure module new-workspace-picker-state.ts with 5 zero-mock unit tests
covering: initial PR selection, branch selection without change, PR
replacement, PR removal on branch switch, and no-duplicate guard.
- Delete clickNewTabButton (duplicate of clickNewChat) and clickNewTerminalButton (duplicate of clickNewTerminal); update all call sites
- Rename clickTerminal → clickNewTerminal to match clickNewChat naming pattern
- Delete waitForLauncherPanel (deprecated no-op)
- Delete waitForAgentFinishUI and getToolCallCount (dead exports never imported)
- Remove ensureE2EStorageSeeded, assertE2EUsesSeededTestDaemon, and related helpers from app.ts — the paseoE2ESetup auto-fixture seeds via addInitScript on every navigation, making these redundant
- Simplify gotoAppShell to a one-liner; simplify gotoHome to use .or() instead of three-way if-else chain
- Strip try/catch self-heal from ensureHostSelected — the fixture guarantees preferences alignment, so the workaround is never needed
* test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store assertion
Removes desktop-updates-section.test.tsx (16 internal mocks, JSDOM, toHaveBeenCalledWith chains on component renders).
Tightens desktop-attachment-store.test.ts: toHaveBeenCalled() → toHaveBeenCalledWith(attachment).
* refactor(app): extract daemon management toggle coordinator with unit tests
Fills the coverage gap left by deleting desktop-updates-section.test.tsx.
Extracts executeDaemonManagementToggle from useDaemonManagementToggle — pure
async coordinator with injected deps (no vi.mock). Unit tests verify the three
key invariants: settings persist before stop, stop is skipped when not
desktop-managed, and start/stop are invoked on enable/disable.
Drops the per-test manual boilerplate (connectWorkspaceSetupClient,
createTempGitRepo, seedProjectForWorkspaceSetup, openProject,
openHomeWithProject, navigateToWorkspaceViaSidebar, try/finally cleanup)
in favour of the withWorkspace fixture landed in #717.
Both tests — main checkout and worktree — verified green locally.
Move composer cancel/queue/attachment/dispatch orchestration out of
composer.tsx into a React-free actions module. The module takes its
dependencies (send client, queue writer, stream writer, attachment
persister, image picker) as injected ports, so the new
composer-actions.test.ts can drive every action with inline fakes —
zero vi.mock of internal modules, zero JSDOM, zero React.
The old composer.test.tsx (heavy mocked component test) is removed
and replaced with composer-actions.test.ts (31 unit tests).
Also extract isWorkspaceAttachment / userAttachmentsOnly /
workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx
into a sibling .ts so the actions module (and composer-attachments.ts)
can use them without dragging React/RN/lucide into a pure test graph.
Moves all preference-resolution logic into a pure `resolveAgentForm(state, action)` reducer
with a discriminated-union action type. The hook becomes a thin React container: it holds state
via `useReducer`, dispatches actions, and owns the hydration-ref timing logic.
Removes the `__private__` export and the vi.mock-heavy live test in favour of direct pure-function
unit tests (53 tests, zero vi.mock of internal modules).
Replace 8-mock hook test (expo-router, layout, platform, navigation,
4 stores) with a pure unit test of the routing decision. The hook now
reads pathname/layout/key, calls routeKeyboardShortcut, and dispatches
the resulting ShortcutAction — no behaviour change.
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.
The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
Adds a `withWorkspace` Playwright fixture plus composable helpers
(permissions, sidebar, composer, agent-stream, settings) so specs read
as user-level intent. Migrates workspace-lifecycle and settings-host-page
to the new DSL as proof.
Replace toBeDefined/toBeTruthy guards with concrete shape assertions
using toContainEqual(expect.objectContaining(...)) in diff-highlighter
tests, and toMatch(/^script-draft-\d+$/) for the ID check in
project-config-form tests.
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts
so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests
for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor
coverage. Delete pr-pane.test.tsx — the component test was asserting on derived
label/icon strings that are now covered by pure function tests.
Rewrites two store tests to verify observable state instead of implementation-level call counts.
navigation-active-workspace-store: the "persists" test now uses a functional in-memory AsyncStorage mock that retains written values across vi.resetModules(), then rehydrates a fresh store instance and asserts the resulting selection state matches what was written.
checkout-git-actions-store: removes toHaveBeenCalledWith/toHaveBeenCalledTimes assertions on client methods; replaces with getStatus() assertions that reflect the store's actual state after each action completes or fails.
* perf(cli): run E2E tests in parallel via worker pool
The custom CLI test runner ran 35 tsx test files sequentially, making the
cli-tests CI job the longest in main CI (~17 minutes). Each test file
already isolates its own daemon (ephemeral port + tmp PASEO_HOME), so
parallelism was just gated by the runner.
Replace the sequential recursion with a fixed-size worker pool (default
concurrency=4 to match GitHub Actions standard runners; override via
PASEO_CLI_TEST_CONCURRENCY). Buffer per-test stdout/stderr and flush as
a contiguous block on completion so concurrent output stays readable,
and report per-test wall clock plus the five slowest tests.
Local wall clock drops from ~12-15 minutes serial to ~2:49 with
concurrency=4. The slowest single test (05-agent-run, 49s) is now the
floor; CI should land near 4-5 minutes.
* fix(cli-tests): deflake 30-chat under load and shard CI across 3 runners
`chat wait` reads the latest message id and then subscribes for newer
messages. Under CI load the subprocess takes >1s to bootstrap, so the
old test's 250ms head start before posting "second message" raced
against that read. When the post landed first, the subprocess saw the
second message as latest and timed out waiting for a newer one. Replace
the brittle delay with a post-and-race loop: every iteration posts a
fresh "second message" and races a 250ms tick against the wait promise,
so whichever message lands after the snapshot wakes wait deterministically.
CI was also slower than local (10.3min vs 2.8min). On 4-vCPU GHA
runners, 35 sequential-CPU-time of ~1850s caps wall clock around 8 min
even at concurrency=4. Shard the suite across 3 GHA runners via matrix
strategy. The runner now reads PASEO_CLI_TEST_SHARD/SHARD_TOTAL and
distributes files into buckets — known long-pole tests
(05/06/11/13/14-...) round-robin first so they spread across shards
instead of clustering by their numeric prefixes, then the remainder
fills in the reverse direction to balance light load.
Local run is unchanged (single shard by default). Expected per-shard
wall on CI: ~2:30 + setup ≈ ~4:30 total.
* refactor(agent-providers): add type guards and fix type-aware lint errors in diagnostic-utils, generic-acp-agent, and tool-call-detail-primitives
* fix(server): defer PTY onExit to allow pending data events to fire
On Linux, node-pty's onExit callback can arrive before the last buffered
PTY data chunk is delivered via onData. Wrapping finish() in setImmediate
gives libuv's I/O poll phase a chance to flush remaining PTY reads before
the promise resolves, preventing tail bytes from being silently dropped.
Fixes a flaky worktree-bootstrap test that reliably reproduced on Linux CI.
* unslop: remove dead guard, fix import order, trim comment
- generic-acp-agent: remove isNonEmptyStringArray and its guard; the
constructor parameter is already typed [string, ...string[]] so the
check can never fire
- provider-registry: move isNonEmptyStringArray below the import block
instead of between two import groups
- worktree: condense 3-line comment to one line per project convention
Clear all type-aware lint errors in four app state/runtime files:
- voice-context.tsx: bind runtime methods before passing to useSyncExternalStore
and object spreads to satisfy the unbound-method rule
- workspace-tabs-store.ts: replace unsafe `as` casts with isPlainRecord type
predicate + toObjectRecord; add coerceWorkspaceTabTarget to bridge unknown
storage data to WorkspaceTabTarget without assertions
- host-connection.ts: same isPlainRecord/toObjectRecord pattern; replace
String(record.x ?? "") calls with typeof guards to fix no-base-to-string
- use-audio-recorder.native.ts: construct real Blob instead of casting an
object literal; restructure useEffect for consistent-return; drop await on
void recorder.record(); use instanceof Error for message extraction; remove
redundant Boolean() wrapping
Adds isObjectRecord, isUnknownArray, isChildProcessWithStreams, and
isImageMimeType type predicates to eliminate all no-unsafe-type-assertion
violations surfaced by typeAware lint mode. Fixes floating promise, adds
throw after exhaustive switch, and widens readCompactionMetadata to accept
unknown so callers need no casts.
* fix(app): make e2e setup an auto fixture so it runs for the first test of every spec
The fixtures.ts beforeEach was declared at the top level of a non-test
fixture file. Playwright sometimes skipped it for the first test of a
subsequent spec when multiple specs ran in the same worker — that test
hit page.evaluate without the seed nonce or daemon registry in
localStorage and failed (then passed on retry, masking the bug behind
retries: 1). Reproduced locally by running sidebar-workspace then
startup-loading: the first startup-loading test got no fixture setup
and threw "Expected e2e seed nonce".
Move the setup into an `auto: true` fixture, the canonical Playwright
pattern for running setup for every test in a workspace. The afterEach
console-attachment is folded into the same fixture's teardown.
* refactor(server): simplify nullish handling in workspace-git-metadata
Drop the explicit `null` arg in `deriveProjectSlug(input.cwd, null)` —
the second parameter already defaults to `null`. Inline the
`repoName && repoName.length > 0` check in `parseGitHubRepoNameFromRemote`
since `pop()` on a non-empty array returns a non-empty string here, and
`|| null` covers the empty-string case directly.
* schedule: add `paseo schedule update` to edit schedules in place
Editing a schedule today requires delete+recreate, losing run history
and the schedule id. This adds an additive RPC and CLI command that
patches name, prompt, cadence, new-agent target fields, max-runs, and
expires-in without touching runs or in-flight executions. nextRunAt is
recomputed only when the cadence actually changes.
* schedule(cli): share cadence flag parser between create and update
Both paths were turning --every/--cron into a ScheduleCadence with
near-identical code. Extract `parseCadenceFromFlags` so the literals
and the exclusivity check live in one place; create wraps it to
require a value, update lets it stay optional.
Reconciliation overwrote correctly-set project display names with the
directory name for non-GitHub remotes (e.g. gitlab.com/acme/app), because
buildWorkspaceGitMetadataFromSnapshot only handled GitHub URLs while the
registry-model layer used the more general deriveProjectGroupingKey path.
Reuse that path here so both layers agree on the owner/repo display name.
Replace unsafe 'JSON.parse(content) as Type' patterns with proper Zod schema
validation. This fixes type-aware lint errors and provides runtime safety.
- pid-lock.ts: Add pidLockInfoSchema and parsePidLockInfo helper
- package-version.ts: Add packageJsonSchema and parsePackageJson helper
- relay-transport.ts: Add isRecord type guard to avoid unsafe type assertion
* feat(server): derive owner/repo display name for any remote host
Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.
Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.
* feat(app): show projects from any git remote in Projects settings
Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.
Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.
* chore(app): simplify Projects empty state to "No projects yet"
Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.
* test(app): cover non-GitHub remote in projects-settings e2e
Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.
Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.
---------
Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
Interval schedules used to wait the full interval before their first run, which
made `--every 1d` feel broken for fresh schedules. Now `--every` fires
immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now`
and `--run-now` override the defaults, and the parser rejects redundant combos.
Adds `paseo schedule run-once <id>` to manually trigger a single run without
advancing cadence or recomputing completion. The new `runOnCreate` field and
`schedule/run-once` RPC are additive on the WebSocket schema.
The MCP create_agent tool inherited the parent agent's mode regardless of
provider, so a Claude parent in bypassPermissions spawning an opencode
child would feed an invalid mode to opencode and the new agent would die
in error state. The fix:
- Add an optional mode field to the agent-to-agent input schema.
- Validate any explicit mode against the target provider's available
modes; throw with the list when invalid.
- Inherit the caller's mode only when both sides use the same provider;
otherwise refuse with a helpful error listing the target's modes.
Mirrors the MCP create_worktree tool's three modes (branch-off,
checkout-branch, checkout-pr) against the daemon's existing
createPaseoWorktree RPC, so CLI users (especially over --host) can
provision worktrees in the paseo-managed location.
The error message gained a `--mode` mention when the schedule mode flag
landed; the existing CLI integration test still asserted on the old
exact-prefix substring and broke main CI for all PR builds.
- Add scripts/lower-thinking-tone.mjs to scale the thinking-tone PCM
amplitude in-place; apply it at 0.1 so the cue is a soft indicator
instead of triggering the VAD via mic feedback.
- Drop the "Received first voice_audio_chunk" log that re-fires every
summary window because the chunk counter is reset.
- Match Spoke message icon and label sizing to the standard tool-call
badge.
- Append a spoken-input instruction so voice replies route through the
speak tool.
OpenCode subproviders (e.g. opencode/kimi-k2.6 on OpenCode Zen) emit
session.status:retry events with messages like "Internal server error"
when the upstream provider returns 5xx. opencode itself retries
indefinitely with backoff and never emits a terminal event for these.
Previously the adapter only surfaced retry messages on a small allowlist
of "fatal" tokens (insufficient balance, invalid api key, etc.) and
silently dropped everything else. The agent appeared hung from the
user's perspective — no message, no spinner update, nothing — until the
upstream eventually recovered or the user manually interrupted.
Forward every session.status:retry as a non-terminal timeline error
item so the user can see what opencode is doing, mirroring opencode's
own TUI. Drop the fatal-token allowlist: classifying which retries are
"really" terminal is opencode's job, and synthesizing turn_failed for
ones we guess at is misleading anyway because opencode keeps spending
upstream while we tell the user the agent is done.
Also a small design pass on the timeline error rendering:
- drop the redundant "Agent error" prefix (the message is descriptive)
- drop the colored box background (visual weight too high for a retry)
- align the icon vertically to the first text line (height+center)
- make the message text selectable so users can copy errors
Plumb --mode/--verify-mode through paseo schedule create and paseo loop run.
Mark each provider's unattended mode (claude bypassPermissions, codex/opencode
full-access, copilot autopilot) so loop and schedule services pick it by
default when no explicit modeId is given.
The CommandOptions interface in packages/cli/src/output/with-output.ts had
an open `[key: string]: unknown` indexer, so every consumer of the global
--host and --json options had to cast `options.host as string | undefined`
at the call site. Add `host?: string` and `json?: boolean` to the interface
and remove 29 redundant casts across 12 command files.
Add oxlint-tsgolint and configure typescript/no-unnecessary-type-assertion
to flag redundant `!` and `as Foo` casts. Type-aware mode is left off by
default to keep `npm run lint` fast; the rule sits configured for when we
turn type-aware on intentionally. Auto-fix removed ~283 redundant casts;
two manual touch-ups: a real tsgolint false positive in split-container.tsx
and a stale ChildProcess import after a double-cast collapsed.
Subagent task_notification system messages arrive without
parent_tool_use_id but with tool_use_id pointing at the parent's Task
call, so they slip past the sidechain router and render as top-level
"Task Notification" rows in the parent's timeline. Skip them when the
referenced tool_use is a Task call; parent-level background bash
notifications still flow through.
The Fastfile called wait_for_build_processing_to_be_complete, which
isn't a built-in fastlane action and isn't published as a plugin
anywhere on rubygems. The submit_review lane has therefore failed
on every release that reached it (v0.1.65-beta.1, v0.1.66, v0.1.67).
Replace it with a direct Spaceship::ConnectAPI poll: fetch the build
matching the latest TestFlight build number, wait until its
processing_state is VALID, then hand off to deliver. 30-minute cap
with a clear failure message on INVALID/FAILED or timeout. Spaceship
ships with fastlane so no plugin or extra gem is required.
Lands in the next release; v0.1.67 needs a manual review submission
from App Store Connect.
Translate Paseo's header-keyed AskUserQuestion answers into the full question-text keys Claude expects before resolving question permissions.
Also preserve the original question payload when UI callbacks return an answers-only updatedInput, and lock the behavior down with provider-flow regression coverage in claude-agent tests.
The async settings gate added in f0d96f8e called
shouldStartDaemon() inside Promise.resolve(...).then(...) with no
catch. If loadDesktopSettings() rejects (corrupted JSON, FS error),
the daemon never starts, no error renders on the splash, and the
retry button repeats the same silent path.
Add a recordError method to DaemonStartService and an onGateError
callback to startDaemonIfGateAllows / startHostRuntimeBootstrap.
_layout.tsx wires it through so a gate rejection populates
lastError, the splash shows the failure, and retry has signal.
Test #1 was asserting agent-reconnecting-toast on a workspace with no
agents — but commit 1daa1314 moved the reconnect indicator from the
workspace banner into the per-agent panel toast. Create an idle agent
and navigate to it so the agent panel mounts before dropping the
daemon gate.
Test #2 regex still pinned the old "Connecting to localhost" copy;
commit c534c857 tightened it to just "Connecting" with the hostname in
the description. Match the new title plus the existing offline copies.
f0d96f8e added packages/desktop/src/daemon/daemon-manager.test.ts,
which calls vi.mock("@getpaseo/server", ...). Vite's module
resolution still needs to find the package's main entry, so
desktop CI must build the server (and its dependency chain) before
running the tests. Mirrors the prebuild steps used by typecheck,
server-tests, and playwright.
7c85341a moved first-agent branch auto-naming out of the worktree
service (sync slugify) into the workflow layer (async LLM call).
The MCP test stubs createPaseoWorktree, bypassing the workflow,
so the placeholder mnemonic branch is never renamed at this layer.
Replace the obsolete sync-slugify outcome assertion with a
placeholder-presence check; the auto-name logic itself is covered
in worktree-branch-name-generator.test.ts.
Plumbs persistSession through the structured response pipeline so
metadata, branch name, commit message, and PR text generators no
longer leave provider-side artifacts behind:
- Codex: passes ephemeral: true on thread/start (no rollout file)
- OpenCode: deletes the session via session.delete on close
- Claude: forwards persistSession: false to the SDK query
Adds a real-credentials e2e test per provider that asserts the
generated title is applied AND no persistence artifact is produced
(rollout for Codex, project JSONL for Claude, leftover session for
OpenCode).
Server: drop the synchronous branch-name generator dep, use the
worktree slug as the initial branch, and move first-agent branch
auto-naming to an async post-creation step backed by a structured
LLM call. Guards against renaming when the placeholder branch has
already been changed.
App: make workspace and worktree archive optimistic, rolling back
the local hide if the daemon call fails, and suppress incoming
workspace upserts while an archive is pending.
Linux EAS runners don't ship Ruby/Bundler, so `bundle install`
fails with "bundle: command not found". macOS workflow runners
have Ruby 3.2 + Fastlane preinstalled, so switching this job to
runs_on: macos-medium gets us there without a manual install step.
The eas/checkout step already cd's into packages/app (where eas.json
lives), so the explicit `working_directory: ./packages/app` on the
Fastlane steps resolved to /home/expo/workingdir/build/packages/app/packages/app
and failed every release with ENOENT.
The workspace gate (loading/unreachable/missing) used surfaceWorkspace,
which clashed with the panels' surface0. Switch the gate shell to
surface0 so it matches the agent panel.
Also remove three hot-path useUnistyles() calls from _layout.tsx that
were re-rendering the whole app on every theme/breakpoint/insets change:
RootLayout and AppContainer move to StyleSheet.create, and the desktop
window-controls effect lifts into a small leaf component.
The workspace reconnect banner ignored safe-area insets on Android,
overlapping the system status bar. The agent panel already shows a
"Reconnecting..." toast on disconnect, so consolidate on that and drop
the banner entirely. Add persistent toast support (durationMs: null)
and dismiss the toast when the connection returns to online instead of
auto-dismissing after 2.2s.
The 'Connecting…' placeholder was removed from welcome-screen.tsx in
02adadbb (Always show welcome copy on welcome screen) but the e2e
helper still asserted it visible, breaking startup-loading.spec.ts.
f58cfe9 (Open script service URLs in-app or external) added
serviceUrlBehavior to AppSettings and a new useDropdownMenuClose
export from dropdown-menu. The unit tests were not updated:
- use-settings.test.ts: expected settings objects missed the new
serviceUrlBehavior field.
- workspace-scripts-button.test.tsx: dropdown-menu mock missed
useDropdownMenuClose, breaking HostLinkRow render.
fa20f313 wired AsyncStorage persistence into
activateNavigationWorkspaceSelection. The mock here returned undefined
from setItem, so the .catch() in persistLastWorkspaceRouteSelection
threw. Make get/set/removeItem return resolved promises like real
AsyncStorage.
Adds a Service URLs setting (ask / in-Paseo / external browser) that
controls where URLs from running scripts open on desktop. First click
prompts with a "don't ask again" checkbox; in-Paseo opens a workspace
browser tab. Closing a browser tab now clears its session partition.
Drop the "Connecting…" placeholder shown while reconnecting to saved
hosts. The screen now keeps the welcome copy and add-host actions
visible until the auto-redirect to an online host fires.
Spawn the shared OpenCode server in its own POSIX process group and terminate provider child process trees with graceful timeout escalation before forcing SIGKILL. Reuse the same helper for Codex app-server cleanup so process-tree behavior is covered consistently across providers.
Add cross-platform unit coverage for macOS/Linux process groups, Windows taskkill cleanup, and timeout escalation. Add the process-tree test to the Windows-critical CI allowlist; Linux full server tests already include it.
Refs #227
Update CHANGELOG.md in place per beta policy and add a compat note on the
`sub_agent.actions` field flagging it as drop-on-cleanup once clients
<= 0.1.65-beta.3 are out of the field.
With capability negotiation in place, the consumer-side tolerance pattern is unnecessary. Drop the dead readDeclaredClientCapabilities helper, inline the parsing in ClientCapabilities.fromHello, delete a tautological assertion in the relay reconnect test, and document the compatibility rules in architecture.md.
- Refresh icon: use single-arrow RotateCw instead of two-arrow RefreshCw
- URL bar selects all text on focus
- Cmd+R now reloads the active browser pane (tracked via IPC) when
the URL bar has focus, instead of reloading the Electron window
- Forward Paseo shortcut keys (b, e, w, t, k, /, \, comma, digits,
enter, arrows) from the embedded webview to the main window so
sidebar/tab/etc. shortcuts still fire when focus is inside the
loaded page
Previously, dictation was cancelled the moment the user switched away
from the active agent tab, losing any in-progress recording. Drop the
auto-stop-on-blur behavior so users can start dictating, switch tabs,
and come back to finalize.
@fireblue diagnosed the production failure mode: long canonical timeline catch-up could exceed the relay WebSocket frame cap, close with 1009, and send clients into a reconnect loop. Their seatbelt fix also removed the `limit: 0` footgun that let app callers request an unbounded frame.
This maintainer pass keeps that diagnosis and redirects the UX around bounded projected pages:
- initial agent load fetches the latest canonical tail
- app resume/reconnect re-fetches the latest tail instead of chaining after-cursor pages
- in-stream gaps still use bounded after-cursor catch-up
- older history loads explicitly when the user scrolls to the oldest edge
- shared page size is TIMELINE_FETCH_PAGE_SIZE = 100 projected timeline items, matching the daemon's canonical projection semantics rather than raw delta chunks
Original diagnosis and fix by @fireblue.
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
* feat: add browser pane element picker
Port the browser pane from PR #198 and route picked elements through workspace attachments so the context follows every agent composer in a workspace.
Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
* docs: document electron platform overlays
* fix: harden browser pane navigation
* fix: polish browser tab interactions
* fix: route browser pane focus shortcuts
---------
Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
Claude, Codex, OpenCode, and ACP each reimplemented the same `run()`
orchestration: pre-start event buffering, turn-id filtering, terminal
event settling, timeline collection, and final-text reduction. Hoist
the loop into `provider-runner.ts` with a small adapter (startTurn,
subscribe, getSessionId) and pluggable final-text reducer.
Net -252 lines across the four providers. Also fixes a latent bug
where a `turn_failed` emitted before startTurn returned was buffered
and silently swallowed instead of rejecting the run.
Centralize pending-run, waiter, and finalized-turn bookkeeping in a
new ForegroundRunState module so streamAgent, replaceAgentRun,
cancelAgentRun, reload/close, and stream dispatch share one
coordinator instead of hand-rolling the lifecycle in five places.
Move workspace descriptor map building, fetch_workspaces filtering /
sorting / cursor paging, and archiving overlay state out of the Session
god class into a dedicated WorkspaceDirectory module.
Session keeps thin delegation methods so existing tests and callers see
the same surface; cursor errors are still mapped to SessionRequestError
at the session boundary.
The MCP get_agent_activity tool sliced the raw timeline before passing
to the curator, so `limit: 5` could land mid-message and return a
trailing fragment of streamed assistant text. Now goes through the
existing projection pipeline so the limit counts whole projected
entries — assistant deltas merged, tool calls collapsed by callId,
reasoning chunks merged.
Same bug fixed in the wait_for_agent timeout's "Recent activity"
preview, which used the same raw slice.
Consolidates the merge logic: activity-curator no longer ships its own
collapseTimeline, it delegates to projectTimelineRows. One source of
truth for "what is a complete message".
Drops the meaningless `provider` parameter that was threaded through
the projection module — merge logic never branched on it. The wire
schema keeps `provider` per entry; session stamps it once at the emit
boundary. Adds `reasoning_merge` to the projection kind enum (additive
wire change).
Three sibling modules (session-stream-lifecycle, session-timeline-seq-gate,
and the bootstrap-tail policy from session-timeline-bootstrap-policy) only
served the stream reducer. Inline them as private helpers in
session-stream-reducers.ts; keep deriveInitialTimelineRequest exported for
the hook caller. Removes shallow seams without changing behavior.
Move slot map, listener fanout, binary input encoding, and stream-frame
decoding out of daemon-client.ts into TerminalStreamRouter so the
4.5k-line client no longer owns that bookkeeping. Public DaemonClient
API and on-the-wire behavior unchanged.
Two breakages surfaced when CI started running desktop tests:
- Root vitest.config.ts replaced default exclude with `["**/.claude/**"]`,
losing `**/node_modules/**`. Desktop has no local vitest config, so
vitest scanned into `node_modules/zod/src/v4/classic/tests/*.test.ts`
and failed on missing peers (`@web-std/file`, `recheck`). Spread
`configDefaults.exclude` so node_modules stays excluded.
- window-manager dark titlebar color changed from #18181c to #181B1A in
963c7926 (white-flash fix) but the test was not updated. Update the
expected color to match the new intended behavior.
Skills installed via Paseo desktop never updated after first run, so
users were stuck on already-fixed skill content. On every launch, if
the paseo base skill is present in `~/.agents/skills/`, sync each
bundled skill (incl. `references/`) into agents, claude (via symlink/
junction), and codex — overwriting only files whose content differs.
Files on disk that are not in the bundle are left alone; deprecation
goes through tombstones (e.g. `paseo-orchestrate` redirects to
`paseo-epic`).
Also refreshes the bundled skill set: drops `paseo-chat`, adds
`paseo-advisor` and `paseo-epic`, and turns `paseo-orchestrate` into
a tombstone redirecting to `paseo-epic`.
Adds a desktop test job to CI on Ubuntu and Windows so the
junction/symlink path is exercised on both platforms.
Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.
- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
`readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
emit binary frames when set; legacy JSON path remains for old clients.
Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
`expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
removed.
Map OpenCode `task` tool events into the existing sub_agent timeline shape,
enabling live activity tracking for OpenCode subagent sessions.
- Add `childSessionId` (optional) to sub_agent schema
- Make `actions` optional with default [] for backward compatibility
- Parse task tool input/output into sub_agent detail (tool-call-detail-parser)
- Track child session linkage and buffer/flush child tool parts
- Render sub_agent actions and session IDs in the app component
- Add glob tool detail mapping
- Include backward-compat schema test
Co-authored-by: Ryan Swift <ryan@mlh.io>
Squash commits keep the original commit author, but `gh pr view N --json author` returns the PR opener — using that field silently mis-credits work to the maintainer who landed the PR (and the skip-@boudra rule then drops the credit). Document the `--json commits | unique logins` query as the source of truth for attribution.
Replace top-level `attachments` and `nameContext` on `create_paseo_worktree_request` with `firstAgentContext: { prompt?, attachments? }`, the single signal driving auto-rename. Drop the `worktreeSlug || …` short-circuits so a random placeholder slug no longer blocks renaming, drop the attachment-as-PR fallback in favor of explicit `githubPrNumber`, and reshape the MCP `create_worktree` tool around a `target` discriminated union (`branch-off` / `checkout-branch` / `checkout-pr`) with concise field descriptions so callers stop misreading `prompt` as a name field.
The gutter was showing the message-circle icon on every reviewable line on
native and on compact form factors, displacing the line number whenever
review draft mode was active. Restore the default by computing showAction
purely from interaction state (hover/press), comment presence, and editor
visibility — drop the showPersistentAction flag and the isNative bias.
Tap on the gutter directly opens the inline review editor on every
platform. On native, long-pressing a line of code (350ms) also opens the
editor via a new LongPressableLine wrapper, preserving web text selection.
Moves terminal RPC, binary streaming, slot allocation, and exit-subscription
bookkeeping out of the 9.7K-line Session class and into a dedicated controller
with a small facade (dispatch, handleBinaryFrame, killTerminalForClose,
killTerminalsUnderPath, dispose, getMetrics). Session loses 7 fields and 22
methods (-651 LOC) and the cluster's invariants now live next to their state.
Replace the duplicated 3-stage Zod pipeline (pass1 -> pass2 envelope
union -> pass2 discriminated union) in each provider mapper with a plain
resolveToolKind(name) function over readonly name sets. Opencode's
toolKind was unused; Claude/Codex used it only as a switch discriminator.
Five set_agent_*_response and update_agent_response payloads were
pixel-identical. Share one named schema so the concept is explicit and
new agent-action responses don't copy-paste the same four fields.
Wire format and inferred types are unchanged.
Only /api/health remains public for liveness probes. /api/status
exposes hostname, version, and server ID which shouldn't be
accessible without authentication.
* feat: direct TCP URI with SSL toggle and optional password auth
Replaces the heuristic-driven direct-connection model with a user-controlled
one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and
masked Password fields that compose the canonical
`tcp://host:port?ssl=true&password=xxx` URI used as the storage form.
The daemon gains optional shared-secret auth: `Authorization: Bearer <pw>` on
HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` on the WS upgrade
(browser WebSocket can't set custom headers). Configured via config.json
`auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep
working unchanged.
The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag
drives scheme selection at every call site.
* fix: stabilize direct tcp auth ci checks
* fix: restore fetch stub in bootstrap smoke test
* fix(app): collapse Advanced section in add-host modal
* feat(server): hash daemon password in config
* fix: update lockfile signatures and Nix hash
* Improve direct TCP auth failures
* Fix workspace cwd updates after rebase
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Pass `--host https://app.paseo.sh/#offer=...` (or `PASEO_HOST=...`) to
any CLI command and the connection routes through the relay with E2EE,
the same way the mobile and desktop apps connect. Same `paseo daemon
pair` link the app already accepts — no new pairing flow.
Closes#181
Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only.
* Add inline git diff review comments
Adds a review draft store keyed per diff target, inline gutter + thread
rendering in the diff pane, and a generated review attachment that flows
through the composer and agent providers.
* Strip generated review attachments before saving to draft store
Drafts persist only UserComposerAttachment (no review kind), so filter the
auto-generated review attachment out when seeding the draft or restoring
after a failed auto-submit.
* Fix inline review typecheck and test harness drift
* Unslop inline review feature
- Fix stale-closure bug in composer queueMessage: clear drafts for the
actually-queued attachments, not the latest props
- Drop dead useEffect mirroring initialBody in inline review editor
(parent already remounts via key)
- Restore typed AgentAttachment signature on renderPromptAttachmentAsText;
delete schema redispatch helper and drop null-checks across 5 providers
- Inline single-use review prompt formatters and replace marker switch
with const map
- Drop normalizeComment field-copy redundancy after type guard
- Remove __reviewDraftStoreTestUtils export; test migration via
rehydrate() public path
- Refactor findTarget to return the target line directly (no non-null
assertion at call site)
- Drop redundant isGeneratedReviewAttachment helper; inline kind check
* Update review pill to withUnistyles after rebase
Main's unistyles refactor stripped useUnistyles in favor of withUnistyles +
StyleSheet. Convert ReviewAttachmentPill to use ThemedCircleDot + ICON_SIZE so
the icon picks up the foreground-muted mapping like the other pills.
* Fix lint and typecheck after rebase onto main
Resolve drift after main's withUnistyles refactor:
- Composer complexity: dedup voice?.isVoiceSwitching to drop to 20
- Move per-comment Pressable handlers in git-diff-inline-review into
CommentRow subcomponent so callbacks are stable
- Hoist style/onPress factories to module level (iconButton,
iconButtonDestructive, ghostButton)
- Memoize style arrays in git-diff-pane review wrappers
- Replace inline {} placeholders with useMemo styles
- review-draft-store: extract applyCommentUpdates to avoid map-spread,
include input.key in useMemo deps
- Test fixtures: hoist EMPTY_COMMENTS, add buildReviewActions helper
* Drop GitDiffPane default param to keep complexity at 20
After the rebase, main's `enabled = true` default param combined with the
inline review additions pushed cyclomatic complexity to 21. Drop the
default and resolve `enabled !== false` at the call site (comparison
doesn't count toward complexity).
* Reshape inline review behind @/review module
Drop the `generated: true` attachment flag and consolidate inline-review
logic behind a deep `@/review` module so composer and git-diff-pane stop
hand-composing review primitives. Workspace-scoped review snapshots are
now derived at submit/queue time rather than persisted in the draft
store. Composer review references drop from 51 to 4; git-diff-pane drops
~108 lines and now consumes a controller hook + slot components.
* Fix optimistic review attachments
Surfaces the daemon's existing resume-from-persistence capability through a
new top-level CLI command so users can pull existing Claude Code, Codex, or
OpenCode sessions into Paseo without losing history. Adds a new additive
`import_agent_request` WebSocket message; no existing schemas change.
Refs #611, #237, #492; partially addresses #268.
The app service was passing the proxy URL (daemon.<branch>.<slug>.localhost:6767)
which routes through the main daemon. Switch to the worktree daemon's own
ephemeral port so the worktree app talks to the worktree daemon without
a main-daemon hop.
Refs #637.
EXPO_PUBLIC_LOCAL_DAEMON is a developer directive that the app must show
the configured host even when disconnected, so the user can see and
diagnose connection failures. The previous bootstrap conflated this with
the default "auto-add localhost:6767 if a daemon answers" behavior, and
the silent inner catch hid every failure mode.
- Split bootstrapLocalhost into bootstrapDefaultLocalhost (probe-then-
upsert, unchanged) and bootstrapConfiguredOverride (unconditional
upsert with placeholder serverId local:<endpoint>).
- Reconcile the placeholder to the daemon's real serverId in place when
the probe receives server-info; rekey controllers and listeners.
- Replace the silent catch with console.warn carrying endpoint and error.
- On every override boot, purge any host owning the override connection
under a non-matching serverId so a fresh dev daemon (rotated keypair)
no longer leaves the registry pointing at a defunct host.
- Prune cross-endpoint placeholders when EXPO_PUBLIC_LOCAL_DAEMON changes.
Refs #637.
* refactor(server): validate ACP mode/model selection and subscribe to live state
Match Zed's selection validity model: resolve the requested mode/model
against availableModes/configOptions before issuing the ACP RPC, log a
warning instead of throwing on unknown values, and adopt the
currentValue echoed back in setSessionConfigOption responses. Subscribe
to current_mode_update / config_option_update so AgentManager re-emits
state when the agent changes selection unprompted.
Also drops the Copilot Autopilot auto-approve shim to mirror Zed's pure
pass-through requestPermission path.
* refactor(server): emit ACP session config drift via namespaced events
Replace the parallel subscribeToSessionState callback channel with three
payload-bearing variants on the existing AgentStreamEvent union
(mode_changed, model_changed, thinking_option_changed). Manager updates
its record from the event payload and emits state — no callbacks back
into the session. Variants suppressed before websocket dispatch so the
wire schema is unchanged.
The OpenCode adapter sent the prompt before its SSE event subscription
was active. Since /event does not replay, terminal session.error/idle
events for invalid mode or unsupported model could land before the
subscription, leaving the agent stuck in running forever.
Await subscription readiness before promptAsync, and surface synchronous
or rejected prompt errors as turn_failed. Also force-drop sockets in
daemon shutdown so test harnesses don't hang on lingering WS upgrades.
Adds a real-opencode e2e regression test for the invalid-mode case and a
unit test for the synchronous-throw path.
* docs: rename to lowercase + drop leftover plans
Rename docs in docs/ to lowercase kebab-case for consistency and update
all references in CLAUDE.md, CONTRIBUTING.md, CHANGELOG.md,
packages/server/CLAUDE.md, and inter-doc links.
Drop two leftover design plan docs:
- docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md
- docs/plan-approval-normalization.md
* docs: drop stale uppercase entries from case-insensitive rename
* feat(website): power /docs from public-docs/ markdown tree
Move website docs out of TSX route components and into a root-level
public-docs/ directory of plain markdown files with frontmatter
(title, description, nav, order).
- Add packages/website/src/docs.ts loader using import.meta.glob with
?raw to compile the markdown into the bundle at build time.
- Replace the 9 hand-written docs/*.tsx routes with a single $.tsx
catch-all that renders any slug via react-markdown.
- Drive the docs sidebar nav from frontmatter order/nav.
- Auto-discover docs routes in vite.config.ts so the sitemap stays in
sync without manual edits.
* fix(website): bind dev server to 0.0.0.0 so port collisions trigger fallback
`host: "127.0.0.1"` (or unset) lets macOS coexist with another process
holding an IPv6 dual-stack `*:8082` socket, so Vite never sees
EADDRINUSE and silently binds alongside it. Forcing IPv4 wildcard
makes the conflict real, and Vite's default `strictPort: false`
falls through to the next free port.
* fix(website): restore docs page styling after markdown migration
Add a .docs-prose class that mirrors the styling the original
docs/*.tsx components hand-rolled (h1/h2/h3 sizes, paragraph/list
spacing, link colors, code blocks, callout-style blockquotes).
ReactMarkdown was emitting unstyled HTML because the previous
wrapper class only had inline-code rules — headings and code
blocks fell back to user-agent defaults.
Fixes the Claude query restart path that could drop conversation context after thinking effort changes or rewind-driven restarts.
Addresses #439.
Related to #156 and #200.
* test(server): add real-spawn round-trip for % in argv
RED test: pass --format=%(refname)... through spawnProcess to a child
node script that echoes argv[2]. Asserts the child receives the original
string verbatim. Wired into the Windows CI matrix to expose the
escapeWindowsCmdValue %-doubling bug under cmd.exe.
* test(server): use bare 'node' command so cmd.exe path is exercised
process.execPath has both an extension and a path separator on Windows,
so shouldUseWindowsShell returns false and the broken %-escape pipeline
is never invoked. Use the bare command name 'node' instead.
* fix(server): stop doubling % in cmd.exe arg escaping on Windows
cmd.exe only collapses `%%` → `%` inside batch files; on the command
line / via `cmd /c "..."` `%%` stays literal. Doubling `%` in
escapeWindowsCmdValue meant args like `--format=%(refname)` reached git
as `--format=%%(refname)`, which git interprets as the escape sequence
for a literal `%`, so format atoms appeared verbatim and the branch
picker showed `%(refname)%09%(committerdate:unix)` instead of branch
names.
Update unit tests that pinned the broken doubling behavior.
Cross-arch packaging (e.g. arm64 build on an x64 Windows runner)
can't smoke the unpacked binary because the host can't spawn it.
Skip the smoke instead of crashing with spawn UNKNOWN.
Combines `git pull` followed by `git push` behind a single action in the
git actions split-button. Push is skipped if pull fails, and errors are
surfaced through the same toast pathway as the existing pull and push
actions.
Multi-arch Windows build in one electron-builder pass, with the website
self-healing across the rename. Asset names go from
`Paseo-Setup-${version}.exe` to `Paseo-Setup-${version}-{x64,arm64}.exe`.
The website now reads the actual installer filename off the latest
GitHub release, so legacy single-arch releases keep rendering a single
"Download" pill while dual-arch releases promote to "Intel / x64" +
"ARM64" automatically. No homepage code change needed when ARM ships.
The daemon was dying silently with no log trail. Two compounding causes:
1. uncaughtException/unhandledRejection handlers in index.ts called
process.exit(1) immediately after logger.fatal, but pino is configured
with async streams (sync: false console + rotating-file-stream), so the
fatal entry never flushed. main().catch already worked around this with
a 200ms setTimeout — extract exitAfterPinoFlush() and apply it to the
process-level handlers too.
2. socket.send() in the relay transport adapter and ws.send() in
sendToClient/sendBinaryToClient were unprotected. The ws library throws
synchronously on send-after-close, and the readyState === 1 check has a
race window. With an active foreground turn streaming agent_stream
output, a peer-side disconnect could turn into an uncaughtException —
which (per #1) then exited without logging.
Wrap the three send sites in try/catch and warn-log on failure; let
onclose/onerror drive cleanup. The readyState fast-path stays as an
optimization.
Refs getpaseo/paseo#612
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`mapfile` is bash 4+, but macOS GitHub runners ship bash 3.2, so the
upload step exited 127 on both arm64 and x64 jobs. Replaced with a
portable while-read loop.
Add release notes for the 0.1.65-beta.1 beta covering the atomic
manifest fix for the Apple Silicon Rosetta race (#555), the Linux
process-name fix (#602), assistant image loading states, and the
diff gutter alignment fix.
The macOS publish matrix had each arch run electron-builder with
--publish always, so each runner uploaded its own latest-mac.yml to the
GitHub release the moment it finished. The slower arch clobbered the
faster one, leaving a window (typically 1-3 minutes) where the live
manifest contained only one arch's files[]. Apple Silicon clients
polling during that window could install the x64 build, ending up
under Rosetta.
Also closed the related window where finalize-rollout stamped
releaseDate and rolloutHours after the merged manifest was already
public — auto-updater treats missing rollout fields as "admit", so 100%
of stable users could grab a release before staged rollout kicked in.
Now every build runs --publish never. Each platform job uploads its
non-yml artifacts directly via gh release upload --clobber, and stages
its manifest as an Actions artifact. The renamed finalize-rollout job
downloads all manifest artifacts, merges mac arm64+x64, stamps rollout
metadata, validates, and uploads every manifest in one pass. Public
manifests are never visible in an unmerged or unstamped state.
Refs #555.
Render an activity indicator while image dimensions resolve and an
"Image unavailable" fallback if loading fails, replacing the previous
behavior where the surface stayed blank. Reserves a fixed minimum
height in both states so message layout doesn't shift.
The recently added numberOfLines={1} on the gutter <Text> wraps the
content in display:-webkit-box; overflow:hidden on web, where a plain
ASCII space collapses to zero height. That shifted every line number up
by one row, leaving "1" next to the hunk marker. Use a non-breaking
space so the cell keeps its line height.
The Linux wrapper renamed the executable to Paseo.bin and exec'd it with
--no-sandbox, which surfaced as Paseo.bin in the dock and process list on
Ubuntu. Drop the wrapper and the chrome-sandbox deletion entirely. .deb/.rpm
now ship chrome-sandbox SUID 4755 (electron-builder 26 default postinst,
matches VS Code) and run Paseo directly with the Chromium sandbox on.
AppImage cannot carry SUID, so --no-sandbox is now injected at runtime only
when process.env.APPIMAGE is set.
Fixes#602
Re #381
The empty event-stream mock added in #597 races with the summarize-success
path: consumeEventStream sees the empty stream end immediately and emits a
"stream ended before terminal event" turn_failed which gets buffered before
session.run has assigned its local turnId. The buffered event replays after
startTurn returns, marks settled = true, and rejects completion — but
session.run skips await completion because settled is true, leaving the
rejection unhandled and tripping vitest.
Yield session.idle so the stream resolves the turn the same way real
OpenCode SSE does.
Follow-up to the projection fix. The mechanism/policy split landed projection
on a graceful-degrade path, but I also made the setup, teardown, and terminal
wrappers silently absorb parse failures — which broke the existing contract
that `runWorktreeSetupInBackground` surfaces "Failed to parse paseo.json" to
the user when they invoke setup against a workspace with a malformed config.
Restore the throw, but with the path included and the underlying parse error
attached as `cause` (Pino expands it; UI surfaces the path + detail). Extract
`paseoConfigParseError` so spawnWorkspaceScript and the three wrappers throw
the same shape.
Also update opencode-agent.full-access.test.ts mode-order assertion that the
reorder commit forgot to update.
Add 0.1.64 release notes and document the instant-admit release path
(release:patch + immediate desktop-rollout.yml dispatch) so future
fast-rollout releases follow a single canonical flow.
A malformed paseo.json (e.g. git-conflict markers) in any registered
workspace caused readPaseoConfig to throw, which propagated through
getScriptConfigs -> buildWorkspaceScriptPayloads ->
Session.describeWorkspaceRecord and failed the entire
fetch_workspaces_request with code=fetch_workspaces_failed. The client
showed no workspaces at all.
Reshape: separate loading (mechanism) from policy (per-caller).
- readPaseoConfig now returns a discriminated result
({ ok: true; config } | { ok: false; configPath; error }). Pure load,
no logging, no opinions.
- Workspace projection owns its own policy via readPaseoConfigForProjection
in script-status-projection.ts: warn via pino with configPath +
workspaceDirectory + err, treat workspace as having no scripts, keep the
list alive.
- spawnWorkspaceScript throws with cause so the existing
handleStartWorkspaceScriptRequest catch logs it once with full context.
- The setup/teardown/terminal wrappers retain their pre-existing silent
degradation on missing/broken config.
- getScriptConfigs takes a parsed PaseoConfig | null instead of repoRoot
so the projection can pass the result through without double-reading.
Closes https://github.com/getpaseo/paseo/issues/598
* fix(server): support OpenCode full-access approvals
* fix(opencode): unconditionally auto-approve tool permissions in full-access mode
OpenCode permissions that resolve to allow do not surface prompts in the first place, so checking runtime permission rules before replying made Paseo full-access misleading. Permissions that default to ask, like external_directory and doom_loop, would still surface even in full-access.
Make full-access match OpenCode's own --dangerously-skip-permissions behavior by replying "once" to every tool permission prompt, while still letting reply failures fall back to surfacing the permission to the user.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Typing @ in the chat input to reference a file froze the app on huge
workspaces. Two compounding causes:
1. The autocomplete hook fed fileFilterQuery straight into the React
Query key, so every keystroke fired a new directory_suggestions
request and a fresh BFS scan on the daemon. Added a 180 ms debounce
that mirrors the existing pattern in new-workspace-screen so rapid
typing coalesces into one request.
2. searchWorkspaceEntries only ignored node_modules, so dist, build,
target, out, coverage, vendor and __pycache__ each consumed the
5,000-entry scan budget before any real source file was reached.
Extended WORKSPACE_IGNORED_DIRECTORY_NAMES with those names and
added a regression test that proves a deep source file is still
reachable when those directories are full of bait entries.
Refs #599
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`findActiveFileMention` walked back through `@` candidates with
`lastIndexOf("@", atIndex - 1)`, but per spec a negative `position`
clamps to 0 — so once `atIndex` hit 0 with an invalid query (e.g. a
trailing space), the loop stepped from 0 back to 0 forever, freezing
the JS thread on every keystroke and forcing a quit.
Refs #596, #425
Typecheck pulls in @server/* sources via tsconfig path alias, but the
prior `npm install --workspace=@getpaseo/app` skipped server/relay deps,
leaving zod, @anthropic-ai/claude-agent-sdk, and @getpaseo/relay/e2ee
unresolvable on CI.
Bucket divisor now produces [0, 1) so the single UUID hashing to
0xFFFFFFFF is no longer permanently excluded by the strict-less-than
admit condition.
Mirrors the post-stamp manifest validation step from desktop-rollout.yml
into desktop-release.yml's finalize-rollout job so a stamp-rollout.mjs
regression can't silently ship a broken manifest.
Adds boundary tests: bucket=0 stays blocked at exact release time,
max-bucket admits at and past rollout end, unparseable releaseDate
admits, bucketFromStagingUserId is strictly < 1 at the upper bound.
Linear ramp from 0% at publish to 100% at releaseDate + rolloutHours
(default 24h, configurable per release). Beta channel and rolloutHours=0
short-circuit to admit everyone. Per-machine bucket derives from a
persistent UUID at <userData>/.updaterId and feeds electron-updater's
isUserWithinRollout hook.
Adds desktop-rollout.yml workflow for in-place rolloutHours edits on
already-published releases (hotfix to 0 or extend the ramp), serialized
against finalize-rollout in desktop-release.yml via a shared concurrency
group keyed on the tag. Replaces the hand-rolled mac manifest merge
with a js-yaml round-trip that preserves unknown fields.
Built-in provider clients bypassed mergeModels in listModels(), so
configured profileModels/additionalModels were honored by the catalog
but ignored when AgentManager picked a default for new agents.
Wrap the inner client whenever overrides exist (not only when the
provider id differs) and route listModels through mergeModels.
Fixes#579
The hardcoded OPENAI/ANTHROPIC/OPENROUTER env-var check missed
DEEPSEEK_API_KEY and ~14 other providers the pi-ai SDK supports,
hiding the Pi provider entirely when only those keys were set.
Delegate to ModelRegistry.getAvailable() so every supported provider
is recognized automatically. Diagnostic now lists configured providers
dynamically instead of three hardcoded keys.
The gutter width formula was calibrated for the diff pane's 12px font,
so 3-digit numbers wrapped to two lines in the file preview's 14px
gutter. Parameterize on font size and add numberOfLines={1} as a guard.
When a user archives a git repo and later opens a subfolder of it that is
not its own git project, the resulting workspace is a directory — not a
revived git workspace pointing at the archived parent. To get git features
back the user unarchives the parent (S6/S11).
Triple-clicking a message extended the document Range across the
sibling composer and into the textarea, copying both on Cmd+C. Mark
the messages region non-selectable on web and re-enable selection on
each bubble's outer container so triple-click stays scoped.
Provider diagnostics were stringifying Error objects through JSON.stringify,
which yields {} and hides the cause. Spawned children logged stderr to the
daemon log but never attached it to thrown errors, so the diagnostic showed
"Server: Unavailable ({})" with no clue why.
Generalize toDiagnosticErrorMessage to extract message, code, signal, stderr,
stdout, and recursive cause from any Error (including execFile-style errors),
and never return {} or empty. Buffer stderr/stdout in OpenCode startServer
and include them in startup-failure rejections. Stop swallowing errors in
resolveBinaryVersion. Drop the duplicate stringifyUnknownError helpers in
opencode-agent and pi-direct-agent and route every callsite through the
single canonical helper.
Filter archivedAt records out of the prefix-fallback resolver and stop
canonicalizing into archived parents — opening a child of an archived
git ancestor now creates a fresh workspace at the input path instead
of resurfacing the archived parent.
- session.test.ts: drop stale 4th workspaceGitService arg from
createPullRequest assertion (signature changed in ee5a23d9).
- workspace-git-service.test.ts: bump local flushPromises microtask
count to cover the extra await resolveGitHubRemoteForTarget step
added by the GitHub remote resolver.
- provider-windows-launch.test.ts: retry rmSync in afterEach to
ride out Windows file locks from the spawned claude.cmd.
* Add GitHub branch open target to workspace button (#96)
* refactor: simplify workspace open targets to flat shape
Collapse the editor/url discriminated union into a single OpenTarget
{ id, label, icon, onOpen } so the button renders targets uniformly
and the mutation is shape-agnostic. Drop the heavily-mocked button
test in favor of the URL builder tests, restore the decodeURIComponent
guard for valid-syntax-but-invalid-UTF-8 pathnames, and note the SSH
host alias limitation.
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Resolves the case where a remote like `git@github-work:owner/repo.git`
points at github.com via `~/.ssh/config` but Paseo treats it as
non-GitHub.
Adds a centralized GitHub remote resolver that parses standard remotes
directly and resolves SSH aliases via `ssh -G` (with hostname
validation to keep the alias out of ssh's option parser). The resolver
is shared by workspace GitHub feature detection, PR status, PR
creation, and workspace git metadata.
HostRuntime self-boots via boot() and emits state through existing
subscribeAll plumbing. DaemonStartService runs in parallel: on success
it upserts the connection, which HostRuntime's syncHosts already wires
into a controller; on failure it emits an error.
The bootstrap no longer awaits daemon-start before letting saved-host
controllers connect. index.tsx resolves the redirect from
useEarliestOnlineHostServerId + useDaemonStartLastError subscriptions,
not from a procedural promise. The splash error is derived state:
splashError = !anyOnlineHostServerId ? daemonStartError : null. Retry
calls only DaemonStartService.start().
Deletes initializeHostRuntime, setupDesktopManagedConnection,
addConnectionFromListenAndWaitForOnline, bootstrapDesktop on the store,
waitForAnyConnectionOnline (now unused in production), and the
phase enum. Net -623 lines.
The before-quit handler preventDefaulted on every quit and re-fired
app.quit() from .finally. That re-entered Electron through the
window-all-closed gate, which on macOS is a no-op listener that
vetoes the quit — so the window closed but the process stayed up.
Use app.exit(0) to bypass the close → window-all-closed → will-quit
chain entirely. Also drop the daemon-stop NSAlert: with no parent
window it ran app-modal and stalled the child-process pipes for
'paseo daemon stop', so daemon shutdown waited on the user dismissing
the dialog. Replace it with an in-app overlay rendered in the renderer
via a paseo:event:quitting IPC event. Stop opening DevTools on dev boot.
The shimmerText style forced flexShrink: 1 on the primary label, while
the real label has flexShrink: 0. With long secondary labels, the
shimmer truncated the primary label and shifted the secondary
truncation point, so the shimmer no longer aligned with the actual
text. Drop the override so each shimmer label inherits its base
flexShrink.
Lets first-time users edit project settings via the UI and have those
scripts apply to their first worktree without first having to commit
paseo.json. Committed configs are unaffected — git worktree add already
checks them out, so the seed is a no-op there.
Allow `OPENAI_REALTIME_URL` env var to override the hardcoded
`wss://api.openai.com/v1/realtime?intent=transcription` endpoint, with
the current value as default. Pairs with the OpenAI SDK's existing
`OPENAI_BASE_URL` support that already makes HTTP audio endpoints
reroutable. Unblocks pointing the realtime session at any OpenAI
Realtime API protocol-compatible backend (Azure OpenAI, Alibaba
Bailian / DashScope, self-hosted compat layers) without further
changes.
Closes#569
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Navigate Projects via the settings sidebar entry; the top-level
Projects button never existed and the page lives under
/settings/projects.
- Match the row's accessibility label (Edit <project>) and the
textbox label (Worktree setup commands) the screen actually uses.
- Replace the .xterm-rows DOM selector with .xterm-screen for the
vim layout assertion. With the WebGL renderer, .xterm-rows > div
is empty, so the test has been red since it was added in 2b372765.
- Add testID="startup-splash" to the simplified splash screen so e2e helpers
can detect desktop daemon bootstrap without depending on removed copy.
- Update startup-dsl helper to assert the testID instead of the deprecated
"Starting local server..." text.
- Replace the obsolete localStorage write for manageBuiltInDaemon in the
local-daemon sidebar test with a get_desktop_settings IPC mock so the
desktop bridge surfaces the seeded host without triggering bootstrap.
Commit 5b47b659 swapped the 'On'/'Off' segmented buttons for a Switch
component (accessibilityRole=switch with label 'Inject Paseo tools').
Update the assertion to match the new role.
Commit d3f9655e removed the implicit 'claude' default for --provider on
schedule create. Test 1 still relied on the default, which fails fast
with MISSING_PROVIDER. Pass --provider claude so the assertion path that
expects target=new-agent:claude continues to hold.
Daemon emits 'Provider <id> is disabled' for disabled providers (see
session.ts emitProviderDisabledResponse). The test was asserting the
older 'is not available' phrasing, which never matched the actual
output.
classifyDirectoryForProjectMembership normalizes input.cwd via
path.resolve, which on Windows turns "/tmp/repo-feature" into
"D:\\tmp\\repo-feature". Mirror existing pattern (deriveWorkspaceId
tests at lines 91/107) and wrap the expected cwd with
normalizeWorkspaceId so the test passes on both POSIX and Windows.
- Add FolderPlus to lucide-react-native mock in left-sidebar.test
(sidebar started using FolderPlus in 291a1246, mock was stale).
- Pass --provider in cli run daemon-not-running test so it reaches
the daemon-connect step instead of erroring out at MISSING_PROVIDER
validation introduced in 64b9b612.
Same root cause as 16cf9edf — checkout-git emits mainRepoRoot directly,
so the helper must initialize it to null or downstream isWorktree
derivation flips to true on undefined !== null.
de428582 made workspace-git-service propagate checkoutStatus.mainRepoRoot
directly. The test helper never set the field, so the runtime snapshot
emitted undefined where tests expected null.
The CLI module was dropped from desktop deps in ba9ed8b9 (Knip
false-positive — runtime resolves it via createRequire, which static
analysis can't see). electron-builder then shipped 0.1.63-beta.{1,2}
without app.asar/node_modules/@getpaseo/cli, breaking every daemon
status check from the desktop wrapper. Splash startup never saw the
daemon as ready and retries collided with the live pid lock.
Re-declare the dep, and switch private-workspace internal @getpaseo/*
deps to "*" so npm always resolves the workspace sibling and never
falls back to the registry. Publishable workspaces (cli, server) keep
the root-version pin so their npm tarballs reference real ranges.
Update sync-workspace-versions.mjs to preserve that shape on release
bumps, and add a packaging assertion in desktop-packaging.test.ts that
fails fast if a runtime-required workspace dep is removed again.
Replaces broad useUnistyles() subscriptions in composer, workspace, and
related components with withUnistyles wrappers and StyleSheet.create so
re-renders stay scoped to the tokens actually consumed. Lifts the design
scales (spacing, iconSize, fontSize, etc.) to top-level exports in
theme.ts so component literals share a single source of truth with the
unistyles theme.
Newer clients were rejecting checkout_status_response and
project-checkout-lite payloads from older daemons that don't yet send
mainRepoRoot. Make the field optional with a null default so the schema
stays forward-compatible.
Placement only ever needed local git data; fetching GitHub PR status
was incidental coupling through getSnapshot, blocking the response
~5s with no feedback in the desktop app. Split it: getCheckout for
placement (local only), getSnapshot for the watch path. classify is
now a pure function over a checkout. The observer emits the full
gitRuntime/githubRuntime via workspace_update when the background
snapshot lands.
Rename CalloutCard to SidebarCallout (it was always sidebar-specific) and
add a shadcn-style Alert primitive with info/success/warning/error variants
for inline use in screens. Replace the misused SidebarCallout in project
settings with Alert and surface the underlying RPC error message instead
of generic copy. Tune destructive color per theme to a calm tinted red.
Replaces the daemon version mismatch sidebar callout with a Set up
worktree scripts callout that watches the active git workspace and
points the user at the project's settings when paseo.json is missing
worktree.setup. Dismissals are sticky per project; read failures are
silent.
Browser xterm.js auto-replies to DA1/DA2/DA3 traveled back over the
websocket and arrived at the PTY after the foreground app had exited,
so the response bytes (\x1b[?62;4;9;22c) landed on the shell prompt as
visible garbage. Move all replies to the daemon's headless xterm where
they reach the PTY synchronously, and suppress every query class on
the browser side. The image addon also registers a {final:"c"} handler
inside the post-mount raf, so re-register suppression after addons
load to stay last in xterm's LIFO dispatch.
Replaces the previous printf-injection test with a real foreground
helper that queries DA1 and asserts the reply lands on stdin, plus a
browser table covering DA1/DA2/DA3 + DSR/CPR/DECRQM.
The Claude SDK delivers Grep tool_result content as a plain string, which
claude-agent.ts wraps as { output: <string> } for unrecognized tools. The
canonical ToolGrepOutputSchema requires structured fields, so parsing fell
through to an unknown detail and the UI rendered only the redundant query.
Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
Replace text-heavy splash ("Welcome to Paseo", "Starting local
server...", "Connecting...") with just the logo centered with a
shimmer sweep effect. Error state preserved as-is.
Persist the user's last active workspace and navigate directly to it
on cold start instead of always landing on the open-project screen.
- Unified bootstrap into one cross-platform wait step after
platform-specific connection setup
- Store-level wait with workspace preference: preferred host online
resolves immediately, grace period if a different host connects
first, bounded 5s timeout for unreachable hosts
- index.tsx is fully declarative — reads resolved target from context,
no useEffect/promises/refs/timers
Move the open-file button inline after the summary text so the click
target sits next to what it acts on, and replace the tool icon with the
chevron on hover instead of showing a separate chevron at the row's end.
Hovering the icon slot keeps the tool icon visible for predictability.
Swap daemon management and keep-running-after-quit buttons for Switch
toggles. Remove the restart/start daemon control. Simplify the provider
diagnostic sheet to reuse shared settings styles and SettingsSection.
Drop the unused "sm" size variant from the Switch component. Remove
trailing periods from hint text for consistency.
Show a file-symlink icon on hover that opens the file referenced by read,
edit, write, and simple shell commands (cat, head, tail, etc). Extracts
the file path from tool call details via a new utility with tests.
When a saved host reconnects before bootstrapDesktop finishes, the phase
was regressed from "online" back to "connecting". Track the race settlement
and skip the setPhase("connecting") call if anyOnline already won. Also
extract getEarliestOnlineHostServerId into HostRuntimeStore and use it to
initialize the bootstrap phase, avoiding a flash of the starting screen.
Replace the async daemon status resolution during quit with a synchronous
PID file check, avoiding races when the event loop is shutting down. Show
a blocking dialog so users know the daemon is stopping. Add --kill-timeout
to the CLI stop command for the desktop app to control SIGKILL wait time.
Lifecycle config now takes either a multiline shell script string or an
array of commands, making it friendlier to edit in a textarea. Schema
parsing moved to Zod so coercion stays in one place.
Adds a per-provider toggle in Settings → Providers, persisted via daemon
config, so users can turn off providers they don't have installed (or
don't want to use) without removing them from the registry.
Disabled providers stay visible everywhere — settings, `paseo provider
ls`, MCP `list_providers`, model/mode lists — marked as disabled rather
than silently filtered. Availability probes are skipped for them, and
attempts to launch an agent or fetch models/modes from a disabled
provider return a clear "Provider '<id>' is disabled" error.
Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider
summary) are backward-compatible: optional with a default of true so
older clients continue to parse new daemon messages.
CLI: `paseo provider ls` gains an ENABLED column; JSON output includes
the new field.
Move daemon management and release channel settings out of the renderer's
AsyncStorage and into a desktop-owned settings store persisted by the main
process. Adds a "Keep daemon running after quit" option, a daemon lifecycle
section in settings, and a one-time migration of legacy renderer-owned
desktop settings.
* chore(lint): use Set for LOG_FORMATS membership check
* chore(lint): convert type aliases to interfaces (autofix)
- Remove no-use-before-define rule (conflicts with unistyles ordering)
- Add typescript/consistent-type-definitions: interface
- Run oxlint --fix: 606 type->interface conversions across 268 files
Typecheck green. Warnings: 5432 -> 3787 (-1645).
* chore(lint): clean up relay package warnings
* chore(lint): clean up cli package warnings
* chore(lint): flatten nested ternaries in server (no-nested-ternary)
* chore(lint): escape entities and hoist inline objects in website
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* fix(types): restore Record assignability after type->interface autofix
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): clean up desktop and highlight packages
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)
* chore(lint): format keyboard-shortcuts-section
* chore(lint): rename shadowed bindings in server (no-shadow)
Rename inner bindings that shadow outer imports or function params:
- Promise executor `resolve` -> `resolvePromise` (shadowed path `resolve`)
- Method params `options` -> `input`/`target`/`update`/`runOptions`/`opts`/`killOptions`
- Misc loop/destructure renames for `workspaceId`, `scriptNames`, `path`, `query`, `taskNotificationItem`
Mechanical change only; no behavior change.
* fix(types): add index signatures for Record assignability
* chore(lint): extract nested callbacks in server (max-nested-callbacks)
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
Work in progress: 30 of 369 warnings fixed across 23 files.
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
* chore(lint): reduce cyclomatic complexity in server
* chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)
* chore(lint): parallelize safe await-in-loop in server
* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)
Finish eliminating jsx-no-new-function-as-prop warnings in
agent-status-bar.tsx and complete refactor of git-diff-pane.tsx
by extracting per-item components and using stable useCallback
handlers.
* chore(lint): parallelize more safe await-in-loop in server
* fix(server): restore microtask ordering in message dispatch and stream event handler
The complexity refactor added async dispatcher chains that inserted extra
microtasks before message handlers ran, and wrapped the stream event switch
in an await that fired between emitState and dispatchStream. Two tests
regressed on both counts. Route to the matching dispatcher synchronously
and skip the await when the stream handler has no async work.
* chore(lint): hoist/memoize inline arrays and objects in app JSX
* chore(lint): parallelize safe awaits in server (no-await-in-loop)
Converts three sequential `for ... await` loops to `Promise.all`:
- OpenCode: configure MCP servers in parallel
- Sherpa: download model files and ensure multiple models concurrently
- Speech runtime: check required model files across models in parallel
* chore(lint): clear website warnings to zero
Resolves all 61 oxlint warnings in packages/website/ by hoisting inline
callbacks/JSX, extracting sub-components to satisfy jsx-max-depth, using
stable data-derived keys, adding explicit button types, flattening nested
ternaries, and removing unused code.
* chore(lint): parallelize more safe awaits in server (no-await-in-loop)
- Workspace reconciliation: archive missing workspaces and orphaned
projects concurrently instead of sequentially.
- Directory suggestions: resolve child directory candidates in parallel
before filtering.
* chore(lint): allow css imports in import/no-unassigned-import, hoist host page styles
* chore(lint): hoist dictation-controls inline arrays
* fix(server): restore microtask ordering in session message dispatch
Converts per-group dispatchers to Promise<void> | undefined. The complexity
refactor broke two tests by inserting extra microtasks between each
dispatcher's await. The new pattern routes synchronously via a nullish-
coalescing chain and awaits only the matching dispatcher's promise.
* chore(lint): flatten nested ternaries in button/volume-meter/autocomplete
* chore(lint): narrow explicit any in misc server files
* chore(lint): flatten nested ternaries in host-runtime/host-page/providers
* chore(lint): clear desktop warnings to zero
Convert polling loops to recursive helpers to avoid no-await-in-loop,
and switch the ws import to the named export so the WebSocket type is
referenced directly.
* chore(lint): remove useless constructors in server tests
* chore(lint): hoist pair-scan inline arrays and objects
Extracts corner style arrays and barcode scanner settings to module
scope, memoizes insets-dependent body/helper text styles.
* chore(lint): hoist inline style arrays in question-form-card
* chore(lint): hoist/memoize inline styles in plan-card
Extracts markdown rule style arrays into dedicated MarkdownInlineText,
MarkdownListItemContent, and MarkdownParagraph subcomponents that
memoize their own style arrays. Memoizes PlanCard container/title/
description styles.
* chore(lint): narrow explicit any in server relay/loader/logger-likes
* chore(lint): hoist SheetBackground combined style
* chore(lint): hoist inline styles in screen-header
* chore(lint): hoist inline styles/objects in menu-header
* chore(lint): hoist top-level inline arrays in diff-viewer
* chore(lint): narrow explicit any in codex-app-server-agent thread items
* chore(lint): reduce max-depth in claude-sdk-behavior test
* chore(lint): memoize inline styles in command-center
* chore(lint): extract helper to reduce max-depth in agent-response-loop
* chore(lint): flatten nested conditions in pi-direct-agent history
* chore(lint): reduce max-depth in claude-agent query pump
* chore(lint): clear cli warnings to zero
Refactor polling loops into recursive helpers to satisfy
no-await-in-loop, fix WebSocket named import, and correct
an absolute-path import in tests/tmp.
* chore(lint): memoize inline styles in context-menu
* chore(lint): narrow explicit any in claude-agent/openai stt
* chore(lint): reduce max-depth in opencode-agent foreground loop
* chore(lint): memoize inline styles in explorer-sidebar
* chore(lint): memoize mobile sidebar styles in explorer-sidebar
* chore(lint): memoize inline styles in agent-list
* chore(lint): reduce relay test no-await-in-loop warnings
Refactor while-loops and for-retry loops in e2e.test.ts and
live-relay.e2e.test.ts into recursive poll/attempt helpers.
Remaining 8 warnings in encrypted-channel.ts concern the custom
Transport interface (on-handler slots, not DOM EventTarget) and a
sequential send loop; both reflect deliberate runtime contracts.
* chore(lint): fix typecheck errors from hoisting refactors
- pair-scan: type BARCODE_SCANNER_SETTINGS as BarcodeSettings
- explorer-sidebar: add missing desktopSidebarStyle useMemo
- sidebar-workspace-list: coerce null dotColor to transparent
- menu-header, e2e.test: formatting
* chore(lint): memoize inline styles in workspace-screen
* chore(lint): memoize inline styles in workspace-desktop-tabs-row
* chore(lint): hoist constant style arrays in desktop-updates-section
* chore(lint): memoize inline styles in combobox
* chore(lint): extract SplitGroupChild to memoize inline styles
* chore(lint): memoize inline styles in dropdown-menu
* chore(lint): memoize inline styles in workspace-hover-card
* chore(lint): memoize inline styles in autocomplete, message-input, composer
* chore(lint): remove explicit any in server (129 warnings)
- session.ts: catch(error: any) -> catch(error) with Error coercion at use
- daemon-client transport: any -> unknown in listener types
- sherpa/onnx loaders: introduce structural native types
- pocket-tts-onnx: typed ONNX session inputs/outputs/tensors
- tests: any -> unknown + named stub types
* chore(lint): hoist inline arrays and objects in app (145 warnings)
- Hoist static style arrays/objects to module-level consts
- Memoize dynamic ones with useMemo and correct deps
- jsx-no-new-array-as-prop: 145 -> 43
- jsx-no-new-object-as-prop: 64 -> 21
* chore(lint): memoize inline styles in _layout
Fixes 3 react-perf/jsx-no-new-object-as-prop warnings in root layout by
memoizing the stack screen options, the agent screen override, and the
GestureHandlerRootView root style.
* chore(lint): memoize inline styles and objects in stream/status panes
Clears 10 react-perf/jsx-no-new warnings across welcome-screen,
file-explorer-pane, terminal-pane, agent-stream-view, and
agent-status-bar by extracting per-item subcomponents, hoisting constant
style tuples, and memoizing derived arrays/objects.
* chore(lint): memoize inline style objects in list/pane components
Clears 6 react-perf/jsx-no-new-object-as-prop warnings across
synced-loader, stream-strategy-web, sortable-inline-list, file-pane, and
both draggable-list platform variants by memoizing or hoisting their
inline style objects.
* chore(lint): hoist test fixture arrays/objects
Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.
* chore(lint): memoize inline styles in message components
Clears 5 react-perf/jsx-no-new-* warnings in message.tsx by memoizing
image source objects, extracting an AssistantMessageBlockContainer for
per-block spacing, and moving todo list item rendering into a
TodoListItemRow subcomponent so each row memoizes its own style arrays.
The remaining 8 warnings in react-native-markdown-display render rules
are inherent to that library's rule API.
* chore(lint): remove unused vitest imports in server tests
* chore(lint): remove unused helpers and vars in server tests
* chore(lint): add explicit returns in then() callbacks in server
* chore(lint): fix no-shadow and no-nested-ternary in server
* chore(lint): flatten nested blocks to satisfy max-depth in server
* chore(lint): extract helpers to satisfy max-depth in server
* chore(lint): parallelize Claude persisted agents lookup
Replace sequential parseClaudeSessionDescriptor loop with Promise.all
to fix no-await-in-loop warning.
* chore(lint): parallelize Linux watch directory traversal
Switch BFS to level-by-level Promise.all over readdir calls, replacing
the sequential pop/await loop that triggered no-await-in-loop.
* chore(lint): parallelize workspace registry bootstrap upserts
Collect per-workspace upsert inputs in a sync pass, then run registry
writes via Promise.all to eliminate no-await-in-loop warnings.
* chore(lint): parallelize test daemon cleanup rm calls
Run the paseoHomeRoot/staticDir removals concurrently in close() and
the startup catch block to fix no-await-in-loop.
* chore(lint): mechanical cleanup in app (unused, shadow, nested-ternary)
Recovers in-flight edits from the app mechanical agent that couldn't be
pushed due to concurrent tree contention. Removes unused helpers and
locals, flattens nested ternaries, narrows a few props where unused.
* chore(lint): parallelize pending permission approvals
approvePendingPermissions now filters and records handled IDs in a
sync pass, then awaits all allowPermission calls concurrently instead
of looping awaits.
* chore(lint): parallelize rebase head-name lookup
Try both rebase backends (rebase-merge, rebase-apply) concurrently
via Promise.all and return the first non-null result.
* chore(lint): no-unused-vars in app
* chore(lint): parallelize worktree terminal bootstrap
Start each bootstrap terminal concurrently via Promise.all. Since every
spec creates an independent terminal and returns a standalone result,
parallelization preserves order while removing two no-await-in-loop
warnings.
* chore(lint): parallelize worktree bootstrap test cleanup and script spawn
Run terminal cleanup across managers and spawnWorkspaceScript for api
and web concurrently to eliminate no-await-in-loop warnings while
preserving semantics.
* chore(lint): prefer-add-event-listener in app
* chore(lint): parallelize script-health-monitor afterEach and spawns
Close all stub TCP servers concurrently and spawn typecheck/api
scripts via Promise.all to drop two no-await-in-loop warnings.
* chore(lint): jsx-no-useless-fragment in app
* chore(lint): parallelize executable PATH probing
* chore(lint): parallelize client and relay transport test cleanup
* chore(lint): parallelize agent storage record file probing
* chore(lint): no-array-index-key in app
* chore(lint): parallelize bootstrap provider availability cleanup
* chore(lint): parallelize Codex rollout file search
* chore(lint): parallelize dictation wav fixture search
* chore(lint): parallelize Claude session id fixture test
* chore(lint): parallelize script health monitor server cleanup
* chore(lint): parallelize Codex skills directory scan
* chore(lint): promise/always-return in app
* chore(lint): no-shadow in use-dictation, hoist icons in splash screen
* chore(lint): no-shadow in composer callbacks
* chore(format): apply oxfmt to 3 server files
* chore(lint): no-explicit-any in stt-manager/chat-mentions tests
* chore(lint): no-shadow in small app files
Rename inner shadowing identifiers in:
- add-host-modal, agent-status-bar, sidebar-workspace-list, terminal-pane
- workspace-setup-dialog, session-context, use-is-local-daemon
- setup-panel, new-workspace-screen, workspace-desktop-tabs-row
* chore(lint): no-explicit-any in claude-agent.test
* chore(lint): no-shadow in small app test/e2e files
* chore(lint): no-explicit-any in workspace-git-service.test
* chore(lint): no-shadow in app test hoisted blocks
* chore(lint): no-explicit-any in websocket-server.runtime-metrics.test
* chore(lint): no-explicit-any in acp-agent.test
* chore(lint): no-shadow in app composer/message-input/e2e helpers
* chore(lint): no-explicit-any in session.workspace-git-watch.test
* chore(lint): no-explicit-any in websocket-server.notifications.test
* chore(lint): no-shadow in sidebar-workspace-list.test, use-pr-pane-data.test
* chore(lint): no-explicit-any in websocket-server.relay-reconnect.test
* chore(lint): no-explicit-any in codex-app-server-agent.test
* chore(lint): no-shadow in composer.test, host-runtime.test, new-workspace-screen.test
* chore(lint): no-explicit-any in codex-app-server-agent.features.test
* chore(lint): hoist jsx-as-prop in app components batch 1
Memoize inline JSX icons passed as props across add-host-modal,
branch-switcher, composer, pair-link-modal, pr-pane, and
sidebar-workspace-list to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): no-explicit-any in session.test
* chore(lint): hoist jsx-as-prop in app components batch 2
Memoize inline JSX passed as props across agent-list, agent-status-bar,
file-explorer-pane, git-actions-split-button, and message to satisfy
react-perf/jsx-no-jsx-as-prop.
* chore(lint): hoist jsx-as-prop in app components batch 3
Memoize inline JSX passed as props across combined-model-selector,
draggable-list.native, provider-diagnostic-sheet, and
workspace-setup-dialog to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): hoist jsx-as-prop in desktop components
Memoize inline JSX passed as props across desktop-permissions-section,
desktop-updates-section, integrations-section, and pair-device-section
to satisfy react-perf/jsx-no-jsx-as-prop.
* chore(lint): no-explicit-any in mcp-server.test
* chore(lint): hoist jsx-as-prop in workspace/new-workspace screens
* chore(lint): hoist jsx-as-prop in remaining screens
* chore(lint): no-explicit-any in session.workspaces.test
* chore(lint): no-explicit-any in snapshot-mutation-ownership.test
* chore(lint): no-explicit-any in dictation-stream-manager.test
* chore(lint): no-explicit-any in relay-transport.e2e.test
* chore(lint): no-explicit-any in worktree-session.test
* chore(lint): no-explicit-any in model-resolver.test
* chore(lint): no-explicit-any in sherpa-parakeet-stt.test
* chore(lint): no-explicit-any in speech-download.e2e.test
* chore(lint): no-explicit-any in script-health-monitor.test
* chore(lint): no-explicit-any in schedule/service.test
* chore(lint): no-explicit-any in persistence-hooks.test
* chore(lint): no-explicit-any in editor-targets.test
* chore(lint): no-explicit-any in send-while-running-stuck-test-utils.test
* chore(lint): no-explicit-any in agent-storage.test
* chore(lint): no-nested-ternary in app batch 1 (18 files)
* chore(lint): no-explicit-any in generate-sherpa-tts-matrix
* chore(lint): no-explicit-any in claude-agent
* chore(lint): no-explicit-any in websocket-server
* chore(lint): no-explicit-any in process-conversation
* chore(lint): no-explicit-any in daemon-client
* chore(lint): no-explicit-any in codex-app-server-agent
* chore(lint): no-nested-ternary in app batch 2 (26 files)
* chore(format): apply oxfmt to server test files
* chore(lint): parallelize agent archiving in test-mcp-inject
* chore(lint): no-explicit-any in small app files (batch 1)
* chore(lint): no-explicit-any in small app files (batch 2)
* chore(lint): no-explicit-any in app (batch 3)
Covers use-web-scrollbar, stream-strategy, dictation-stream-sender test,
terminal-perf helper, checkout-git-actions-store test, and
web-desktop-scrollbar.
* chore(lint): no-explicit-any in app (batch 4)
Covers composer, message-input, tooltip, workspace-desktop-tabs-row,
and the e2e helpers (app, node-ws-factory, terminal-probes).
* chore(lint): no-explicit-any in polyfills/crypto.ts
* chore(lint): no-explicit-any in components/sidebar-workspace-list.tsx
* chore(lint): prefer-array-find over filter().at/pop
Replace filter(pred).at(-1)/pop() patterns with findLast(pred) and
filter(pred)[0] with find(pred) across server and app.
* chore(lint): no-explicit-any in components/message.tsx
* chore(lint): no-explicit-any in components/plan-card.tsx
* chore(lint): no-map-spread replace with Object.assign
Replace { ...obj, override } inside map callbacks with Object.assign({}, obj, { override })
to satisfy oxc/no-map-spread. Preserves copy-on-write semantics.
* chore(lint): no-extraneous-class in test mocks
Add dispose() stubs to xterm addon mocks to satisfy typescript-eslint/no-extraneous-class, and convert static-only Notification mocks to plain objects.
* chore(lint): no-named-as-default use named imports for ws and openai
* chore(lint): jsx-no-new-array-as-prop hoist composed style in volume-display
Memoize the style array so it is not created inline on every render.
* chore(lint): prefer-add-event-listener use Object.assign for non-DOM handlers
These transports (Transport interface, StreamableHTTPServerTransport) use
plain on<event> properties rather than EventTarget, so addEventListener is
not an option. Using Object.assign to set the handlers avoids the lint
false-positive without changing behavior.
* chore(lint): no-multiple-resolved null pendingResolve after settling
Replace boolean-settled guards with a nullable captured resolve/reject.
Each promise callback captures the resolver, nulls it on first use, and
calls it. This preserves the existing single-resolution semantics while
making the no-multiple-resolved rule happy.
* chore(lint): reduce structural complexity in server
Extract nested-callback cleanup helpers in worktree-bootstrap.test. Extract Codex model definition builder to drop list-models complexity below 20.
* chore(lint): extract helpers in agent-activity and session-store-hooks.test
Split tool-call update handling in groupActivities into helpers to clear max-depth. Hoist useWorkspaceFields selector in the hooks test to drop callback nesting.
* chore(lint): rules-of-hooks and exhaustive-deps in agent-stream-view
Move useMemo calls for permission card styles above the early return
so hooks run unconditionally. Add missing dependencies to the inline
path press handler and the stream render callback.
* chore(lint): rules-of-hooks in diff-scroll via optional context hook
Replace try/catch around useExplorerSidebarAnimation with a new
useExplorerSidebarAnimationOptional hook that returns null when the
provider is absent.
* chore(lint): rules-of-hooks and exhaustive-deps in sidebar-workspace-list
Replace conditional useMemo calls with plain inline arrays, capture
creatingWorkspaceTimeoutsRef.current in the effect body before cleanup,
and depend on the full input object in armTimers.
* chore(lint): rules-of-hooks in split-container
Hoist useWorkspaceLayoutStore and useMemo calls above the pane-kind
early return so hooks always run in the same order.
* chore(lint): rules-of-hooks in context-menu and dropdown-menu
Inline the resolvedWidthStyle object inside the content useMemo and move
the early return below the hook so it is always invoked in the same order.
* chore(lint): rules-of-hooks in web-desktop-scrollbar
Move thumbRegionStyle and handleStyle useMemo calls above the visibility
early return.
* chore(lint): reduce complexity in types/stream
Split reduceStreamUpdate timeline branch into per-case helpers (tool call and compaction) to clear complexity and max-depth warnings.
* chore(lint): reduce complexity in diff-highlighter
Extract metadata detection, path extraction, hunk parsing, and content-line push into helpers to drop parseDiff complexity below 20.
* chore(lint): exhaustive-deps in composer and use-attachment-preview-url
Memoize githubSearchItems so the picker callbacks don't see a new array
every render, hoist realtimeVoiceButtonStyle above rightContent useMemo
that depends on it, and add missing style dependencies. Tighten
use-attachment-preview-url to read the attachment via a ref while keying
off stable field primitives.
* chore(lint): reduce complexity in tool-call-detail-state
Extract per-detail helpers for hasMeaningfulToolCallDetail to clear complexity warning.
* chore(lint): reduce complexity in agent-grouping
Split groupAgents into partition, project-activity map, active-project, and inactive-date helpers.
* chore(lint): reduce max-depth in voice-runtime
Extract retireFinishedGroup helper to flatten processPlaybackQueue nesting.
* chore(lint): reduce max-depth in use-branch-switcher
Extract maybeRestoreStashForBranch callback to flatten handleBranchSelect nesting.
* chore(lint): exhaustive-deps in components batch
Address exhaustive-deps warnings across file-explorer-pane, file-pane,
git-diff-pane, message, provider-diagnostic-sheet, stream-strategy-web,
terminal-emulator, terminal-pane, and volume-meter. Memoize values that
otherwise changed every render, read non-reactive values through refs
when the effect intentionally tracks a different key, and add missing
dependencies where they were genuinely absent.
* chore(lint): reduce complexity/max-depth in draft-store
Extract collectQueuedMessageAttachmentIds and collectStreamUserImageIds helpers from runAttachmentGc. Extract buildMigratedDraftRecord helper for migratePersistedState.
* chore(lint): reduce complexity in tooltip and workspace-scripts-button
Extract resolveActualSide and resolveAlignedCoordinate helpers in tooltip. Extract resolveScriptIconColor in workspace-scripts-button.
* chore(lint): exhaustive-deps in contexts and hooks batch
Fixes exhaustive-deps warnings across app contexts and hooks by adding
missing deps, stabilizing derived arrays with useMemo, capturing ref values
at effect entry, and using ref pattern where deps were intentionally omitted.
* chore(lint): final hook-rules in workspace-screen, editor-button, stores, e2e
Memoizes derived arrays (availableEditors, terminals) to stabilize
useMemo deps, adds missing deps (normalizedServerId, normalizedWorkspaceId,
explorerToggleStyle, workspaceIdsKey, eventName), and renames the Playwright
fixture callback parameter from `use` to `provide` so eslint-plugin-react-hooks
doesn't conflate it with React's use() hook.
* chore(lint): reduce complexity/max-nested-callbacks in workspace-tabs-store
Extract migrate function body into migrateWorkspaceTabsState top-level
helper with per-loop sub-helpers (migrateUiTabsForKey, mergeExplicitTabOrder,
mergeLegacyTabOrder, migrateFocusedTabIds) and replace the inline IIFE in
ensureTab with buildNextTabsForEnsure helper.
* chore(lint): reduce complexity in session-store and panel-store
Extract isSessionServerInfoUnchanged helper from updateSessionServerInfo
in session-store. In panel-store split migrate body into per-version
migration helpers (migratePanelV2Explorer, migratePanelV3Explorer,
migratePanelExplorerTabByCheckout, migratePanelDesktopFocusMode) and
a top-level migratePanelState function.
* chore(lint): reduce complexity in keyboard-shortcuts and use-keyboard-shortcuts
Split resolveKeyboardShortcut into resolveInitialChordStep and
resolveAdvancingChordStep helpers with a shared buildMatchFromBinding
factory. Split handleAction's large switch into handleDispatchOnlyAction,
handlePayloadAction, handleSettingsToggle, and handleCommandCenterToggle
helpers, with hasPayloadKey type guard replacing inline payload checks.
* chore(lint): reduce complexity in audio recorder web hooks
Extract assertMicrophoneEnvironment helper from useAudioRecorder start
callback. Split useDictationAudioSource stop callback with
disconnectDictationAudioGraph, stopMediaRecorderIfActive,
finalizeRecorderStoppedPromise, and safeDisconnectNode helpers.
* chore(lint): reduce complexity in use-git-actions
* chore(lint): reduce complexity in use-pr-pane-data
* chore(lint): reduce complexity in use-agent-autocomplete
* chore(lint): reduce complexity in use-agent-screen-state-machine
* chore(lint): reduce complexity in session-stream-reducers
* chore(lint): reduce complexity in desktop-updates-section
* chore(lint): reduce complexity in pair-device-section
* chore(lint): reduce complexity in update-callout-source
* chore(lint): reduce complexity in provider-diagnostic-sheet
* chore(lint): disable no-await-in-loop (sequential-by-necessity cases)
Most remaining no-await-in-loop warnings were in legitimate sequential
patterns: polling loops with sleep, streaming/pagination cursors, shared
mutable state, ordered side effects (audio playback, port allocation).
Parallelizing these would change observable behavior. Rather than force
restructures that add complexity without benefit, disable the rule.
* chore(lint): reduce complexity in setup-panel
Extract helpers (resolveAutoExpandIndex, resolveSetupStatusLabel,
resolveCommandLog, buildCommandRowState) and lift the standalone log
view and top-level error banner into dedicated sub-components to drop
SetupPanel below the complexity limit.
* chore(lint): reduce complexity in AssistantMarkdownImage
Extract error-text resolution into a helper so the image component
drops below the cyclomatic complexity threshold.
* chore(lint): reduce complexity in ProjectLeadingVisual
Extract the status-variant dispatch into a ProjectLeadingVisualStatus
sub-component so the outer function stays below the cyclomatic
complexity limit.
* chore(lint): extract ChatAgentContent agent-building helper
* chore(lint): reduce complexity in ChatAgentContent
Extract the chat-agent selector (selectChatAgentState +
resolveChatAgentFromSession), the agent-shape constructor
(buildChatAgentFromState), and the not-found/error/boot view
dispatch (renderChatAgentNonReadyView) into helpers so both the
selector callback and the component function drop below the
cyclomatic complexity limit.
* chore(lint): extract subcomponents to reduce JSX nesting depth
* chore(lint): reduce complexity in app components (status-bar, explorer, setup-dialog, tool-call-details)
* chore(lint): reduce complexity in app hooks/runtime/stores/misc components
* chore(lint): long-tail cleanup (no-async-endpoint-handlers, unicorn rules, unescaped-entities, unassigned-import allowlist)
* chore(lint): simplify agent useMemo deps to match helper signature
* chore(lint): rename ChatService.postMessage to dispatchMessage
* chore(lint): server cleanup (no-shadow, no-useless-*, no-unmodified-loop, control-regex, require-yield)
* chore(lint): app cleanup (jsx-max-depth, jsx-no-new-*-as-prop, complexity, misleading-regex, no-useless-*)
* chore(lint): e2e cleanup (no-unmodified-loop-condition, max-nested-callbacks)
* chore(lint): workspace-screen complexity + no-empty-pattern cleanup
* chore(lint): use Array.from for defensive snapshot iteration
* chore(lint): final mechanical tail (control-regex, max-depth, useless-expressions, examples cleanup)
* chore(lint): reduce agent-stream-view complexity
* chore(lint): reduce resolveAgentModelSelection complexity
* chore(lint): reduce globalSetup complexity
* chore(format): oxfmt draft-store single-line signature
* chore(lint): reduce GitDiffPane complexity
* chore(lint): reduce MessageInput complexity
* chore(lint): reduce Combobox complexity
* fix(server): bound listLinuxWatchDirectories readdir concurrency
Wraps the per-level readdir traversal in listLinuxWatchDirectories with a
module-level p-limit (default 16, tunable via PASEO_LINUX_WATCH_READDIR_CONCURRENCY).
On broad repos, the previous Promise.all over an entire BFS level could issue
an unbounded number of concurrent readdir calls. Behavior is otherwise
identical: still traverses all non-.git directories and swallows per-directory
readdir failures.
* chore(lint): promote oxlint warnings to errors
- package.json: lint/lint:fix now pass --deny-warnings so any warning fails
- .github/workflows/ci.yml: add lint job running npm run lint
- .oxlintrc.json: disable no-empty-pattern for e2e/fixtures.ts
(Playwright requires the empty object destructure as the first arg)
- packages/app/e2e/fixtures.ts: restore async ({}, provide) signature
- packages/app/e2e/global-setup.ts: oxfmt single-line signature
* fix(test): relay-transport ws mock exports WebSocket named binding
Commit 38efad7d switched relay-transport.ts from default to named import
of ws.WebSocket, but the test mock still only exported default. Updated
vi.mock("ws") to return both default and named WebSocket bindings so the
mocked module matches the new import shape. Classification: test drifted
with production code; test now exports what the production import needs.
* chore(lint): make oxlint error at config level; add Lefthook pre-commit
Replaces --deny-warnings workaround with semantic severity at the config
level, and adds whole-repo pre-commit gates via Lefthook.
- .oxlintrc.json: all enabled categories (correctness, suspicious, perf)
and intentional rule overrides now use "error" instead of "warn".
Removed duplicate "require-await" key (last-wins "off" preserved).
- package.json: lint scripts are plain oxlint / oxlint --fix. Adds
lefthook devDep and "prepare": "lefthook install --force" so hooks
install on npm install even where core.hooksPath is set globally.
- lefthook.yml: pre-commit runs format:check, lint, typecheck in
parallel. Whole-repo gates (no glob filters). Block-only, no
auto-formatting.
* fix: update lockfile signatures and Nix hash
* chore(lint): pre-commit runs formatter in write mode, then lint+typecheck
Switches the pre-commit hook from format:check to format (write) with
stage_fixed: true, so the formatter rewrites files and re-stages them
into the commit. Lint and typecheck then run in parallel against the
formatted tree.
Sequencing uses Lefthook 2.x jobs with a nested group: top-level jobs
run sequentially, so the format job completes before the checks group
starts; within checks, lint and typecheck run in parallel.
CI continues to use format:check, so unformatted code pushed with
--no-verify still fails on the server.
* chore(lint): revert pre-commit to format:check (no stage_fixed)
stage_fixed: true with a whole-repo formatter (oxfmt .) can silently
re-stage the full reformatted file when a developer used git add -p
to stage only a hunk, losing their hunk-level intent. Safer to just
block the commit on unformatted code and let the dev run
`npm run format` themselves.
Reverts to the simple parallel check form: all three commands run
concurrently and are read-only.
* chore(ts7): tsgo compatibility across the monorepo
Preserves tsc 5.9.3 behavior while making TypeScript 7 / tsgo
(@typescript/native-preview 7.0.0-dev.20260423.1) green
package-by-package.
tsconfig:
- server/tsconfig.server.json: drop removed `baseUrl`; rewrite
`@server/*` paths entry to be relative to the tsconfig.
- website/tsconfig.json: drop removed `baseUrl`; rewrite
`~/*` paths entry to be relative.
- expo-two-way-audio/tsconfig.json: replace inherited
moduleResolution=node10 with `bundler`, set `module: esnext`
(required by `bundler`). Matches Metro/Expo runtime behavior
and avoids forcing `.js` extensions in source.
source:
- app/e2e/helpers/terminal-probes.ts: WebSocket subclass `send`
now types its argument as `Parameters<WebSocket["send"]>[0]`
so the subclass signature tracks the platform type across
tsc 5.9 and TS 7 libdom.
- relay/src/crypto.ts: tweetnacl setPRNG callback allocates a
fresh Uint8Array for getRandomValues (owning ArrayBuffer) and
copies into the caller-provided view. TS 7 libdom requires
ArrayBufferView<ArrayBuffer> (not ArrayBufferLike).
All 8 packages green under both tsc --noEmit and tsgo --noEmit.
* fix(app): update os-notifications test mocks for addEventListener
Commit 46731a51 switched attachWebClickHandler from notification.onclick
to notification.addEventListener("click", ...) to satisfy the
prefer-add-event-listener lint rule. The test mocks still asserted
the old onclick property, so two specs failed on CI with
"notification.addEventListener is not a function". Update the three
MockNotification classes to record click listeners through
addEventListener and drive them via clickListeners[0] in the
assertions.
* chore: switch typecheck from tsc to tsgo (@typescript/native-preview)
Installs @typescript/native-preview 7.0.0-dev.20260423.1 as a root
devDependency and swaps every per-workspace `typecheck` script from
`tsc --noEmit` to `tsgo --noEmit`. Build scripts still use `tsc`, so
emit behavior is unchanged. Monorepo typecheck drops from ~28s to
~4.7s wall clock (~6x faster).
expo-two-way-audio previously ran `tsc` without --noEmit for its
typecheck step. tsgo is stricter about rootDir on emit, so the
typecheck script explicitly passes --noEmit here too.
* fix: update lockfile signatures and Nix hash
* fix(app): commit *.css module declaration for CI typecheck
expo generates expo-env.d.ts (which references expo/types where
*.css is declared) locally, but the file is gitignored. CI does a
fresh checkout + install and doesn't run expo, so the declaration
is missing and tsgo rejects the side-effect import
'@xterm/xterm/css/xterm.css' with TS2882. Add a committed
packages/app/global.d.ts with 'declare module "*.css"' so typecheck
is self-contained.
tsc tolerated the missing declaration more loosely; tsgo is stricter.
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
38 files removed across app and server (verified unreferenced via
grep + typecheck). Includes dead components, hooks, stores, utils,
POC scripts, an empty orchestrator stub, stale playwright configs,
and a duplicate workspace-registry test-helpers module.
knip config tightened: added babel.config.js, native platform
extensions, and test-stubs/ to app entry patterns.
Scoped to only `no-unused-vars` and `react/jsx-no-useless-fragment` —
the safe subset of --fix-suggestions. Primary changes:
- Remove unused named imports from statements
- Remove unused default/type imports entirely
- Collapse `<><Single/></>` fragments to `<Single/>`
Verified clean: typecheck + format + lint all green.
The safe --fix introduced three type issues:
1. unicorn/prefer-set-has rewrote array literals to `new Set(...)`
without updating the `T[]` type annotation — broken assignment.
Fix type to `Set<T>` in logger.ts and agent-manager.ts.
2. unicorn/no-array-reverse changed `.reverse()` → `.toReversed()`
(ES2023). Bump `lib` to ES2023 in tsconfig.base.json and
tsconfig.server.json. Target stays at ES2020 — Node 20 supports
the methods natively, no downlevel needed.
3. preserve-caught-error added `new Error(msg, { cause })` (ES2022),
also covered by the lib bump.
Auto-fixed 102 warnings (6040 → 5938) across 57 files. Main categories:
- preserve-caught-error: add { cause: error } to re-thrown errors
- unicorn/no-array-reverse: .reverse() → .toReversed()
- unicorn/no-useless-fallback-in-spread: drop unnecessary ?? {} in spreads
- oxc/no-map-spread: rewrite object spread in .map() to Object.assign
- react-hooks/exhaustive-deps: trim module-level constants from dep arrays
Ran npm run format after to clean up whitespace from lint mutations.
Apply prettier-compatible formatting across the repo to match the
incoming oxfmt configuration. Mechanical reformat only — no logic
changes. Covers YAML quote normalization, package.json key sorting,
Markdown/TOML formatting, and minor TS whitespace tweaks.
Adds a fastlane lane and an EAS Workflow custom job that runs after
submit_ios. Picks up the latest TestFlight build, waits for Apple
processing, and submits for review with auto-release on approval.
Uses ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 secrets from the EAS
production environment.
Splits first-class and custom provider docs out of the Configuration
page onto /docs/providers. Introduces the first-class providers
(claude, codex, opencode, copilot, pi) and covers extending a provider,
Z.AI and Qwen plans, multiple profiles, custom binaries, ACP agents,
additionalModels, and disabling. Points to docs/CUSTOM-PROVIDERS.md on
GitHub as the source of truth.
Codex was clearing fast mode after approving a plan.
This keeps fast mode and plan mode independent. Approving a plan still exits plan collaboration mode, and the follow-up implementation turn now keeps serviceTier=fast when fast mode was already enabled.
Verified locally:
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts --bail=1
- node node_modules/vitest/vitest.mjs run packages/server/src/server/agent/agent-manager.test.ts --bail=1
- npm run typecheck
- Fake gh stub now handles `gh pr view --json <fields>` by exiting 1
with "no pull requests found for branch", matching the daemon's
expected no-current-PR path; resolveCurrentPullRequestView no longer
cascades failures across the suite.
- Rewrite the two post-setup script tests in workspace-setup-streaming
to exercise explicit launch paths (UI scripts menu + daemon RPC) —
39db3ea5 intentionally removed automatic post-setup script spawning.
- Harden terminal-alternate-screen synchronization: wait for the probe
to observe alt-enter and alt-exit plus the final normal-screen marker
before asserting, so the echoed command no longer races the alt-screen
transition.
807cce6a hides the "Show setup" action when there is no workspace setup
snapshot. That broke the case where setup genuinely ran on a different
WebSocket session than the one the browser reconnected with: the snapshot
was cached per-Session, so discovery returned null and the action stayed
hidden.
Share the setup snapshot cache at the websocket-server level and inject
it into every Session. On focus, the workspace screen now requests
fetchWorkspaceSetupStatus when it has no local snapshot; workspaces with
no setup history still return null, preserving 807cce6a's intent.
packages/app's vitest config has a browser project backed by playwright
+ chromium; the app-tests job needs the browsers installed the same way
the playwright job already does.
mcp-server tests invoked registered tools via .handler, but the MCP
SDK exposes the callable as .callback. model-resolver now passes
force: false to fetchModels; widen the spy matcher to match.
Scripts launched from the UI now run in a dedicated terminal per script
(titled after the script) and reuse the same terminal across re-runs.
Script lifecycle is driven by a new onCommandFinished signal sourced from
OSC 633 command-finished events emitted by the zsh shell integration, so
foreground script exit updates status without killing the terminal.
Script payloads now carry terminalId so the workspace can surface the
associated tab from the scripts dropdown.
- PR icon color reflects merge state (open/merged/closed) via the
existing getWorkspacePrIconColor helper.
- PR badge is rendered before the failed-checks badge in the workspace
row.
- Hovering the PR badge swaps the GitPullRequest icon for ExternalLink
in place (same size, no layout shift) and removes the trailing arrow.
- Hovering the checks row in the workspace hover card brightens the
icon and "Checks" label to foreground.
The drag handle was attached only to the left sub-view of each row, so
grabbing the right half initiated no drag, and pointerdown on the branch
or project title raced the browser's text-selection start before
dnd-kit's activation threshold — often producing a text selection
instead of a drag.
Move dragHandleProps to the outer row wrapper, add userSelect: none to
the row styles, and tighten PointerSensor activation distance.
Drop the one-shot getCheckoutDiff query and use skipToken; the
subscribe_checkout_diff flow populates the cache on subscribe response
and updates. Derive isLoading from the presence of cached payload and
remove the now-unused isError/error fields from git-diff-pane.
Pass useIsFocused through as isRouteFocused rather than short-circuiting
the whole tree. Terminals query/subscription, checkout diff, providers
snapshot, keybindings, tab reconciliation, focused-agent writes, and
sidebar rendering all no-op when the route is in the stack but not
focused, so a hidden workspace screen no longer drives background work.
Expo Router maps getId to React Navigation getId, which reorders an
already-mounted workspace screen on Android native-stack/Fabric. Keep
workspace identity/retention outside the route-level API and leave a
comment warning against re-adding it.
Aggregates inbound message counts, bytes, handler timings, and
agent_stream breakdowns over a rolling window and flushes via logger.
Gated on runtimeMetricsIntervalMs config; disabled by default.
CheckoutDiffManager.refreshTarget was calling workspaceGitService.getCheckoutDiff
without forcing a cache bypass, so watch-fired refreshes within the 15s consumer
TTL returned the pre-mutation cached diff — fingerprint matched, no
checkout_diff_update was emitted, and the sidebar diff appeared frozen while an
agent edited files. Route watch-fired refreshes through the service with
{ force: true, reason: "working-tree-watch" } so they always recompute.
Pi's streamHistory only emitted text, so resuming a session lost tool
calls, tool results, and bash executions from the replayed timeline.
Iterate session.messages to also emit running/completed/failed tool_call
items and bashExecution shell calls, and tag user messages with a stable
messageId so hydrateTimelineFromLegacyProviderHistory can dedupe.
Cover it with a real OpenRouter-backed e2e test that runs a bash tool
call, resumes via PiDirectAgentClient.resumeSession, and asserts the
replayed timeline contains the user message, assistant message, and
completed shell tool_call with the expected output.
Centralizes refresh orchestration in WorkspaceGitService and GitHubService:
server coalesces, deduplicates, and throttles git/GitHub reads; clients peek
snapshots (cold reads fall through to a one-shot fetch) and never drive
refreshes. Introduces a two-TTL model (consumer vs internal), force-emit
bypass so fingerprint-matched refreshes still propagate, notifyGitMutation
wiring at ~13 action handlers, adaptive GitHub polling based on check state,
and checkout_status_update / prStatus push-to-cache wire messages. Fixes the
stale diff-stat bug and the inverted base-branch diff direction, and flips
next-action affordances promptly after commit / PR merge.
Replaces the per-client stale/visibility policy with one shared
presence cascade so a backgrounded Firefox tab can no longer steal
in-app notifications from an active Electron desktop window.
Server (`agent-attention-policy.ts`): exports `PRESENCE_THRESHOLD_MS`
(3 min) and `computeNotificationPlan({ allStates, agentId, reason,
nowMs })` returning `{ inAppRecipientIndex, shouldPush }`. Cascade:
focus suppression requires presence; otherwise the most-recent
present client wins (lower-index tiebreak); push fires only when no
client is present, and never on `error`. `broadcastAgentAttention`
calls the policy once per event.
Electron (`daemon-manager.ts`, `idle.ts`): registers
`desktop_get_system_idle_time` returning `powerMonitor
.getSystemIdleTime() * 1000`. Renderer wrapper
`getDesktopSystemIdleTimeMs()` is best-effort: rejected IPC, null,
NaN, Infinity, or negative values return null without throwing.
Hook (`use-client-activity.ts`): Electron-only effect polls every 5s
and updates `lastActivityAtRef` only forward, so OS-wide activity
keeps a backgrounded Electron window present while in-window events
still update immediately.
`ClientHeartbeatMessageSchema` is unchanged; old/new clients and
daemons remain wire-compatible.
Tests: 52 focused tests across policy unit, dispatch, e2e, hook,
desktop wrapper, and permissions. Phase-3 dispatch test +
Phase-5 stale-poll test verified by mutation (breaking the focus
check / the `>` guard makes the exact regression fail).
Eliminate sidebar re-render cascades by fixing both sides of the
subscription contract:
- Session-store writes preserve identity: setWorkspaces, mergeWorkspaces,
removeWorkspace, and patchWorkspaceScripts return the previous Map/entry
reference when content is unchanged.
- HostRuntimeController.updateSnapshot only notifies when a patched field
actually changed; idle probe ticks no longer replace the snapshot.
- New canonical hook surface in stores/session-store-hooks.ts
(useWorkspace, useWorkspaceFields, useWorkspaceStructure,
useWorkspaceKeys, useResolveWorkspaceIdByCwd,
useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority,
useRecommendedProjectPaths, useHasWorkspaces). These are the only
place to subscribe to workspaces.
- 15 greedy subscribers migrated to the hook surface. The sidebar is now
driven by useWorkspaceStructure for identity/ordering; each row
hydrates its own descriptor via useWorkspaceFields, so a workspace
update re-renders only its row. Archive-redirect callbacks switched to
event-time useSessionStore.getState().
- Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts,
and the unused SidebarProjectEntry aggregates (activeCount,
totalWorkspaces, aggregated statusBucket).
No user-visible behavior change. Targeted tests green (79 tests across
host-runtime, session-store, session-store-hooks, sidebar list, sidebar
row model, sidebar shortcuts, workspace source of truth).
The file explorer had a useEffect that re-enforced expansion of every
ancestor of `selectedEntryPath` whenever `expandedPaths` changed. As a
result, the moment a user collapsed the parent folder of a file they
had just opened, the effect re-added that folder to the expanded set
and the folder appeared stuck open.
Selection and expansion are orthogonal: clicking a file should not
dictate that its parent folder stays open forever. There is also no
current caller that sets `selectedEntryPath` from outside the tree, so
the reveal-on-external-selection use case the effect was designed for
is unused. Delete the effect and its two now-dead helpers. If a future
"Reveal in Explorer" action is introduced, it can expand ancestors at
the call site.
Adds an E2E regression test that reproduces the original scenario:
expand a folder, open an image, collapse the parent folder, assert
children are hidden. Also asserts an unrelated sibling folder still
expands after the image is open, guarding against any future regression
that makes the bug global.
normalizeCheckoutPrStatusPayload always emits the full wire shape now
(number, repoOwner, repoName, isDraft, checks, checksStatus,
reviewDecision) — the workspace-git-watch test's toEqual assertion
still had the pre-PR-pane shape and was failing.
Update the expected object to match the actual wire contract.
message-input.tsx imports ./composer-height-mirror, which only exists
as .web.ts / .native.ts / .d.ts. Metro picks the right variant at build
time, but vitest was using Vite's default extension list and failing to
resolve the import — breaking app tests in CI.
Prepend `.web.*` variants to `resolve.extensions` in both the root and
app vitest configs so test runs (which already alias react-native →
react-native-web) pick the web implementation, matching what Metro
does for the web bundle.
Branch switcher listed remote-only branches as if they were local, then
threw "Branch not found" when clicked because the checkout path only
verified `refs/heads/<name>`. Explicit `origin/<name>` input went the
other way — verify passed, but checkout produced a detached HEAD.
Split the concerns into three layers:
- Enumerate: `listBranchSuggestions` returns provenance (`hasLocal`,
`hasRemote`) so the wire payload no longer loses origin-only info.
Fields are optional on `branchDetails` for backward compat.
- Resolve: new `resolveBranchCheckout` normalizes `origin/<name>` →
`<name>`, verifies explicit `refs/heads/` and `refs/remotes/origin/`
paths (no tag-DWIM), returns a tagged
`{ local | remote-only | not-found }` result. Policy (local wins,
origin fallback) lives here.
- Act: `checkoutExistingBranch` no longer does implicit DWIM. Local →
`git checkout <name>`. Remote-only → `git checkout -b <name> --track
origin/<name>`. Not-found → throws the existing
`Branch not found: <name>` string. Response carries optional
`source: "local" | "remote"`.
`validateBranch` now routes through the same resolver so both paths
agree on what a branch is.
Also fixes a related stale-base diff bug: worktrees created off
`origin/main` could show phantom "added" commits when the local `main`
lagged. `resolveBestComparisonBaseRef` is now origin-first for
diff/status. Merge still uses the "most-ahead" helper so unpublished
local commits aren't lost. `doesGitRefExist` tightened to only swallow
exit 1 (missing ref), propagating other git failures.
Introduce a PR tab in the explorer sidebar that shows status checks,
reviews, and the merged timeline for the current branch's pull
request. Extend checkout PR status with number, draft flag, repo
identity, and per-check workflow/duration; add a new
pull_request_timeline request/response pair backed by a GraphQL
query; enrich the workspace git snapshot to carry the fields through
to the client; and extend the checkout-git-actions store so timeline
queries are invalidated alongside status when mutations land.
Pass the GitHub service into pullCurrentBranch, pushCurrentBranch,
createPullRequest, branch setup, and worktree archival, and invalidate
the cached PR status / repo view whenever a mutation succeeds. Have
mergeToBase return the worktree cwd it actually touched so the session
can invalidate the base checkout when the merge lands there.
Plumb updatedAt through GitHub issue/PR search and committerDate through
branch suggestions so the new-workspace picker interleaves branches and
PRs sorted by last activity. Debounce the search query so remote GitHub
searches are driven by what the user typed rather than a static list.
Measure the header row width and hide text labels on the Open,
Scripts, and git action split buttons below 700px so the header
fits without wrapping on split-pane layouts.
Introduce a shared scoreMatch utility that tiers matches by
word-boundary vs mid-word offset, and use it to rank combobox
options so e.g. "py" prefers "a/py" over "happy".
The spawnSync parent had no signal handlers, so SIGINT killed it immediately
while the supervisor kept running in the background. The shell prompt came
back before the daemon had actually drained. Install no-op SIGINT/SIGTERM
handlers so spawnSync waits for the supervisor's graceful exit.
Pi's ACP binary streams 200+ redundant tool_call_update events per tool call,
bypassing the server coalescer and flooding the app with synchronous state
updates. Two-layer fix:
Server: extend AgentStreamCoalescer to handle tool_call items via last-write-wins
per callId (200ms window, up from 33ms). Terminal statuses flush immediately.
App: mergeToolCallDetail and appendAgentToolCall now return existing references
when fields are unchanged, preserving React.memo equality checks.
Measuring scrollHeight on the live textarea with an explicit height
set can only grow — it can never report a shorter content height.
The old `height: auto` toggle flickered across browsers.
Use a hidden off-DOM textarea that mirrors the composer's text-metric
styles and width, and read its scrollHeight on value/resize changes.
Gated by Metro file extension (.web / .native) so it's trivial to
rip out later.
* feat(server): replace Pi ACP integration with direct SDK provider
Replace the ACP-based Pi provider (which spawned a `pi-acp` subprocess
and talked JSON-RPC over stdio) with a direct in-process integration
using `@mariozechner/pi-coding-agent` as a library.
The new `PiDirectAgentClient` and `PiDirectAgentSession` use the Pi SDK
directly — creating sessions via `createAgentSession()`, managing models
via `ModelRegistry`, and mapping Pi's `AgentSessionEvent` stream to
Paseo's `AgentStreamEvent` types including:
- Thread/turn lifecycle events
- Assistant text and thinking/reasoning deltas
- Tool execution (bash, read, write, edit, find, grep, ls) with arg
caching across start/update/end events
- Compaction events
- Session persistence and resume via `SessionManager`
Thinking levels flow through the existing `thinkingOptionId` /
`setThinkingOption()` infrastructure, not through features.
Includes 9 real e2e tests against a live Pi agent covering tool calls
(bash/read/write/edit), reasoning chunks, session persistence/resume,
model listing, and thinking option management.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update lockfile signatures and Nix hash
* refactor(server): improve Pi direct provider code quality
Address coding standards violations in pi-direct-agent.ts:
- Replace all `as Record<string, unknown>` / `as any` / `as unknown as`
casts with properly typed boundaries using Pi SDK's exported tool
input types (BashToolInput, ReadToolInput, EditToolInput, etc.)
- Introduce named interfaces at all boundaries: PiPromptPayload,
ToolCallOutputSummary, PiModelReference, PiPersistenceMetadata
- Parse tool args once at boundary into typed discriminated union,
then trust types internally — no more typeof probing in mappers
- Break all dense ternary chains into named steps / helper functions
- Import SDK types directly (RegisteredCommand, ResourceLoader, Skill)
instead of casting through anonymous inline shapes
- Isolate the system prompt SDK escape hatch into a minimal boundary
helper with a named interface
Behavior is preserved — all 9 real e2e tests still pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: regenerate package-lock.json with resolved/integrity fields
The previous lockfile regeneration ran under an offline/cached npm
context and stripped resolved URLs and integrity hashes from existing
entries, which would break npm ci and Nix builds.
* fix: update Nix npmDepsHash for regenerated lockfile
* chore(server): bump @mariozechner/pi-coding-agent to ^0.67.68
* fix: update lockfile signatures and Nix hash
* fix(app): surface agent setter RPC errors as toasts
setAgentMode/Model/ThinkingOption/Feature errors from the daemon were
only logged to console, so failures like missing API keys silently
vanished. Add toast.error fallbacks at all call sites and hoist
ToastProvider above SessionProvider so session context can use it.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.
Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
and slugifies all labels at the boundary; `untitled` fallback only
applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
the slug; GitHub URL parsing now requires `host === "github.com"` to
avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
cleanup after a branch rename doesn't leave a stale entry behind.
App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.
Landing-page service URL examples updated; allowlist docs unchanged.
- worktree-core test scans the server source via a pure Node walker
instead of shelling out to `rg`, which is not installed on CI runners.
Invariant and scope are unchanged.
- settings-host-page specs target the new detail-header label + rename
affordance (settings-detail-header-title / host-page-label-edit-button)
since the label card moved into the shared header as part of the
modular settings refactor.
- settings-navigation compact tests assert the new two-tier Back
semantics: root Back exits settings, detail Back returns to /settings.
Introduce AgentStreamCoalescer to batch rapid-fire agent events before
broadcasting, reducing WebSocket chatter during tool-heavy turns. Wire
websocket-server runtime metrics so we can observe coalescing behavior
in tests. Work in progress.
Chrome gating currently disables shortcuts on non-workspace routes. Temporary
pathname check until chromeEnabled is split into separate workspace/global
concerns.
Document the PASEO_SERVICE_<NAME>_URL/PORT and PASEO_URL/PORT contract exposed
by the service-script runtime. Split paseo.json's monolithic "server" script
into "daemon" and "app" services so peer discovery exercises the new env.
Pull service env construction and port allocation out of worktree-bootstrap
into dedicated modules with their own tests, so the bootstrap path only
orchestrates. Adds peer collision detection and a port plan that is
computed once and reused across scripts.
invalidate() flagged the query stale and kicked off a refetch unconditionally,
causing a loading flash every time the model selector opened. Expose
refetchIfStale() instead so callers only refetch when data is actually stale.
The new-workspace composer is a one-shot "create" submit, not a chat send, so
a return-key glyph reads more accurately there than an arrow. Add a submitIcon
prop on Composer/MessageInput (defaulting to "arrow") and opt NewWorkspace
into "return".
The worktree.new keyboard handler was registered inside ProjectHeaderRow,
which only mounts when the sidebar is open. Register a single global handler
in the app layout keyed off the navigation-active workspace selection.
Pass NO_BROWSER=true when probing ACP agents for models, modes, and
persisted sessions. Gemini CLI's ACP newSession handler calls refreshAuth
inline and will open a Google OAuth URL in the browser when no valid
cached token is present — every snapshot warm-up triggered this on
navigation. Real agent sessions still use their own launchContext.env,
so interactive auth continues to work when actually starting an agent.
Closes the hover card via a safe-zone hook that covers the trigger, the
content, and the rectangular bridge between them, so crossing the visual
gap no longer drops the hover. Replaces the event-based close flow whose
dropped pointerleave events left cards stuck open.
Modal close restores focus to the tooltip's trigger, which was firing
onFocus and re-opening the tooltip. Gate onFocus on input modality so
only keyboard-driven focus opens it, matching W3C tooltip behavior.
Also removes the key-based remount workarounds that existed solely to
sidestep this bug.
The source-control button rendered diff stats inline with no formatting
while the sidebar used toLocaleString, so identical numbers looked
different. Route both through DiffStat and switch to compact notation
(e.g. 87,700 -> 87.7k) so large counts stay readable.
Fan out agent close + terminal kill concurrently with Promise.allSettled
so one failure never leaves the worktree half-dead. Await real PTY exit
with SIGHUP→SIGKILL escalation before rm to avoid EBUSY races. Tolerate
missing repoRoot and retry directory removal with backoff. Purge stale
client workspace layout/tab state after successful archive.
The legacy list_provider_models_request handler was calling
providerRegistry[p].fetchModels() directly, so every client call spawned
the provider fresh (e.g. repeated copilot ENOENT errors). Old clients
still send this RPC.
Route it through ProviderSnapshotManager so simultaneous calls dedupe
through the in-flight warmUps map, cached entries (ready / error /
unavailable) are served without spawning, and at most one spawn occurs
per cwd per TTL window regardless of client count. Direct-fetch path
kept only as a fallback for the null-manager case (tests).
The workspace screen was prefetching per-provider model lists via
`useProviderModels`, which fanned out N parallel
`list_provider_models_request`s that bypassed `ProviderSnapshotManager`
and spawned each provider fresh on every call (e.g. repeated copilot
ENOENT errors). The return value was never consumed.
Replace it with `useProvidersSnapshot(serverId, workspaceDirectory)` so
warmup goes through the shared, deduped, TTL-cached snapshot path that
consumers already read from.
Workspace creation landed on the setup tab because openTab implicitly
focused the tab it opened, and the setup auto-open effect ran after
the setup dialog's navigation, stomping on the agent tab's focus.
Split the store API into explicit openTabFocused and openTabInBackground
so every caller declares intent. The setup auto-open effect now opens in
background and no longer steals focus; user-intent call sites (sidebar,
kebab menus, file explorer, terminal creation, prepareWorkspaceTab)
use openTabFocused and drop the redundant paired focusTab call.
On iOS, tapping "+" → "Add image" in the Composer left an invisible
backdrop trapping touches after the native photo picker dismissed. Root
cause: the dropdown menu's onSelect fired while the RN Modal was still
completing UIKit dismissal, so PHPicker presented over a dismissing
Modal that never cleanly released.
DropdownMenu now defers the selected item's onSelect until Modal
onDismiss (UIKit-level completion) plus a short buffer on iOS, then
invokes it. Same DropdownMenu instance — no per-call opt-in.
Along the way, the Maestro repro surfaced a controlled-TextInput
character-drop race: fast inputText on a controlled input drops
characters mid-type. Direct-host and pair-link inputs are now
ref-backed uncontrolled inputs with stable testIDs.
New reusable E2E primitives under packages/app/maestro/:
- flows/land-in-chat.yaml — clearState + direct connect to
127.0.0.1:6767, lands in the composer. Compose on this for any
composer-level fixture.
- image-picker-repro.yaml — regression for the zombie-backdrop bug.
docs/MOBILE_TESTING.md gains the patterns: reach-the-composer via
land-in-chat, uncontrolled inputs for Maestro-typed fields, and the
dropdown → native-presenter dismissal timing on iOS.
Existing relay-pairing and relay-then-direct fixtures updated to use
stable testIDs instead of ambiguous text / point-based taps.
`window` exists on React Native (aliased to `global`) but `window.location` is
undefined, so reading `window.location.pathname` threw and killed the redirect
effect — leaving the host index route stuck rendering null (blank screen).
Use the `pathname` from `usePathname()` directly, restoring the pre-refactor
behavior that worked on both web and native.
Images and GitHub issues/PRs now live in one draft-store list (ComposerAttachment union) with cwd persisted on the draft. Paperclip becomes a + drop-up menu; queue button removed. Each pill uses a shared AttachmentPill wrapper with a corner-X that mirrors the auto-update toast spec. Pill body opens the attachment: GitHub via openExternalUrl, images via a new fullscreen AttachmentLightbox. GitHub picker auto-closes on select.
If a first archive attempt removed git's admin dir but failed to clean the
working tree (e.g. due to file churn during setup), retrying the archive
failed the ownership gate with "Worktree is not a Paseo-owned worktree"
because isPaseoOwnedWorktreeCwd required the worktree to still appear in
git worktree list.
- isPaseoOwnedWorktreeCwd now decides ownership by path shape
($PASEO_HOME/worktrees/<hash>/<slug>[/...]) and treats git as
best-effort for resolving repoRoot.
- deletePaseoWorktree tolerates a missing admin dir: it skips teardown
when the tree is absent, swallows a failing git worktree remove so
the rmSync fallback always runs, and follows up with git worktree
prune to clear stale admin refs.
- Relabel the post-creation setup failure log as
"Background worktree setup failed" since the worktree itself already
exists at that point.
Assistant markdown images with ~-prefixed paths hit ENOENT because
path.resolve doesn't expand ~, leaving paths like
workspaceRoot/~/rest-of-path that never resolve. Reuse the existing
resolvePathFromBase helper so tilde paths inside the workspace load
instead of getting stuck on the image spinner; the sandbox check still
rejects tilde paths that resolve outside the workspace.
Adds claude-opus-4-7 and claude-opus-4-7[1m] alongside the existing 4.6
entries (4.6 stays default). Opus 4.7 introduces a new "xhigh" effort
level between high and max; the option only surfaces on 4.7 models.
The SDK 0.2.71 effort union doesn't yet include xhigh, so the value is
cast at the assignment site.
Tightens transparent icon gaps to 4px and gives filled action buttons
(send, interrupt) a 4px left margin so they sit 8px from neighbors.
Shrinks context-window meter SVG from 20 to 16 to match the visual
weight of surrounding icons.
The selector returned a freshly constructed authority object each call, so
useSyncExternalStore saw a new reference every render and force-rerendered,
producing a maximum update depth crash. Select the specific workspace
descriptor (stable reference) and derive the authority via useMemo.
The opaque-workspace-identifier refactor (e6044264) added a `b64_` prefix
to base64-encoded workspace IDs in route builders. Two app tests still
expected the unprefixed form and failed in CI.
Session.unarchiveAgentState(id) and the direct agentManager.unarchiveSnapshotByHandle
delegation were removed when main's 86bb5cc8 + a11c246b refactors moved unarchive
through the shared mcp-shared helper. Remove the now-dead test assertions so the
suite reflects the real ownership boundary.
Split the OpenCode reliability bullet into four per-PR entries, shorten
the Windows and provider-models bullets, and drop low-signal qualifiers.
Add a "Changelog conciseness" section to the release playbook so the
next agent knows bullets must be scannable in one glance.
Extract `unarchiveAgentState` and `sendPromptToAgent` into `mcp-shared`
so every surface (app/WS, MCP, CLI-through-MCP) runs the same sequence:
unarchive → ensureAgentLoaded → optional mode change → recordUserMessage
→ startAgentRun. MCP `send_agent_prompt` previously skipped unarchive and
cold-agent rehydration, so sending to an archived agent over MCP left it
hidden from `paseo ls` and failed entirely when the agent wasn't already
in memory. Session now delegates to the shared function and its private
`unarchiveAgentState` is gone.
Some status output (e.g. "Not logged in") goes to stderr. Capture both
stdout and stderr, including when the command exits non-zero, so the
diagnostic surfaces the full picture.
Return stdout verbatim instead of parsing JSON fields. Parser was fragile
to upstream schema changes and added no value — users can read the raw
output directly.
Explicit `undefined` keys on optional snapshot fields were converted
to `null` by `ensureValidJson`, failing `AgentSnapshotPayloadSchema`
validation on the wire. Stop writing those keys so they remain absent,
which is what `.optional()` expects.
Also adds a test that parses `list_agents` output through the schema
to catch this class of bug.
* fix(server): harden Codex/Windows startup and provider resolution
Addresses #452, #443, #353, #418, #403, #221, #307, and locks in #284.
- Replace custom where.exe/which output parsing with npm `which@^5` plus a
spawn probe. findExecutable now enumerates all PATH+PATHEXT candidates
and returns the first invokable one. A WindowsApps-ACL'd codex.exe no
longer wins over a working codex.cmd (#452).
- Make default provider isAvailable() check the binary instead of always
returning true. Codex/Claude/OpenCode default isAvailable() now defer
to isCommandAvailable() so missing CLIs surface as unavailable instead
of throwing later from spawn (#221, #443).
- Gate AgentManager.resumeAgentFromPersistence on isAvailable() so a
persisted agent record with a missing binary cannot reach provider
spawn during rehydration. The daemon stays up and the agent reports
unavailable (#443, #353, #418, #403).
- Drop --path-format=absolute from rev-parse callers and validate stdout
through a shared parser that rejects multi-line output and unknown-flag
echoes. --show-toplevel is absolute by default in modern Git;
--git-common-dir is resolved against the command cwd. Fixes workspace
registration on pre-2.31 Git that echoed the unknown flag and produced
a two-line "path" (#307).
Tests:
- Real-FS executable.test.ts using temp PATH fixtures, covering the .cmd
fallback after .exe pre-spawn failure (Windows-only) plus the
null-on-no-invokable-candidate case.
- provider-availability.test.ts builds real provider clients against a
temp-dir-only PATH for Codex/Claude/OpenCode.
- bootstrap-provider-availability.test.ts builds the daemon and triggers
ensureAgentLoaded so it actually exercises resumeAgentFromPersistence.
- claude-agent.spawn.test.ts asserts shell: false reaches spawnProcess
from the Claude SDK spawn override, locking in 39b56af4 for #284.
- checkout-git-rev-parse.test.ts covers nested-checkout resolution and
the old-Git multi-line stdout case via a tightly-scoped runGitCommand
fake.
CI:
- server-tests-windows now also runs the new and modified test files so
the Windows behaviors are exercised on windows-latest.
* fix: update lockfile signatures and Nix hash
* fix(server): handle synchronous spawn UNKNOWN on Windows
On Windows, child_process.spawn() throws synchronously when invoked on
a corrupt or invalid .exe (e.g., a WindowsApps stub the current user
cannot execute, or a zero-byte file). The executable probe did not
guard the spawn call, so the synchronous throw rejected the probe
promise instead of resolving false, preventing findExecutable() from
trying the next candidate. This is the root cause of the daemon-crash
pattern in #452: codex.exe from WindowsApps would hard-fail before the
.cmd shim ever got a chance.
Wrap spawn() in try/catch and settle false on sync throw. The existing
error-event and exit-event handlers already cover async failure modes;
sync throw just needed one more guard.
Also adjust three Windows-only test comparisons that were asserting
platform-dependent string equality:
- executable.test: compare .cmd paths case-insensitively (which@5
preserves PATHEXT casing, which is uppercase in production).
- workspace-registry-model.test: expect normalizeWorkspaceId(path),
not the hardcoded POSIX form.
- checkout-git-rev-parse.test: normalize separators/case when
comparing git's Windows forward-slash output against realpathSync.
* test(server): canonicalize repo root via git on Windows to fix short-name mismatch
realpathSync on Windows preserves 8.3 short names (e.g. RUNNER~1) while git's
rev-parse --show-toplevel always returns the long-name form (runneradmin).
Use git as the canonicalizer on both sides of the assertion so the comparison
holds regardless of how Windows exposes the temp directory path.
* fix(server): Claude is always available in default mode; SDK bundles cli.js
The Phase 2 change wrongly tied Claude's default-mode isAvailable() to
isCommandAvailable("claude"). Claude's default runtime does not use an
external `claude` binary — @anthropic-ai/claude-agent-sdk ships its own
cli.js and spawnClaudeCodeProcess runs it via process.execPath. The
previous `return true` was correct; restore it.
Update provider-availability.test.ts to assert the truthful behavior:
Claude reports available even when no `claude` binary is on PATH.
Codex and OpenCode genuinely require their binaries on PATH, so their
availability checks remain unchanged.
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Gate the GitBranch icon behind isGitCheckout, and keep the header
skeleton visible until the checkout status query resolves so the icon
doesn't pop in and cause layout shift.
* feat: add server-side TTL for provider snapshots and model list UI
Provider snapshots cached forever, causing newly available models
(e.g. OpenCode Go minimax, glm) to never appear in the picker.
Server: ProviderSnapshotManager now returns stale data immediately
and kicks off a background re-warm when the snapshot is older than
5 minutes. Injectable TTL/clock for testing.
App: Provider settings rows show model count and are tappable to
open a diagnostic sheet with a read-only model list (label + ID).
* feat: add per-provider refresh for models and diagnostic
* refactor: await refresh completion and clean up diagnostic sheet state
- ProviderSnapshotManager.refresh is now async; session.ts awaits it so
the RPC ACK reflects real completion instead of just queuing work.
- Preserve models/modes/fetchedAt on entries during targeted refresh so
the list no longer flashes empty mid-refresh.
- Show "Updated Xs ago" next to the Models count, plus loading and
error states for the list body.
- Split resetSnapshotToLoading into full vs targeted branches.
- Flatten nested ternary in model list rendering into renderModelsBody.
- Drop redundant local refreshing state and cargo useMemo wrappers.
* fix(app): widen isImeComposingKeyboardEvent type to accept optional fields
TextInputKeyPressEventData has isComposing and keyCode as optional, so
Pick<KeyboardEvent, ...> was too narrow and broke typecheck on main.
Add `selectable` prop to the code line Text component so users can
long-press to select and copy text on iOS. On web/desktop this is
already the default behavior via CSS user-select.
Markdown file preview has a similar issue but requires a different
fix (custom rules for react-native-markdown-display), left for a
follow-up.
Refs #238
Related #21
Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
- Add attribution format requiring PR link and contributor GitHub for each bullet
- Skip attribution for core team (@boudra); changelog highlights community work
- One bullet can reference multiple PRs and contributors; de-duplicate names
- Document ordering: user-facing features first, then QoL, then internal-with-user-benefit
Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer.
* style(app): theme native scrollbars in tool call detail views
Apply CSS scrollbar-color to all ScrollViews in ToolCallDetailsContent
and DiffViewer so the browser scrollbar matches the app's dark theme
instead of showing the default white native scrollbar.
* refactor: split useWebScrollbarStyle into .web.ts/.native.ts platform files
- Native shim returns undefined without calling useUnistyles
- Web variant uses properly typed WebScrollbarStyle interface instead of `as any`
- Add .d.ts for TypeScript module resolution
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Extract shared functions from session.ts (toAgentPersistenceHandle,
buildStoredAgentPayload, ensureAgentLoaded) so both CLI/WebSocket
handlers and MCP tools use the same code paths for agent lookup.
Fix get_agent_status, get_agent_activity, and list_agents MCP tools
to fall back to persistent storage for archived agents. Add
includeArchived param to list_agents. Fix setupFinishNotification
to not wake archived callers. Delete dead agent-management-mcp.ts.
Allow providers to specify tools that should be disabled via a
disallowedTools array in config.json. This is useful for providers
that extend "claude" but point to third-party API endpoints that
don't support Anthropic-only server-side tools like WebSearch.
Closes#390
Detect .md and .markdown files in the shared file pane and render them as markdown instead of syntax highlighted code. This makes README-style files easier to read without changing the existing preview flow for other text files or .mdx.
Keep the change focused to the app renderer by reusing the existing markdown stack and adding a narrow extension check with targeted coverage.
Translate OpenCode todo and compaction events into Paseo timeline items so issue #106 uses the existing todo list and compaction UI without changing the client model.
Keep session.status handling limited to existing terminal states and add focused translator coverage for each mapped event.
Fixes#106
Co-authored-by: OpenCode <noreply@openai.com>
hasInitializedRef was set to true before confirming the directory
listing request actually succeeded. On page refresh the WebSocket
client is still reconnecting, so requestDirectoryListing returned
early with "Host is not connected" and the ref stayed true —
preventing any retry once the client became available.
Fix: make requestDirectoryListing return Promise<boolean> and reset
hasInitializedRef on failure so the init effect re-runs automatically
when requestDirectoryListing is recreated after the client reconnects.
Fixes#441
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
AgentLoadingService was extracted from session.ts but dropped all
hydrateTimelineFromProvider() calls, leaving agents with empty timelines
after daemon restart. Also fixed a duplicate initial-prompt send bug and
removed redundant null checks, dedup maps, and hasOwnProperty theater.
* fix: file preview shows stale content when re-opening a file
The file content query inherits `refetchOnMount: false` and
`gcTime: Infinity` from the global QueryClient defaults, so
re-opening a previously viewed file never re-fetches its content.
Set `staleTime: 0` and `refetchOnMount: true` on the file preview
query so it always fetches the latest content when the component
mounts.
Fixes#351
* fix: keep original staleTime, only add refetchOnMount
staleTime: 5_000 is reasonable — avoids redundant fetches when
quickly toggling the same file. The actual fix only needs
refetchOnMount: true to override the global default of false.
---------
Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
The Create button on the new workspace screen was visible but clicking
it did nothing — submitAgentInput() still rejected empty payloads as
"noop". Thread allowEmptySubmit through Composer into submitAgentInput
so the callback fires and workspace creation + redirect completes.
Adds regression test for the empty-submit path.
* refactor: rename allowedHosts to hostnames for DNS rebinding config
The old name confused users who thought it was related to CORS.
"hostnames" communicates that you're declaring the daemon's own
hostnames, not granting access to external parties.
Backward compatible: old config files (daemon.allowedHosts), env var
(PASEO_ALLOWED_HOSTS), CLI flag (--allowed-hosts), and Nix option
(allowedHosts) all still work as deprecated aliases.
* fix(nix): update npmDepsHash to match current package-lock.json
Terminal tests (launcher-tab, terminal-performance):
- Terminal creation no longer silently no-ops when workspace metadata
hasn't hydrated. Early clicks are queued and flushed once the workspace
directory is ready, with toast feedback.
- Terminal button is disabled while waiting on workspace readiness.
- Removed isFocused gate from ?open= intent consumption in workspace
layout so deep-linked terminals open reliably on cold startup.
New workspace test:
- New workspace screen now shows a visible Create button even when the
composer is empty, via new allowEmptySubmit and
submitButtonAccessibilityLabel props threaded through Composer and
MessageInput.
Forward-port main's timeline epoch tracking, projected window selection,
and schema updates into dev. Remove aider/amp/gemini providers and
provider-history-compatibility layers that are no longer needed.
The prop represents whether the pane/panel is focused, not whether the
text input is focused. Rename across all 5 files to match the caller
names and reduce confusion.
Show a muted "⌘L to focus" hint in the top-right of the message input
when unfocused and empty on web. Registers Cmd+L (Mac) / Ctrl+L
(non-Mac) bindings for the existing message-input.focus action.
Treat OpenCode slash-command header timeouts as recoverable transport failures so Paseo waits for the SSE terminal event instead of failing the turn immediately.
Add a regression test for the timeout path to keep slash commands aligned with the existing event-stream completion flow.
Reconcile OpenCode provider state when Paseo closes or archives an agent so upstream sessions do not remain active after local teardown.
Keep the close path idempotent by attempting an abort first and then marking the upstream session archived even when the run is already idle or missing.
Fixes#400
Co-authored-by: OpenCode <noreply@openai.com>
Convert cancel_agent_request and clear_agent_attention from
fire-and-forget to request/response RPCs that return the authoritative
agent snapshot. This enables client-side self-healing when agent_update
messages are missed (e.g. agent appears stuck running when it isn't).
On mobile, the server drops live stream events for backgrounded or
unfocused agents. When the user returns, a seq gap triggers a
catch-up fetch whose canonical entries are appended to the tail —
but the head was never flushed. Stale live items from before the
gap stayed in the head and rendered after the newer catch-up entries,
breaking chronological order. Worse, subsequent live events of the
same kind (e.g. assistant_message) would append to the stale head
item, garbling message content.
Now the incremental path in processTimelineResponse flushes head →
tail before reducing canonical entries, keeping the timeline ordered
and the head clean for new live events.
Always render the GitBranch icon regardless of branch availability so
the layout doesn't shift when the branch name loads. Only the chevron
remains dynamic. Also removes vertical padding on mobile to tighten
the gap between the branch row and project subtitle.
A single workspace failing to load git data (e.g. empty repo, corrupt
git state) was crashing the entire fetch_workspaces response, leaving
all users with an unusable app. Now errors are caught per-workspace
and logged as warnings, returning the workspace without git data.
git rev-parse --abbrev-ref HEAD fails with exit code 128 on freshly
initialized repos with no commits. This error bubbled up through
workspace listing and showed a toast error on every app launch for
users with empty git repos.
The new-worktree button spinner was hidden when the cursor left the
project row. Force visibility when the mutation is pending so users
see the loading state throughout creation.
registerPendingWorktreeWorkspace was subscribing the WorkspaceGitService
before the worktree directory existed on disk, caching a stale isGit:false
snapshot. When background reconciliation ran, it consumed the stale cache,
overwrote the correct workspace record with a wrong projectId, and briefly
showed the worktree as a standalone project in the sidebar.
Move syncWorkspaceGitWatchTarget from registerPendingWorktreeWorkspace into
handleCreatePaseoWorktreeRequest, called after createAgentWorktree, so the
first snapshot sees the real worktree — one subscribe, one load, no stale
cache to race against.
The requestWorkingTreeWatch tests assumed macOS/Windows behavior where
recursive fs.watch is attempted. On Linux, the service uses per-directory
watchers instead, so recursive is never tried.
Route all git checkout queries through WorkspaceGitService instead of
shelling out to git on every call. This makes fetch_agents_request
instant when warm (cached in-memory snapshots) and deduplicates
concurrent cold-start refreshes via ensureWorkspaceTarget.
- buildProjectPlacementForCwd now uses workspaceGitService.getSnapshot()
- worktree-session call sites use WorkspaceGitService
- Deleted getCheckoutStatusLite and its 4 return types from checkout-git
- Fixed cold-start thundering herd: getSnapshot deduplicates via
ensureWorkspaceTarget instead of spawning parallel git processes
- Extracted checkout-diff logic into WorkspaceGitService
The handler was calling refresh() before every getSnapshot(), forcing
a full git + gh subprocess reload (~1.2s). The filesystem watcher
already keeps the snapshot current, so just read the cache directly.
The hasSelectedAgent condition was redundantly gating Cmd+E (toggle right
sidebar) and Cmd+Shift+F (toggle focus mode) behind a pathname check that
Cmd+B and Cmd+. didn't require. The action handler already guards against
invalid states, making the when-clause check unnecessary and broken on the
dev branch's navigation flow.
The `referenceAtOrigin` check in `hasResolvedDesktopPosition` was
accidentally inverted during the provider profiles refactor (6f280276).
`!referenceAtOrigin` made the condition trivially true for all normal
triggers, letting the dropdown show at (0,0) before floating-ui resolved
the real position — visible as a left-side flash on the very first open.
- Unify resolveProviderAndModel across CLI and server, supporting
provider/model syntax (e.g. codex/gpt-5.4) for agent runs and schedules
- Add --provider flag to schedule create CLI and both MCP schedule tools
- Add structured debug logging to workspace fetch hydrate, sidebar
refresh, and real-time update paths
- Update orchestrate skill references
Workspace setup may auto-open a setup tab that steals focus, hiding
the agent panel. Click the agent tab to ensure it's active before
checking the composer textbox.
submitAgentInput returned "noop" for empty messages regardless of
allowEmptySubmit, breaking the new-workspace empty-submit flow.
Now the flag is passed from Composer through to submitAgentInput
so empty submissions are allowed when the prop is set.
The unscoped getByRole("button", { name: "Create" }) matched sidebar
"Create a new workspace" buttons from other test suites. Scoping to
message-input-root targets only the Composer submit button.
clickNewWorkspaceButton now completes the full user flow: after
clicking the sidebar button, waits for the /new route, then clicks
the Create button to submit the empty form and trigger workspace
creation + redirect.
assertNewWorkspaceSidebarAndHeader was scanning all sidebar workspace
rows to find the newly created workspace, but would pick up workspaces
from concurrent test suites (e.g. archive-tab). Use the page URL as
source of truth since the app redirects to the new workspace after
creation.
- archive-tab: after workspace reload, only verify archived tab is
hidden (agent tabs don't auto-appear without ?open= intent). Real-time
archive propagation still fully verified before reload.
- new-workspace: use gotoAppShell + sidebar navigation instead of direct
workspace URL navigation. Matches the pattern of passing tests where
the WebSocket connection and workspace hydration complete before
sidebar interaction.
- archive-tab: replace waitForFunction with waitForURL for ?open= intent
consumption, increase test timeout to 300s for slow rootNavigationState
init on CI
- launcher-tab: use Control+t on Linux instead of Meta+t to match app's
keyboard shortcut definitions for non-Mac platforms
- new-workspace: add waitForSidebarHydration to wait for daemon
connection before checking sidebar rows, add re-navigation fallback
if app redirects during hydration, increase timeouts
- setup-streaming: use waitForTerminalContent (xterm buffer API) instead
of fragile .xterm-rows CSS text matching, wait for terminal attachment
before checking content
- Wait for ?open= intent consumption before expecting agent tabs
- Blur composer before second Meta+t to prevent shortcut swallowing
- Use projectDisplayName from daemon instead of computed path label
- Increase setup streaming script terminal timeout for CI
After removing SQLite, workspace IDs are cwd path strings instead of
numeric database IDs. Update type declarations in workspace-setup.ts
and terminal-perf.ts, and remove the numeric parsing in
fetchWorkspaceById that would reject path-based IDs.
The workspace-setup helper was creating DaemonClient without providing a
webSocketFactory, causing failures on Node 20 where globalThis.WebSocket
is not available. Use createNodeWebSocketFactory() like all other e2e
helpers do.
The merge accidentally dropped the authorAgentId from postChatMessage,
causing the CLI chat test to fail since it asserts the sender ID appears
in the output.
describeWorkspaceRecordWithGitData was calling buildProjectPlacement → getCheckoutStatusLite
which spawned 3-4 sequential git child processes per workspace. With 8 active workspaces
and event loop pressure from running agents, this caused fetchWorkspaces to take 35-45s.
Now uses workspaceGitService.getSnapshot() which returns instantly from cache (warm path)
or awaits the git service's own concurrency-managed refresh (cold path).
The "warns when which returns non-absolute path" test exercises
Unix which behavior. On Windows, where.exe path resolution works
differently and the test expectation doesn't apply.
The full server test suite has deep Unix assumptions (hardcoded /tmp
paths, mkdir -p, Unix sockets, bash variable expansion). Rather than
porting the entire suite, run only the tests that exercise
Windows-specific code paths: executable resolution, spawn/exec,
git command handling, provider registry, and config loading.
- Skip worktree test suite on Windows (uses bash shell syntax)
- Skip ~ home-root directory test on Windows
- Make findExecutable mock assertions platform-aware (which vs where.exe)
- Use nonexistent binary name for not-found test case
- Replace single-quoted git commit messages in worktree tests with
double quotes (cmd.exe doesn't treat single quotes as delimiters)
- Trim stdout in spawn tests to handle \r\n vs \n
- Use nonexistent binary name in executable test not-found case
- Replace `mkdir -p` shell calls with `mkdirSync({ recursive: true })`
in worktree tests (cmd.exe doesn't support -p)
- Normalize path assertions in provider-snapshot-manager tests for
Windows drive-letter resolved paths
- Add `:` to Claude history path sanitization so Windows drive letters
(C:\) don't produce invalid directory names
Provider isAvailable() was using executableExists() which only checks
filesystem paths, not PATH. Commands like ["claude", "--flag"] would
show as unavailable even though they'd launch fine. Switch to
isCommandAvailable() which uses findExecutable() for proper PATH
resolution. Un-export executableExists from the public API.
Fix Windows cmd.exe metacharacter escaping — &, |, ^, <, >, (, ), !
are now properly escaped with ^, and % is doubled. Add shared
escapeWindowsCmdValue helper used by both quoteWindowsCommand and
quoteWindowsArgument. Replace local quoteForCmd in spawn.ts.
Add server-tests-windows CI job to catch Windows-specific regressions.
* feat: add provider profiles for custom provider definitions
Users can define custom providers in config.json that appear as first-class
entries alongside built-ins. A provider can override a built-in (custom binary,
env, models) or create a new one by extending a base via `extends`. Generic
ACP transport supported via `extends: "acp"`. Providers can be hidden with
`enabled: false`. Hardcoded models merge with runtime-fetched ones.
- Config schema with Zod validation, auto-migration from old format
- Dynamic provider registry replaces static provider lists
- GenericACPAgentClient for user-defined ACP providers
- Snapshot entries carry label/description/defaultModeId over the wire
- MCP tools accept dynamic provider IDs
- App derives provider definitions from snapshot with static fallback
- CLI `provider ls` calls daemon with label column
- Schedule/session rehydration validates providers against registry
* fix: accept any provider status in CLI provider ls test
The test now connects to a real daemon where providers may be loading
or unavailable, not just the static "available" fallback.
* ci: re-trigger CI checks
* style: fix checkout-git.ts formatting to match CI Biome version
* fix: relax provider ls test assertions for daemon-backed responses
The daemon snapshot may not include all 5 built-in providers in CI
(some require external binaries). Assert at least the core 3
(claude, codex, opencode) instead of all 5.
* fix app combobox dropdown positioning flash
* refactor: stop merging models in provider registry, use override models directly
Override models now replace instead of merge with base provider models.
Also add icon/colorTier fallback from definition modes in fetchModes.
* refactor: make provider definitions fully dynamic from server snapshots
Remove static AGENT_PROVIDER_DEFINITIONS fallbacks from the client — providers,
modes, icons, and color tiers now flow entirely from runtime snapshots. Add icon
and colorTier to AgentMode schema so the server can advertise mode visuals
directly. Fix setAgentMode to persist modeId in agent config so the selected
mode survives session reload. Simplify model merging so profile models replace
runtime models instead of prepending.
* docs: add ad-hoc daemon testing guide
The workspace Stack.Screen was missing a getId callback, causing
expo-router to reuse the same screen instance across workspace
navigations. This left stale params — sidebar switching and new
workspace creation both appeared to navigate but the header stayed
on the old workspace (skeleton for new workspaces).
Adds e2e tests for sidebar workspace switching and new workspace
creation flow (URL, sidebar row, header, single draft tab).
* fix: skip Enter key handling during IME composition
During CJK input (Chinese, Japanese, Korean), pressing Enter while
composing characters should confirm character selection, not send the
message. Check isComposing and keyCode 229 on key events before
handling Enter, matching react-native-web's own IME detection logic.
Fixes#241, #264
* fix: scope IME composition guard to web-only handler
Move isComposing check to top of handleDesktopKeyPress so it never
leaks into cross-platform onKeyPress interfaces. This keeps CJK IME
handling explicit and web-gated.
* refactor: remove IS_WEB alias, use isWeb from platform.ts directly
---------
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
The codebase already has full backend support for "max" effort in types,
validation, SDK passthrough, and error handling. The only missing piece
was the `CLAUDE_THINKING_OPTIONS` array, which didn't surface "max" in
the UI dropdown.
Fixes#271
Co-authored-by: Claude Code <noreply@anthropic.com>
* Add Windows exec foundation helpers
* server: centralize windows exec handling phase 2
* migrate cli daemon status to execCommand
* refactor: complete phase 4 async exec migrations
* test: cover windows exec helpers
* fix: use beforeEach for context.skip in test setup
Add settings.toggle action with Cmd+, (Mac) / Ctrl+, (non-Mac) bindings.
When on a settings route, pressing the shortcut navigates back; otherwise
it pushes to the host settings route. Also adds comma to the shortcut
key map so the combo string parses correctly.
Move createAgentWorktree() from the fire-and-forget background function
into handleCreatePaseoWorktreeRequest so the worktree directory exists
on disk before the client receives the response and navigates. Fixes
ENOENT race when the app queries checkout status on a path that hasn't
been created yet. Setup commands (npm install, etc.) remain background.
The orchestrate skill now keeps the heartbeat alive after PR creation
and monitors CI until all checks pass, fixing failures automatically.
Also requires full PR URLs in all user-facing messages.
The wrapper View with onPointerEnter/onPointerLeave is needed to
keep hover state stable when the mouse moves from the row to the
kebab dropdown (which opens in an overlay outside the Pressable).
Without it, the Pressable fires onHoverOut and the kebab disappears.
On native, isNative makes controls always-visible so this is
web-only behavior which is correct (onPointerEnter works on web).
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.
Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
* Fix uncaught timeout rejection when archiving agents
* fix(server): centralize git subprocesses under runGitCommand with p-limit throttling
Supersedes #299. Same problem (unbounded git subprocess concurrency causing
zombie accumulation), different approach: a single `runGitCommand(args, opts)`
function backed by `p-limit` instead of a custom semaphore pool.
- Always uses `spawn` (one code path, no exec/execFile split)
- Concurrency configurable via PASEO_GIT_CONCURRENCY env var (default 8)
- 30s default timeout for read-only ops, 120s for mutations
- Centralized error formatting, stdout truncation, process reaping
- Runtime tests verifying throttling, timeout kills, deadlock prevention
* fix: update lockfile signatures and Nix hash
* fix(server): add Windows compatibility to runGitCommand
- shell: true on Windows (resolves .cmd/.bat git shims)
- windowsHide: true always (prevents console window flashes)
- quoteForCmd for args with spaces when routing through cmd.exe
- Tests verify shell/windowsHide behavior per platform
* refactor: use spawnProcess instead of duplicating Windows handling
spawnProcess from spawn.ts already handles shell: true on Windows,
arg quoting for cmd.exe, and windowsHide: true. No need to reimplement.
* fix: non-null assertions for stdio streams in strict build
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
xterm.js sends plain \r for Enter regardless of modifiers. Intercept
modified Enter in the custom key handler so it routes through CSI u
encoding (Kitty keyboard protocol), which Claude Code uses for
Shift+Enter newlines.
On Windows, spawnProcess defaults to shell: true which routes through cmd.exe.
The SDK passes --mcp-config with inline JSON containing double quotes that
cmd.exe strips, producing an invalid string that Claude Code interprets as a
file path (e.g. D:\xFlow{mcpServers:{paseo:{type:http,url:http:\...}).
* Align create_agent naming and resolve defaults server-side
* Fix test assertions for default model/mode resolution
- Use config.model and config.modeId instead of non-existent snapshot.model
- Strip legacy "default" model ID in normalizeConfig
- Update runtimeInfo assertion to expect resolved default model
* Fix CI: update CLI test for --title, fix formatting in checkout-git
Add missing lastEmittedByWorkspaceId to test subscription stubs,
update activityAt assertion to null, and adjust dedup test to expect
deduplicated emissions.
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.
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.
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.
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.
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.
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.
* 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>
Electron 41 embeds Node 24 (ABI 145) while system Node 22 (ABI 127)
produces incompatible native modules. Add electron-rebuild step to the
desktop build pipeline so better-sqlite3 is recompiled against
Electron's headers before packaging.
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.
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.
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.
- Fire GitHub search query on mount instead of waiting for 2+ chars
- Add staleTime so combobox reuses cached results on open/close
- Single-select: replace multi-item chips with a transforming trigger
- Trigger shows issue/PR icon, truncated title, and ExternalLink icon
- ExternalLink opens GitHub URL via openExternalUrl (desktop-aware)
- Add ExternalLink trailingSlot on each combobox option row
- Hover on ExternalLink changes color from foregroundMuted to foreground
- Constrain badge width (maxWidth: 240) with flexShrink on text
Use Globe for services and SquareTerminal for scripts instead of
plain status dots. Play icon for the Scripts section title. Running
status uses blue instead of green. Scripts button moved before Open
in Editor in the workspace header.
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).
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.
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.
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.
* 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>
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.
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.
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.
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.
Trace-level logging emits two entries per streaming token per session,
causing significant memory growth and GC pressure under multi-workspace
use. Fixes#227.
- Persist snapshot before emitting closed agent to avoid data loss
- Flush after branch rename so state hits disk immediately
- Filter phantom whitespace-only files from ignore-whitespace diffs
- Only include ignoreWhitespace param when true to keep payloads clean
- Add createFeature test helper for AgentFeature construction
Adds ci.yml covering: biome format check, typecheck across all packages,
server tests (unit + integration), app unit tests, Playwright E2E,
relay tests, and CLI tests. Each job runs independently in parallel.
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.
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.
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.
* 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>
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.
- 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
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.
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.
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.
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.
- 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
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.
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.
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.
* 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>
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.
- 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
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.
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.
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.
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.
- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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
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.
* 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.
* 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.
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.
- 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
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.
- Add service-route-branch-handler for routing requests to branch-specific services
- Add service-hostname utility for generating hostnames from branch names
- Redesign workspace hover card with CI check status display
- Update combined model selector and sidebar workspace list
- Improve agent form state and input draft hooks
- Update checkout-git with enhanced branch handling
- Add workspace git watch and bootstrap improvements
Skip the confirmation dialog when archiving idle agents — archive
immediately. Show a warning that the agent will be stopped only when
it is running or initializing.
Extend the existing gh pr view call with statusCheckRollup and
reviewDecision fields so check data arrives in a single request.
The workspace row now shows an aggregate check icon (green checkmark,
red X, or amber dot) next to the PR badge, and the hover card lists
each individual check with its status and a link to details.
Numeric string IDs like "164" are valid base64 that decodes to garbage
(Hebrew character ""), causing silent workspace lookup failures.
Skip base64 encoding/decoding for numeric IDs and only use it for
legacy path-based workspace identifiers.
getCurrentPathname used window.location.pathname instead of Expo Router's
pathname, which returns a file path on Electron — blocking navigation from
the index route. Also gate the bootstrap progress UI to desktop-only since
web has no local server to start/connect.
- Change workspace id/projectId from z.number() to z.union([z.string(), z.number()]).transform(String) for backward compat
- Add "non_git" to projectKind, "local_checkout" to workspaceKind enums
- Session converts numeric IDs to strings at the descriptor boundary
- Update tests and daemon-client event types to match
- Archive agents optimistically on close with rollback on error
- Close tabs immediately instead of waiting for RPC response
- Kill terminals in background with cache invalidation on failure
- Bulk close fires RPC in background, closes all tabs upfront
- Move new-tab button out of scroll area, rename to "New agent tab"
- Support invalidateQueries option in applyArchivedAgentCloseResults
- Fix workspace descriptor id/projectId types (string not number)
- Replace workspace setup dialog with full-screen new workspace route
- Restyle Combobox SearchInput to match CombinedModelSelector Level 2 design:
borderless sticky search bar above scroll area instead of inline bordered box
- CombinedModelSelector now uses shared SearchInput, removing duplicate ProviderSearchInput
- Fix TooltipTrigger children type to accept render functions (remove PropsWithChildren wrapper)
- Fix empty rightControls View causing gap between dictation and send buttons
- Add branch picker with searchable Combobox and GitBranch icons
- Muted icon buttons (attachment, dictation, voice mode) that brighten on hover
The reconciliation service was skipping worktree workspaces when
updating displayName from the current git branch, causing the sidebar
to show the initial random animal name instead of the actual branch.
- DbProjectRegistry/DbWorkspaceRegistry: use ON CONFLICT(directory) instead
of ON CONFLICT(id) so inserts and upserts handle duplicate directories
gracefully instead of crashing
- Bootstrap: wrap legacy imports in try/catch (non-fatal), move
reconciliation start after imports so first pass cleans up stale data
- Legacy project/workspace import: deduplicate by rootPath (prefer git
over non_git), derive gitRemote from legacy projectId field
- Legacy agent snapshot import: detect git metadata and group agents by
remote/toplevel instead of creating one project per cwd, clamp
running/initializing statuses to closed
- Timeline hydration: set historyPrimed based on whether durable store
has rows (not just whether it exists), remove hard gate so imported
agents get their timelines populated lazily from provider history
- Durable timeline append: use bulkInsert with pre-assigned seq instead
of appendCommitted which recalculates seq, fixing UNIQUE constraint
failures during concurrent hydration writes
Brings in 8 main commits (0.1.48 release, provider overhaul, keyboard
shortcuts, desktop login shell env, question form Enter key) with proper
git history. Resolves conflicts by keeping dev branch architecture where
it subsumes main's changes, and main's provider snapshot COMPAT comments.
Restores all features dropped when merging main into dev:
- Keyboard shortcuts (send, dictation-confirm) with full e2e chain
- Feature toggles (plan/fast mode) in agent status bar
- Sidebar kebab menu (Remove Project) on project rows
- Combined model selector (sticky header, sizing, favorites sort, search)
- Question form Enter key submit with guard
- Input focus restoration (composer + AgentStatusBar onDropdownClose)
- Draft-agent feature toggles and focus restoration
- Provider diagnostics UI (settings section, diagnostic sheet, status badge)
- Provider diagnostics server (snapshot manager, diagnostic utils, RPC)
Fixes dev-branch regressions:
- New workspace button now opens composer in setup dialog via beginWorkspaceSetup
- Setup tab auto-opens when workspace setup is running
- 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
- 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)
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
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.
Migration hardening:
- Back up JSON to $PASEO_HOME/backup/pre-migration/ before import
- Deduplicate projects/workspaces by path before insert
- Log per-batch progress during agent snapshot import
- Clear error messages for corrupt JSON files
- 5 new test cases for backup, dedup, progress, and error clarity
Post-merge fixes:
- Add worktreeRoot to session checkout result (type error fix)
- Port reload agent tab action from main
- Remove deleted useDelayedHistoryRefreshToast hook usage
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.
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.
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.
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.
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.
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.
- 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
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.
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.
* 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>
* 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).
* 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
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.
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.
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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" }.
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.
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.
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.
* 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>
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>
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.
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
Reuse existing project when a matching git remote is found instead of
creating duplicates. Detect git worktrees via --git-common-dir and set
workspace kind accordingly.
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.
Remove the "agent can be a terminal" branching from the entire codebase.
An agent is now always a session-backed chat agent. Standalone terminal
infrastructure (terminal component, ANSI handling, terminal-stream-protocol)
is preserved.
Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails,
launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited,
supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from
all providers, terminal agent persistence/projections.
App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts,
terminal/terminalExit fields on agent state, "Terminal Agents" launcher
section, terminal-agent workspace setup flow, terminal badge in agent list.
CLI: remove terminal column from ls, terminal-agent error from send.
60 files changed, -3592 lines
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
- Align portless code with storage branch's numeric workspace IDs and new field names
- Update workspace kind comparisons (local_checkout → checkout/worktree)
- Add missing services, supportsTerminalMode, terminal fields to test fixtures
- Fix archive timestamp assertions to match dynamic archiveSnapshot flow
- Fix dictation, voice runtime, and service health monitor test timing
- Add xterm-addon-ligatures type declaration for terminal emulator
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.
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.
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.
`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.
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.
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.
- 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
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.
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.
* 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
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>
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>
Built-in service proxy with branch-based URLs, service health
monitoring, workspace hover card with service status, and
"Forget about ports" homepage section.
* 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.
Services defined in paseo.json get reverse-proxied through the daemon
via hostname-based routing on *.localhost. Each service receives $PORT,
$HOST, and $PASEO_SERVICE_URL env vars, and is accessible at
{service}.localhost:6767 (main) or {branch}.{service}.localhost:6767
(worktrees).
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.
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.
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.
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.
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.
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.
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.
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)
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
user-invocable: true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
description:Something is broken or doesn't behave the way it should.
title:"bug: "
labels:["bug"]
body:
- type:markdown
attributes:
value:|
I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me.
Before opening, please:
- search existing issues for the same symptom
- try to reproduce on the latest version
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
- type:textarea
id:description
attributes:
label:What's broken
description:What happened, and what did you expect to happen instead?
placeholder:|
I tried to X, expected Y, got Z.
validations:
required:true
- type:textarea
id:repro
attributes:
label:Steps to reproduce
description:The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
placeholder:|
1. Open the desktop app
2. Pair a daemon
3. Click X
4. ...
validations:
required:true
- type:dropdown
id:surface
attributes:
label:Where did this happen
description:The surface you saw the bug on. Pick the closest match.
options:
- iOS app
- Android app
- Web (browser)
- Desktop (Electron)
- CLI
- Daemon
- Other
validations:
required:true
- type:input
id:paseo-version
attributes:
label:Paseo version
description:Settings → About in the app, or `paseo --version` from the CLI.
placeholder:"0.1.71"
validations:
required:true
- type:input
id:os-version
attributes:
label:OS version
description:Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
placeholder:"macOS 15.2, Windows 11, Ubuntu 24.04"
- type:dropdown
id:provider
attributes:
label:Agent provider
description:If the bug involves a specific agent provider, pick which one.
options:
- Not relevant
- Claude Code
- Codex
- OpenCode
- Custom provider
- type:textarea
id:provider-details
attributes:
label:Provider configuration
description:|
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
placeholder:|
Claude Code v1.2.3 with Anthropic API
Codex CLI v0.5.0 with OpenAI API
OpenCode v0.3.1
(or paste the relevant section of ~/.paseo/config.json for custom providers)
- type:textarea
id:logs
attributes:
label:Logs
description:|
Paste relevant log output. Strongly preferred for crashes and daemon issues.
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
render:text
- type:textarea
id:screenshots
attributes:
label:Screenshots or video
description:|
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
- type:markdown
attributes:
value:|
---
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.
description:Propose a new feature or a change to existing behavior.
title:"feat: "
labels:["enhancement"]
body:
- type:markdown
attributes:
value:|
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
- type:checkboxes
id:prior-search
attributes:
label:Prior search
options:
- label:I searched existing issues and discussions, and this isn't already proposed.
required:true
- type:textarea
id:problem
attributes:
label:What's the problem
description:What are you actually trying to do, and why is the current behavior in the way?
placeholder:|
When I'm doing X, I want to Y, but Paseo currently Z.
validations:
required:true
- type:textarea
id:proposal
attributes:
label:What would solve it
description:A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
validations:
required:true
- type:textarea
id:alternatives
attributes:
label:Alternatives you considered
description:Optional. What else did you try, and why doesn't it work?
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
-->
### Linked issue
Closes #
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
### How did you verify it
<!--
This is the section I read most carefully. I need to see that*you* tested this, not that the diff looks plausible.
-For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
-For behavior changes: the actual steps you ran, and what you observed.
-For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
AI-generated PR descriptions are fine in principle. AI-generated*verification claims* with no actual testing behind them are not, and they're easy to spot.
-->
### Checklist
- [ ] One focused change. Unrelated cleanups split out.
- [ ]`npm run typecheck` passes
- [ ]`npm run lint` passes
- [ ]`npm run format` ran (Biome)
- [ ] UI changes include screenshots or video for every affected platform
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.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
## Repository map
@@ -15,38 +15,138 @@ This is an npm workspace monorepo:
-`packages/desktop` — Electron desktop wrapper
-`packages/website` — Marketing site (paseo.sh)
## Documentation
## Docs
| 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/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
npm run dev:app # Start Expo against the dev daemon
npm run dev:desktop # Start Electron desktop dev
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 lint # Always run after changes
npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER 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.**
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
-`npm run build:client` — rebuild protocol and client declarations.
-`npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
-`npm run lint -- packages/app/src/components/message.tsx`
-`npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- Never flip optional → required, remove fields, or narrow types (`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?"
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
## Platform gating
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
### Rules
- **Default is cross-platform.** Don't gate unless you have a specific reason.
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
```
hooks/
use-audio-recorder.web.ts ← uses Web Audio API
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
## Debugging
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
Paseo is an opinionated product maintained by one person.
I read every issue and PR myself, and I am selective about what contributions I accept.
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
Core product, design, architecture, and workflow changes are not accepted.
Follow these rules if you want your PR to be merged:
- Keep it to one focused change
- Link to an issue
- Explain the problem you're solving
- Include repro steps if it's a bug
- Include QA/testing evidence
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
- If you only tested one platform, say that clearly
Your PR will be closed if you do any of these:
- Bundle unrelated changes
- Fail basic checks like typecheck, formatting or linting
- Make product, design, or architecture changes without prior discussion
- Submit no evidence of testing
- Skip the linked issue
- Clearly fully AI-generated PR
## AI assistance
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
@@ -75,9 +105,9 @@ paseo --host workstation.local:6767 run "run the full test suite"
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
## Skills
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.
Skills teach your agent to use Paseo to orchestrate other agents.
<imgsrc="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date"alt="Star history chart for getpaseo/paseo"width="600"style="max-width: 100%;">
<imgsrc="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date"alt="Star history chart for getpaseo/paseo"width="600"style="max-width: 100%;">
@@ -19,31 +19,46 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
### How it works
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.
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2.The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3.When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
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.
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
### Why the relay can't attack you
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
- **Replay old messages** — Each session derives fresh encryption keys
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **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`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
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).
Paseo uses a host allowlist to validate the`Host` header on incoming requests. Requests with unrecognized hosts are rejected.
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via`hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
Paseo 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.
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.
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
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
- 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:
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.
All workspaces share one version and release together.
## Standard release (patch)
```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.
## Manual step-by-step
```bash
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
```
-`draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
-`release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
## Fixing a failed release build
**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.
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
```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
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
-`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 parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ]`npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
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()=>{
constresult=awaitcreateToolCall(input);
if(result.ok){
expect(result.id).toBeDefined();
}
});
// Good: deterministic and explicit
it("returns timeout error when provider times out",async()=>{
constresult=awaitcreateToolCall(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:
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.
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
Always pass `appVersion`:
```typescript
constclient=newDaemonClient({
url:`ws://127.0.0.1:${port}/ws`,
appVersion:"0.1.70",
});
```
### 2. Provider snapshots are async
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
### 3. fetchAgents is required before most operations
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
### 4. listen: "127.0.0.1:0" for port allocation
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
### 5. Script must live inside packages/server
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
### 6. Cleanup on failure
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).
└────────┘ (agent completes a turn, awaits next prompt)
```
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
3. Notify subscribers
4. Close the runtime (kills the process if still running)
5.**Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
## Workspace activity
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
## The subagents track
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
```
parentAgentId === thisAgent.id AND !archivedAt
```
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
## Why this shape
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
## Limitations
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
### Cross-client tab dismissal
Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer``19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
## Screenshots
```bash
adb exec-out screencap -p > screenshot.png
```
## Cloud build + submit (EAS)
Stable tag pushes like `v0.1.0` trigger:
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
-`.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
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.
-`HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
-`SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
### `packages/cli` — Command-line client
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
- 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
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
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
**Multi-window (hybrid land-on model).**`createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents``PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
>
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
Server → Client: status message with payload { status: "server_info",
serverId, hostname, version, capabilities?, features }
```
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
-`agent_update` — Agent state changed (status, title, labels)
-`agent_stream` — New timeline event from a running agent
-`workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
### Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
-`Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
Example: adding a new enum value
```ts
// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
```
initializing → idle ⇄ running
↓ ↓ ↓
error
↓
closed
```
-`initializing` — provider session is being created
-`idle` — has a live session, awaiting the next prompt
-`running` — provider is currently producing a turn
-`error` — last attempt failed; session is still attached
-`closed` — terminal state, no live session
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
## Agent providers
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision.
For testing rules, see [testing.md](testing.md).
## Core principles
- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit.
- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction.
- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff.
- **Functional and declarative** over object-oriented.
- **`function` declarations** over arrow function assignments.
- **`interface`** over `type` when both work.
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
## Comments and noise
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
- No tutorial comments explaining language features (`// Use destructuring to...`).
- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art.
- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate.
- No commented-out code. Git remembers.
- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it.
## Confidence: commit to a shape
- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says.
- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality.
- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter).
- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch.
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries.
## Types
- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less.
- If a Zod schema exists, the TypeScript type is `z.infer<typeof schema>`. Never hand-write a parallel type.
- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields.
- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args.
- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time.
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site.
- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags.
## Errors
- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string.
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
## Density
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`).
- Boolean expressions with 2+ clauses or mixed concerns → name the conditions.
- Object literals assemble pre-computed values; don't pack branching and lookups into property positions.
- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates.
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
## Structure and modules
- A directory is a module, not a namespace. One intentional public surface; internal files stay internal.
- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path.
- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain.
- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere).
- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it.
- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it.
- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case.
- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace.
- Don't drop new files at the nearest root just because placement is unclear — say so and ask.
## Refactoring is a bolt-on test
- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead.
- Refactors preserve behavior by default. No removing features to simplify code without explicit approval.
- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md).
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
## React
-`useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`.
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
-`useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union.
- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse.
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
- Never define components inside other components. Module-scope only.
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
- Context for stable values (theme, auth). Store with selectors for state that changes.
## Naming
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`.
- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape.
- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong.
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
All provider configuration lives under `agents.providers` in config.json:
```json
{
"version":1,
"agents":{
"providers":{
"provider-id":{...}
}
}
}
```
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
---
## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider)
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
-`extends` — which built-in provider to inherit from (or `"acp"`)
-`label` — display name in the UI
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
---
## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
### Setup
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
-`ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Automated setup is also available: `npx @z_ai/coding-helper`
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
---
## Alibaba Cloud (Qwen) coding plan
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
### Setup
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
2. Subscribe to the Pro plan ($50/month)
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
- API keys must be created in the **Singapore region**
- The coding plan is for personal use only in interactive coding tools
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
---
## Codex with a custom OpenAI-compatible endpoint
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
### Setup
```json
{
"agents":{
"providers":{
"my-codex":{
"extends":"codex",
"label":"My Codex",
"description":"Codex via custom OpenAI-compatible endpoint",
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
```toml
model_provider="my-codex"
[model_providers.my-codex]
name="My Codex"
base_url="https://custom-relay.example.com/v1"
wire_api="responses"
env_key="OPENAI_API_KEY"
requires_openai_auth=false
```
-`base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
-`wire_api` — always `"responses"` (OpenAI Responses API protocol).
-`env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
-`requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
### Notes
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
---
## Multiple profiles for the same provider
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
Example: two different Anthropic accounts as separate profiles:
```json
{
"agents":{
"providers":{
"claude-work":{
"extends":"claude",
"label":"Claude (Work)",
"description":"Work Anthropic account",
"env":{
"ANTHROPIC_API_KEY":"sk-ant-work-..."
}
},
"claude-personal":{
"extends":"claude",
"label":"Claude (Personal)",
"description":"Personal Anthropic account",
"env":{
"ANTHROPIC_API_KEY":"sk-ant-personal-..."
}
}
}
}
}
```
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
You can also combine profiles with model overrides to pin specific models per profile:
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
```json
{
"agents":{
"providers":{
"omp":{"enabled":true}
}
}
}
```
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
{
"agents":{
"providers":{
"my-pi-fork":{
"extends":"pi",
"label":"My Pi Fork",
"command":["my-pi-fork"],
"params":{
"sessionDir":"~/.my-pi-fork/sessions"
}
}
}
}
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---
## Disabling a provider
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
```json
{
"agents":{
"providers":{
"copilot":{"enabled":false},
"codex":{"enabled":false}
}
}
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
---
## ACP providers
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:
```json
{
"agents":{
"providers":{
"my-agent":{
"extends":"acp",
"label":"My Agent",
"command":["my-agent-binary","--acp"],
"env":{
"MY_API_KEY":"..."
}
}
}
}
}
```
Required fields for ACP providers:
-`extends: "acp"`
-`label`
-`command` — the command to spawn the agent process (must support ACP over stdio)
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{
"agents":{
"providers":{
"my-agent":{
"extends":"acp",
"label":"My Agent",
"command":["my-agent","acp"],
"params":{
"supportsMcpServers":false
}
}
}
}
}
```
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
### Example: Google Gemini CLI
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
2. Authenticate with Google (Gemini CLI handles its own auth)
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
1. Paseo spawns the process using the configured `command`
2. Sends an `initialize` JSON-RPC request over stdin
3. The agent responds with its capabilities, available modes, and models
4. Paseo creates a session and sends prompts through the ACP protocol
5. The agent streams responses, tool calls, and permission requests back over stdout
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
Use `disallowedTools` to disable unsupported tools:
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
| `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 `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
| `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, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
### Nested: ScheduleTarget (discriminated union on `type`)
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
---
## 1. Character
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
---
## 2. Component reuse
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
---
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
---
## 4. Buttons
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]``xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
-`fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
-`<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
-`onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
- Node.js (see `.tool-versions` for exact version)
- npm workspaces (comes with Node)
## Running the dev server
```bash
npm run dev:server
npm run dev:app
npm run dev:desktop
```
Root checkout dev is intentionally split across terminals:
-`npm run dev:server` runs the daemon on `127.0.0.1:6768`.
-`npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
-`npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
```bash
PASEO_HOME=~/.paseo-blue npm run dev # explicit home
PASEO_DEV_SEED_HOME=/path/to/home npm run dev # seed from a different source home
PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived worktree home
```
### Daemon endpoints
- Stable daemon launched by the desktop app: `localhost:6767`.
- Root checkout dev daemon: `localhost:6768`.
- Root checkout Expo: `http://localhost:8081`.
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
-`npm run dev` (Windows): `localhost:6767` for the daemon.
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
The app has a gated React render profiler in
`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want
to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When
the query param is absent, `RenderProfile` returns children directly and records
nothing.
Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call
`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the
interaction you want to measure. If a memo comparator or subscription boundary
needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling;
reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`.
Use this workflow for any render investigation:
1. Add stable `RenderProfile` boundaries around the suspected root and expensive
children. Keep IDs specific enough to compare before and after.
2. Reproduce against real app state, not toy fixtures, whenever practical.
3. Record an idle baseline first. If idle is noisy, fix or account for that
before optimizing the interaction.
4. Warm up the route, reset profiler samples, run the exact interaction, then
compare `actualDuration`, render counts, and per-commit samples.
5. When a memo boundary still renders, record reasons before changing code. Do
not guess from object identity alone.
6. Keep changes that move the measured profile. Remove probes or memo wrappers
that do not move the number.
What this caught during the workspace tab investigation:
- A large apparent workspace cost was real interaction work, not daemon noise;
the idle baseline stayed near zero.
- The expensive stream rerender was mostly prop identity churn from pane context
callbacks and capability objects, not new stream data.
- Stabilizing provider actions at the pane boundary helped because every mounted
panel consumes that context.
- Comparing value-shaped capability flags beat preserving object identity through
unrelated stores.
- Some plausible fixes did not pay off: memoizing the tab row and composer draft
object barely moved the profile, so they were removed.
Existing scenario script: workspace agent/terminal tab switching. Start Expo on
web, keep a daemon available, then run:
```bash
PASEO_PROFILE_SERVER_ID=<server-id> \
PASEO_PROFILE_WORKSPACE_ID=<workspace-path> \
PASEO_PROFILE_AGENT_ID=<agent-id> \
npm run profile:workspace-tabs --workspace=@getpaseo/app
```
This script opens the app with `?renderProfile=1`, creates a temporary terminal
tab, switches between a real agent and that terminal, prints aggregated React
Profiler timings, then removes the temporary terminal. It is an example of the
workflow above, not the only way to use the profiler. Useful knobs:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL
PASEO_PROFILE_SWITCH_COUNT=1# number of agent/terminal switch pairs
PASEO_PROFILE_SWITCH_WAIT_MS=250# delay after each click
PASEO_PROFILE_IDLE_WAIT_MS=3000# idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1# include per-commit profiler samples
```
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
source that drives frame production — stuck on a stale display. The compositor
then stops producing frames and the window looks frozen: unresponsive to clicks
and keys even though the renderer and every process stay alive. It self-recovers
after a few minutes, which is too long for a foreground app.
`setupDarwinCompositorWatchdog`
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
this. It polls the renderer for frame production every couple of seconds and,
after a sustained stall while the window is visible and unlocked, restarts the
GPU process so Chromium rebuilds the display link. The probe is skipped while
the screen is locked or the window is hidden or minimized, since a window
legitimately stops producing frames then.
The watchdog deliberately leaves background throttling **enabled**. Calling
`webContents.setBackgroundThrottling(false)` would keep the compositor producing
frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
```json
{
"worktree":{
"setup":"npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"teardown":"npm run db:drop || true"
}
}
```
Every `scripts` entry with `"type": "service"` receives these environment variables:
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
```json
{
"scripts":{
"web":{
"type":"service",
"command":"PORT=$PASEO_PORT npm run dev:web"
}
}
}
```
## Built workspace packages
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
and `uvx`) to exact package versions. Run the drift checker regularly — and
before releases — so catalog installs do not sit on stale agent versions:
```bash
npm run acp:version-drift # report stale/non-exact package pins
npm run acp:version-drift:check # same, exits non-zero on drift
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
```
The checker updates only package-runner catalog entries. Providers that use a
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
are reported as skipped because their versions are owned by the user's local
install.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
```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:
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
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.
## App web deploys
`packages/app` exports a single-page Expo web app and deploys the `dist/`
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
from `packages/app/public/index.html`. Keep the install icons in `public/` so
Cloudflare serves them from stable root URLs after `expo export`.
Do not add service-worker caching casually. Paseo is a live control surface for
agents, and an aggressive service worker can strand installed users on stale web
code. If offline behavior becomes a product requirement, add it deliberately
with an update strategy and test the installed-app upgrade path.
## Expo troubleshooting
```bash
npx expo-doctor
```
Diagnoses version mismatches and native module issues.
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`).
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
-`WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
## The pattern
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
```tsx
//
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
// │
<View
style={styles.workspaceRowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable// ┐ Separate inner Pressable.
onPress={handlePress}// │ Handles press only.
onPressIn={...}// │ Never has onHoverIn/onHoverOut.
onPressOut={...}// ┘
style={workspaceRowStyle}
>
<Viewstyle={styles.workspaceRowMain}>
<Viewstyle={styles.workspaceRowLeft}>…</View>
<WorkspaceRowRightGroupisHovered={isHovered}/>
{/* └─ Reveals content based on hover state. */}
</View>
</Pressable>
</View>
```
Five things make this work. Every one of them matters.
1.**Hover lives on a plain `View`, not a `Pressable`.**`Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
2.**Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
3.**`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
4.**The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
5.**The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
That's the whole pattern. Internalize it.
## When you skip the pattern, here is what breaks
### Failure mode 1 — Nested Pressables fight over hover state
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
### Failure mode 2 — The hovered state changes the trigger's geometry
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
Common variants:
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
- Hover state swaps the trigger for a different element type, remounting it.
Fixes, in preferred order:
1.**Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
2.**Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
3.**Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
```tsx
constshowControls=isHovered||isNative||isCompact;
```
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
## What about `Pressable.onHoverIn` / `onHoverOut`?
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
## Real gaps with floating panels
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
## Pre-PR checklist
Before opening a PR that touches hover:
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
Paseo client UI translations live in `packages/app/src/i18n`.
## Supported Locales
-`en`
-`ar`
-`es`
-`fr`
-`ru`
-`zh-CN`
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
## Translation Scope
Translate client-owned UI copy: labels, buttons, empty states, confirmation text, and local status/error wrappers.
Do not translate agent output, daemon output, terminal contents, file paths, provider names, model names, command names, user-authored text, code blocks, logs, or raw protocol/server error text.
## Adding Copy
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
Keep resource keys grouped by product surface, not component mechanics.
Run:
```bash
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
```
The parity test catches missing keys between English and Simplified Chinese resources.
## Migration Order
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
1. App shell and shared UI chrome: common actions, headers, sheets, command center, and client-owned toast status.
2. Composer and agent workflow: composer input, agent controls, permission prompts, plan approval, and agent panel wrapper states.
4. Workspace and panels: setup/file/browser/terminal wrapper copy, file explorer local states, import-session flows, and remaining local toast/error wrapper text.
Within a migrated surface, do not leave mixed-language neighboring labels when those labels are owned by the client. Move the whole local copy cluster together.
### Progress
- Batch 2 migrated Composer and agent workflow chrome: Composer input and attachments, agent controls, stream permission prompts, agent panel wrapper states, and draft panel descriptors. Provider/model names, provider-defined option labels, agent output, and protocol/server diagnostics remain untranslated.
- Batch 3A migrated Settings Diagnostics/About, Appearance, Shortcuts, Integrations, and Desktop Permissions chrome. Host settings and Project settings remain for Batch 3B; raw runtime status/error details remain untranslated.
- Batch 3B migrated Host settings, Provider diagnostics, and Project settings chrome. Provider/model names, project/host labels, script commands, diagnostic output, file paths, and raw runtime/server error details remain untranslated.
- Batch 4A migrated workspace wrapper chrome for import sessions, file explorer, setup, browser, terminal, and file panels. File paths, URLs, commands, logs, terminal output, provider labels, and raw runtime/server errors remain untranslated.
- Batch 4E migrated onboarding welcome chrome, direct-connection modal fields/actions/local failure guidance, QR scan permission/unavailable states, and desktop pair-device card. Pairing URLs, host/port placeholders, endpoint values, transport details, protocol parser errors, and raw runtime/server errors remain untranslated.
- Batch 4F migrated realtime voice overlay accessibility labels, rewind menu chrome and fallback toast, DiffViewer default empty state, and service URL chooser copy. Shortcut key names, raw daemon errors, diff contents, URLs, and caller-provided override labels remain untranslated.
- Batch 4G migrated the keyboard shortcuts help dialog to render section titles, row labels, and row notes through translation keys. Shortcut key names, shortcut combos, binding IDs, action IDs, and fallback registry labels remain untranslated.
- Batch 4H migrated Sessions screen chrome and AgentList local UI copy: date section headers, local status labels, fallback session titles, badges, load-more/empty states, and the archive action sheet. Agent titles, project paths, provider icons/labels, host labels, relative timestamps, and raw runtime data remain untranslated.
- Batch 4I migrated message utility chrome: image lightbox labels/errors, code and turn copy accessibility labels, dictation controls, question form fallback placeholders/actions, PlanCard fallback title, assistant image fallback errors, and todo list labels. Message bodies, plan text, todo item text, attachment labels, runtime dictation errors, and agent/tool output remain untranslated.
- Batch 4M migrated hook/modal utility chrome: image attachment permissions/dialog/errors, copied toast wrappers, rename modal local validation/fallback errors, branch switcher stash/switch prompts and fallback toasts, and workspace setup local fallback errors. Copied labels supplied by callers, branch names, selected paths, raw dialog/API errors, and raw server errors remain untranslated.
- Batch 4N migrated pure view-model/policy utility chrome: import-session fallback titles/previews/empty states and the worktree setup callout. Provider labels and IDs remain runtime values interpolated into translated wrappers.
- Batch 4O migrated remaining small utility chrome: workspace route gate states/actions, compaction markers, archived-agent callout, web browser fallback, desktop quitting overlay, and image drop overlay. Host names, host status values, browser IDs, token counts, and raw route errors remain runtime values.
- Batch 4P migrated provider-selection pure view-model utility copy to direct `i18n.t(...)` calls and removed the local labels parameter path. Provider/model labels, provider IDs, and provider snapshot error messages remain runtime values.
- Batch 4Q migrated desktop update utility chrome: app update status text, update callout titles/actions, generic update errors, and install-error wrappers. Version labels, installer messages, raw update errors, release-channel data, and logs remain runtime values.
- Batch 4R migrated desktop permission utility chrome: permission status details, permission request fallback errors, empty permission statuses, and desktop notification test wrappers. Browser permission states, exception names, and raw browser API error messages remain runtime values.
- Batch 4S migrated desktop daemon settings chrome: built-in daemon status rows, daemon lifecycle toggles, logs/status modals, clipboard alerts, daemon management confirmations/errors, daemon status load errors, and desktop CLI/skills install wrapper errors. PIDs, log paths, log contents, CLI status output, version values, and raw IPC errors remain runtime values.
- Batch 4T migrated remaining attachment/autocomplete utility chrome: user and composer review attachment labels, workspace hover-card accessibility, branch stash restore prompts/toasts, agent autocomplete loading/empty/fallback error text, older-history fallback toast, draft panel labels, and agent-control fallback labels. PR/issue numbers, browser element tags, branch names, provider labels, model labels, agent prompts, command/file names, and raw server errors remain runtime values.
- Batch 4U migrated shared default utility chrome: Combobox and Autocomplete default placeholders/empty/loading labels, drag-overlay and subagent-track loading labels, sub-agent activity fallback headers, and file-preview fallback errors. Caller-provided labels, tab titles, subagent descriptions, file paths, and raw file-load errors remain runtime values.
- Batch 4V migrated Git policy action chrome to direct `i18n.t(...)` calls: commit/pull/push/sync/PR/merge/auto-merge/archive action labels, pending/success labels, and unavailable reasons. Branch/base refs, PR URLs, GitHub merge-state enum values, runtime statuses, and raw Git/GitHub errors remain runtime values.
- Batch 4W migrated remaining local wrapper states: workspace copy unavailable toasts, startup daemon-log loading/empty/load-failed text, and file-explorer workspace/host unavailable fallbacks. Workspace paths, branch names, daemon log contents/paths, checkout query details, and raw file/daemon errors remain runtime values.
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
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.
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
### Reach the composer
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
```yaml
appId:sh.paseo
---
- runFlow:flows/land-in-chat.yaml
# ...your scenario here, starting from a ready composer
```
See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### New Workspace Creation
The Android workspace-creation regression has a dedicated harness:
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
-`android-dev-client.yaml`
-`connect-direct-if-welcome.yaml`
-`open-prepared-project-sidebar.yaml`
-`new-workspace-open-from-sidebar.yaml`
-`new-workspace-select-codex-gpt54.yaml`
-`new-workspace-submit-and-assert-created.yaml`
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled**`TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
See `pair-link-modal.tsx` for the pattern (`useRef`-backed `onChangeText`, no `value=` prop). Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
### Dropdowns that launch native presenters (iOS)
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
## 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:
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()`:
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
-`packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
-`packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
-`packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
-`packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
-`packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
-`packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
-`npm run typecheck`
-`npm run lint`
-`git diff --check`
-`npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
-`npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
-`npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`
@@ -30,6 +30,7 @@ Each project opens as a workspace. For git projects, the default workspace is th
### 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
@@ -39,6 +40,7 @@ A workspace is a flexible canvas:
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
@@ -65,10 +67,14 @@ Anyone who builds software:
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)
## Current state (May 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
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` from `packages/server/src/server/agent/providers/acp-agent.ts`. 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.
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
---
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
The factory is invoked with `(logger, runtimeSettings, options)`; `options.workspaceGitService` is also available if you need it (see the `codex` factory for an example). The registry already passes the per-provider runtime settings slice through, so you don't index into the map yourself.
### 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`):
Then register it in `packages/app/src/components/provider-icons.ts` by adding an entry to the existing `PROVIDER_ICONS` map (which already covers the built-in providers):
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.
Each domain becomes a controller class in its own file with the **exact** contract the repo already proved twice (`TerminalSessionController` at `packages/server/src/terminal/terminal-session-controller.ts`, and `CreateAgentLifecycleDispatch`):
- An **options-bag constructor** injecting only what that domain reads.
- An **owned-type `ReadonlySet`** of message types.
- A **NON-async `dispatch(msg): Promise<void> | undefined`** that checks the owned-type set FIRST and returns `undefined` synchronously on a miss (verified at terminal-session-controller.ts:140-143).
-`start()` wired from `subscribeToOptionalManagers`, `dispose()` called by the shell's ordered `cleanup()`.
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered**`cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined`**Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
| 5 | WorkspaceController | XL | high |
| 6 | Voice prereqs: emit() purity + abortController ownership | M | high |
| 7 | VoiceSessionController | XL | high |
| 8a | Agent-lifecycle config setters | M | medium |
| 8b | AgentLifecycleController | XL | high |
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
-`chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*`×7, `handleSchedule*`×9, `handleLoop*`×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:**`emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:**`session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
**SessionContext surface:**`emit`, `sessionLogger`, `providerSnapshotManager` (**shared by reference** — stays a daemon singleton read by checkout/lifecycle/workspace), `isProviderVisibleToClient` (predicate closing over `this`, reads `appVersion` live), `downgradeModeIconsForClient`, `downgradeEntryModesForClient`, agent-control reads `{ listProviderAvailability, listDraftFeatures }`.
**Behavior note:** COMPAT correctness — PUSH and PULL paths MUST call the SAME injected visibility/downgrade closures, reading `appVersion` LIVE (mutated post-construction via `updateAppVersion`). Keep `COMPAT(providersSnapshot)` and `COMPAT(customModeIcons)` comments verbatim. Do NOT pull `resolveStructuredGenerationProviders`/`getFocusedAgentSelectionForCwd` in. **Tests:**`session.dispatch-seam.test.ts`, `daemon-e2e/models.e2e.test.ts`, `session.test.ts`.
---
## Slice 3 — Split the shared observer seams (prerequisite, no controller)
In-place refactor on the shell, two named fan-outs:
1.**workspace-git observer** (4910-4917): make `emitCheckoutStatusUpdate` and `onBranchChanged` injectable callbacks; keep `workspaceGitWatchTargets`/`workspaceGitSubscriptions` shared by reference.
2.**agentManager.subscribe callback** (~1298): refactor into `{ onAgentUpdate, shouldAutoAllowVoicePermission(event), onStreamEvent }`.
**Behavior note:** the single hardest seam, split exactly once before the two domains that co-own it. The observer fires BOTH workspace (`handleWorkspaceGitBranchSnapshot`, `emitWorkspaceUpdateForCwd`) and checkout (`emitCheckoutStatusUpdate`) effects; the agent-subscribe callback is invoked by agent EVENTS (not the `??` chain) and does lifecycle + voice work. Add a test asserting BOTH a `workspace_update` and a `checkout_status_update` fire from one simulated git snapshot change, and a voice-permission test for the auto-allow path. **Tests:**`session.workspace-git-watch.test.ts`, `session.workspaces.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
**SessionContext surface:**`emit`, `sessionLogger`, `checkoutDiffManager` (move in + dispose teardown), `github` (shared), `workspaceGitService` (**shared spine**), `workspaceGitWatchTargets`/`workspaceGitSubscriptions` (**shared**), `providerSnapshotManager.listRegisteredProviderIds`. `emitCheckoutStatusUpdate` is now owned here and injected back into the workspace observer seam from Slice 3.
**Behavior note:** safe now that Slice 3 split the observer. `checkoutDiffSubscriptions` teardown moves to `dispose()`, called by `cleanup()` at its current ordinal (8530). **Tests:**`session.dispatch-seam.test.ts`, `checkout-diff-manager.test.ts`, `daemon-e2e/checkout-diff-subscription.e2e.test.ts`, `session.test.ts`.
---
## Slice 5 — WorkspaceController (XL)
**Move:** all workspace handlers (incl. re-homed `handleProjectRenameRequest` and `start_workspace_script_request`) + ~25 private workspace helpers + the whole `workspaceUpdatesSubscription` state machine → `packages/server/src/server/workspace/workspace-controller.ts`.
**SessionContext surface:**`emit`, `sessionLogger`, `projectRegistry`/`workspaceRegistry`/`downloadTokenStore`/script stores/editor cache (**owned**), `workspaceGitService` + watch maps (**shared with checkout**), injected `emitCheckoutStatusUpdate`/`onBranchChanged`, `terminalManager`/`killTerminalsUnderPath`, an `agentUpdatesSubscription` write via a narrow `bufferAgentUpdate` command, `providerSnapshotManager.listRegisteredProviderIds`.
**Behavior note:** the workspaceUpdatesSubscription machine moves WHOLE. The eight already-public workspace methods stay a public surface re-exposed via the shell. Re-homes are atomic remove-from-old-dispatcher + add-to-new-owned-set. **Tests:**`session.workspaces.test.ts`, `session.workspace-git-watch.test.ts`, `session.workspace-resolution-invariants.test.ts`, `session.test.ts`.
In-place, separately reviewable. Split the `audio_output` TTS-debug branch out of `emit()` (8421-8468, bypasses to `onMessage` at 8454) so `emit` is a pure trace+onMessage sink. Move `convertPCMToWavBuffer` (674-701) to `speech/audio.ts`. Decide abortController ownership.
**Behavior note:** TTS-debug split and abortController ownership are the SAME decision (`ttsDebugStreams.clear()` is tied to `createAbortController` reassignment at 8359). Keep `emit` (with the universal trace) on the shell and inject it everywhere — no trace-less emit. Do NOT inject the AbortController by value. Add: a TTS-debug persistence test (with the debug env flag) before the move, and a barge-in→cleanup regression test asserting the NEW run's signal is aborted. **Tests:**`voice-roundtrip.e2e.test.ts`, `voice-permission-policy.test.ts`, `session.test.ts`.
---
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**Behavior note:** depends on Slices 3 + 6. `cleanup()` stays the ordered orchestrator and calls `voiceController.dispose()` at the position the inlined voice teardown occupies today (8505-8525). **Tests:**`voice-roundtrip.e2e.test.ts`, `voice-local-agent.e2e.test.ts`, `session.voice-mcp-config.test.ts`, `session.test.ts`.
---
## Slice 8a — Agent-lifecycle config setters
Parameterize the 4 setter envelopes `handleSetAgentMode/Model/Feature/Thinking` (4209-4390) into one helper; re-home `handleListCommandsRequest` (misfiled in `dispatchMiscMessage`). Add a handleMessage-driven **failure** test per setter (force the command to reject, assert both the `*_response{accepted:false}` AND the `activity_log` error frame in order) BEFORE collapsing. **Tests:**`session.test.ts`, `session.lifecycle-boundary.test.ts`.
## Slice 8b — AgentLifecycleController (XL, LAST)
**Move:** remaining lifecycle handlers + the `agentUpdatesSubscription` fan-out (`bufferOrEmitAgentUpdate`, `flushBootstrappedAgentUpdates`, `matchesAgentFilter`, `forwardAgentUpdate`) → `packages/server/src/server/agent/agent-lifecycle-controller.ts`. Collapse the three lifecycle dispatchers.
**Behavior note:** done LAST — the shared-projection hub. `handleCloseItemsRequest` splits its terminal-kill half from its agent-archive half. **Tests:**`session.test.ts`, `session.wait-for-finish.test.ts`, `session.create-agent-title.test.ts`, `session.lifecycle-boundary.test.ts`, `daemon-client.e2e.test.ts`.
---
## Cross-cutting invariants (every slice)
- **Always** run `npm run typecheck` and `npm run lint` after each slice; run `npm run build:server` before diagnosing cross-package type errors.
- Controller `dispatch` is **NON-async**, guarded by an owned-type `ReadonlySet` check returning `undefined` synchronously on miss. Never `async dispatch`.
All workspaces share one version and release together.
## Two steps
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
**Go-ahead** (user says "go ahead"):
- commit the approved changelog
- run the release
Rules that apply to both steps:
- Last-minute changes always need approval. Every time.
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
## 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.**Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
## 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.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- 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. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
## 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)
```
## Beta flow
```bash
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
# ... test desktop and APK prerelease assets from GitHub Releases ...
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
```
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- Betas publish desktop assets and APKs for testing, but they 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 beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
Use the beta path when you need to:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
### Default behavior
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash
# 1. Cut and publish (default 36h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
```
**Why this is gap-free:**`desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time.
**Hotfix (instant admit) on an already-shipped release:**
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=0
```
`rollout_hours=0` admits 100% of stable users on their next update check (within ~30 min for active clients).
**Slow a rollout down** (e.g. extend total duration to 72h since the original release):
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=72
```
`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now.
The dispatch is idempotent and shares the `desktop-rollout-<tag>` concurrency group with `desktop-release.yml`'s `finalize-rollout` job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
### Custom ramp on a manually-dispatched build
`desktop-release.yml` accepts `rollout_hours` only on `workflow_dispatch`, which is the path used to **rebuild an existing tag** (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
```bash
gh workflow run desktop-release.yml \
-f tag=v0.1.43 \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission.
## Mobile builds (EAS)
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
```bash
cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
# Read failed submit/review job logs.
npx eas workflow:logs <workflow-job-id> --all-steps --non-interactive
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
```
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
-`build_ios` — iOS binary built
-`submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
-`submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
-`build_android` — Android store binary built
-`submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
### Babysitting mobile after a release
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Pattern:
```jsonc
// mcp__paseo__create_heartbeat arguments
{
"name":"vX.Y.Z release babysit heartbeat",
"cron":"*/15 * * * *",
"maxRuns":8,// covers ~2h of build + store-submission window
"prompt":"Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
## Release notes on GitHub
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
- Published beta 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`.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
## 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
# Beta
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
-`vX.Y.Z` or `vX.Y.Z-beta.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
-`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
- If `release:publish:beta` partially fails, re-run it — npm skips already-published versions and keeps prereleases off `latest` because every publish uses `--tag beta`
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
## Changelog format
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` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
## Changelog ownership
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
## 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`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
| Wrong (implementation-facing) | Right (user-facing) |
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
| Added debouncing to the search input | Search results no longer lag behind typing |
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **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.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
```markdown
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
```
Rules:
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Credit the commit author, not the PR opener.** A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but `gh pr view N --json author` returns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.
Use this command to get the GitHub logins for each PR:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
## Pre-release sanity check
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
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.
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
## Changelog scope
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
### Stable release (or promotion)
- [ ] 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` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
## Request/Response Pairs
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
## Message Shape
Requests keep their parameters at the top level:
```ts
{
type:"checkout.github.set_auto_merge.request",
cwd:"/repo",
enabled: true,
mergeMethod:"squash",
requestId:"req_123"
}
```
Responses put correlated result data under `payload`:
```ts
{
type:"checkout.github.set_auto_merge.response",
payload:{
cwd:"/repo",
enabled: true,
success: true,
error: null,
requestId:"req_123"
}
}
```
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
Provider-specific behavior belongs under the provider segment:
-`checkout.github.*` for GitHub-specific checkout operations
-`checkout.gitlab.*` for future GitLab-specific checkout operations
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
## Compatibility
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
```ts
checkout_pr_merge_request;
checkout_pr_merge_response;
```
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
- Add the new names first.
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
- Keep old names accepted until the compatibility window expires.
- Mark shims with `COMPAT(...)` and a removal date.
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.localhost
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.localhost
```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
```
dev--feature-auth--miniweb.localhost
```
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
1. Configure a wildcard DNS record:
```
*.paseoapps.my.domain.com → 10.1.1.1
```
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
Nginx example:
```nginx
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
}
}
```
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
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()=>{
constresult=awaitcreateToolCall(input);
if(result.ok){
expect(result.id).toBeDefined();
}
});
// Good: deterministic and explicit
it("returns timeout error when provider times out",async()=>{
constresult=awaitcreateToolCall(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:
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
### File naming
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
### Test setup
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
- App: `packages/app/vitest.setup.ts` provides `expo`/`__DEV__` shims and stubs a few native-only modules (`react-native-unistyles`, `react-native-svg`, `expo-linking`, `@xterm/addon-ligatures`). Stubbing here is for modules that have no meaningful Node behavior — not a license to mock app code.
## Running tests locally
Test suites in this repo are heavy. Running them in bulk freezes the machine, especially with multiple agents in parallel.
- Run only the file you changed: `npx vitest run <path> --bail=1`
- Never run `npm run test` for a whole workspace unless asked.
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
## 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.
## Two test categories, no others
Every test in this repo lives in exactly one of these shapes:
1.**Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
2.**Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.
1.**Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2.**Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
## Presence is not delivery
Client heartbeat reports presence:
- device type
- app visibility
- focused agent
- last activity time
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
## Catch-up is paged but complete
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
## Resume behavior
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
When a client resumes without a cursor, it fetches the latest tail page.
## Relevant code
- Server live stream forwarding: `packages/server/src/server/session.ts`
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Banned
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
```tsx
conststyles=StyleSheet.create((theme)=>({
container:{
backgroundColor: theme.colors.surface0,
padding: theme.spacing[4],
},
}));
<Viewstyle={styles.container}/>;
```
If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook.
### 2. Hard-coded constants for genuinely static values
If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static.
### 3. `withUnistyles(Component)` for third-party props
When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree.
```tsx
constThemedBlur=withUnistyles(BlurView);
<ThemedBlurtint={theme.colors.surface0}/>;
```
(Mind the `> *` child-selector leak documented further down.)
### 4. There is no "last resort"
There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
## Inline Style Escape Hatch
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
Avoid this pattern when the style depends on the theme:
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
## Fix Patterns
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
In practice the wrapper-`View` pattern is the one we use. Across the app, `withUnistyles` is now reserved for wrapping leaf components — mostly lucide icons (`ThemedActivityIndicator`, `ThemedChevronDown`, …) and small third-party components like `MarkdownWithStableRenderer` — so they pick up theme-reactive `color`/`tintColor` props without re-rendering their parent.
In principle, [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles) can also wrap a `ScrollView` to make `contentContainerStyle` theme-reactive via its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props). We previously did this on the welcome screen and hit the `> *` child-selector leak documented below; we have since moved the welcome screen to the wrapper-`View` pattern. If you find yourself reaching for `withUnistyles(ScrollView)`, treat it as a smell and check whether a wrapper view works first.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## `withUnistyles` And The `> *` Child-Selector Leak
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `panels/agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
```css
.unistyles_j2k2iilhfz{
flex:110%;
background-color:var(--colors-surface0);
}
.unistyles_j2k2iilhfz>*{
flex:110%;
background-color:var(--colors-surface0);
}
```
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
Symptoms to watch for:
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }``View` pick up an unexpected background.
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
## Memoized Style Objects
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
If a style factory is cheap, skipping `useMemo` entirely is also fine.
## Static Theme Imports
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
This is the dominant pattern in the app today (see `sidebar-workspace-list.tsx`, `message.tsx`, the workspace screens). Reserve `useUnistyles()` for the last-resort cases described at the top of this file. Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
Do not apply `StyleSheet.create((theme) => ...)` styles to a Reanimated `Animated.View`. Unistyles wraps styled components in a `<UnistylesComponent>` and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with `Unable to find node on an unmounted component.` This was a real iOS sidebar crash on theme toggle (commit `4896cfe9`).
Fix: keep static positioning on the `Animated.View` in plain React Native `StyleSheet`, and pass theme-dependent values (e.g. `backgroundColor`) as inline style from `useUnistyles()` — the inline path is acceptable here because no other escape works:
This is one of the rare places `useUnistyles()` is the right tool: there is no `withUnistyles(Animated.View)` equivalent, the affected component is small, and the alternative is a crash.
## Adaptive Themes And Persisted Settings
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
1. Unistyles config starts with `adaptiveThemes: true`.
2. The device may report system light.
3. Settings load a persisted non-auto preference, such as dark.
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## Runtime Theme Patching For User Preferences
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
Gotchas:
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
```js
[
"react-native-unistyles/plugin",
{
root:"src",
debug:true,
},
],
```
Then rebuild the bundle and look for lines such as:
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
For paint-layer bugs, use high-contrast probes:
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
2. Cold-restart the app, not just Fast Refresh.
3. Screenshot the simulator and sample pixels to see which color fills the area.
4. Remove the probes before committing.
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container.
"dev:server:watch":"concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw":"npm run dev --workspace=@getpaseo/server",
"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",
"build:highlight:clean":"npm run build:clean --workspace=@getpaseo/highlight",
"build:relay":"npm run build --workspace=@getpaseo/relay",
"build:relay:clean":"npm run build:clean --workspace=@getpaseo/relay",
"build:protocol":"npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean":"npm run build:clean --workspace=@getpaseo/protocol",
"build:client":"npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client:clean":"npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps":"npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps:clean":"npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server":"npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean":"npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:app-deps":"npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean":"npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"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",
"typecheck:server":"npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"start":"npm run start --workspace=@getpaseo/server",
"android":"npm run android --workspace=@getpaseo/app",
"android:development":"npm run android:development --workspace=@getpaseo/app",
@@ -35,64 +82,60 @@
"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 version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"cli":"npx tsx packages/cli/src/index.js",
"dev:desktop":"cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop":"npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop":"npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query":"npm run db:query --workspace=@getpaseo/server --",
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.