Compare commits

..

77 Commits

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

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

* feat: add Nix flake with package and NixOS module

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

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

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

ci: add Nix hash maintenance scripts and workflows

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

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

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

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

fix: update npmDepsHash after upstream sync

nix: allowlist workspace symlinks instead of blocklist

Prevents build failures when upstream adds new workspace packages.

* don't block PRs on nix failures

* better document npm workaround

* fix hash update script, and update hash

* integrate with npm run build:daemon

* ci: trigger nix build on highlight changes

* fix(nix): update npmDepsHash

---------

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

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

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

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

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

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

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

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

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

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

* fix: use workspace-agnostic root tsconfig

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

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

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

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

* fix(app): simplify workspace pane focus handoff
2026-03-24 15:02:02 +08:00
Mohamed Boudra
cbab9332a9 feat(app): redesign command autocomplete with detail card and dropdown styling
- Add detail card above command list showing full description and argument hint
- Compact command rows to single line (label + truncated description)
- Match dropdown component styling (surface1 bg, borderAccent border, shadow)
2026-03-24 13:35:59 +07:00
Mohamed Boudra
fa0346921a feat(skills): add paseo chat coordination 2026-03-24 12:37:13 +07:00
Mohamed Boudra
c6aff35db2 fix(app): restore assistant text selection on web (#140) 2026-03-24 12:01:08 +08:00
Mohamed Boudra
af8509d012 fix(release): unblock 0.1.33 app and server checks 2026-03-24 00:09:05 +07:00
Mohamed Boudra
0e6aba2886 refactor(paseo-loop): support self and new-agent targets with max-time 2026-03-24 00:04:17 +07:00
Mohamed Boudra
e969f42c68 chore(release): cut 0.1.33 2026-03-23 23:21:14 +07:00
Mohamed Boudra
876c08ebc2 docs: add 0.1.33 changelog 2026-03-23 23:13:01 +07:00
Mohamed Boudra
8ba5df358a Revert "debug(app): add dictation pipeline logging"
This reverts commit e36784e510.
2026-03-23 23:06:55 +07:00
Mohamed Boudra
771acd11a1 fix(server): close query stream before interrupt/return in claude agent
Call query.close() before awaiting interrupt/return to ensure the
stream is properly torn down on session close and query restart.
2026-03-23 22:04:37 +07:00
Mohamed Boudra
d4735edd45 fix(desktop): surface notification test errors in UI
Show inline error text when the test notification fails or isn't
delivered instead of silently logging to console.
2026-03-23 22:04:30 +07:00
Mohamed Boudra
e36784e510 debug(app): add dictation pipeline logging
Trace the full dictation flow — confirm, transcript, and auto-send
paths — to diagnose transcription delivery issues.
2026-03-23 22:04:24 +07:00
Mohamed Boudra
a237285808 fix(desktop): resolve to Helper app for daemon child processes on macOS
When packaged, Electron's process.execPath points to the main app
binary which inherits the full app entitlements and UI lifecycle.
Resolve to the Helper binary instead so daemon child processes run
without inheriting the main app's entitlement/UI baggage.
2026-03-23 22:04:19 +07:00
Mohamed Boudra
ee6d8072ed feat(desktop): add microphone entitlement for dictation
Add com.apple.security.device.audio-input to both mac entitlement
plists so the sandboxed app can access the microphone.
2026-03-23 22:04:12 +07:00
Mohamed Boudra
c8d259b1df feat(highlight): make package public and add to release pipeline
Remove private flag, add publishConfig with public access, point
types to dist output, and include highlight in release:check,
release:publish, and dry-run scripts.
2026-03-23 22:04:02 +07:00
Mohamed Boudra
2da56634a1 refactor(skills): strip orchestrator concerns from paseo skill
Paseo skill is now a pure CLI reference — commands, models,
permissions, waiting guidelines, and bash composition patterns.
Orchestrator-specific guidance (agent interrogation, investigation
vs implementation, management principles, committee patterns) moved
to the new paseo-orchestrator skill.
2026-03-23 21:52:36 +07:00
Mohamed Boudra
27be8c3b1e feat(skills): add paseo-orchestrator skill for agent orchestration
Dedicated skill for orchestrator mode — how to manage agents as a
product owner rather than a coder. Covers two-audience model (design
partner to user, product owner to agents), pre-launch logistics,
agent types beyond just impl/review, prompt structure, mandatory
review step, challenging agent bad behaviors (hand-waving,
over-engineering, lying, working around problems), and user signal
interpretation.
2026-03-23 21:52:30 +07:00
160 changed files with 10318 additions and 7155 deletions

View File

@@ -26,6 +26,9 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/app

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

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

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

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

View File

@@ -36,6 +36,9 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay

View File

@@ -1,5 +1,57 @@
# Changelog
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
- The notification test button now surfaces errors instead of failing silently.
## 0.1.32 - 2026-03-23
### Added

View File

@@ -46,9 +46,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
## Orchestrator mode
## Debugging
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
- Launch agents in the most permissive mode
- Set cwd to the repository root
- When agent control tool calls fail, list agents first — it may be a wait timeout
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log

View File

@@ -4,40 +4,88 @@
<h1 align="center">Paseo</h1>
<p align="center">One interface for all your coding agents.</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
## Getting Started
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Desktop app
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). The app bundles its own daemon, so there's nothing else to install. It can also connect to daemons running on other machines.
### Headless / server mode
To run the daemon on a remote or headless machine:
Run the daemon on any machine:
```bash
npm install -g @getpaseo/cli
paseo
```
Then connect from the desktop app, mobile app, or CLI.
Then connect from any client — desktop, web, mobile, or CLI. See [paseo.sh/download](https://paseo.sh/download) for all options.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
Quick monorepo package map:
@@ -57,8 +105,12 @@ npm run dev
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
```

View File

@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.

View File

@@ -0,0 +1,170 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()``activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()``liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()``routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
describePersistence(): AgentPersistenceHandle | null;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds

27
flake.lock generated Normal file
View File

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

59
flake.nix Normal file
View File

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

172
nix/module.nix Normal file
View File

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

144
nix/package.nix Normal file
View File

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

89
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.32",
"version": "0.1.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.32",
"version": "0.1.35",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34842,16 +34842,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.32",
"version": "0.1.35",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@getpaseo/expo-two-way-audio": "0.1.35",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/server": "0.1.35",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34937,6 +34937,8 @@
},
"packages/app/node_modules/expo-clipboard": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
@@ -34956,6 +34958,8 @@
},
"packages/app/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -34963,11 +34967,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.32",
"version": "0.1.35",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.32",
"@getpaseo/server": "0.1.32",
"@getpaseo/relay": "0.1.35",
"@getpaseo/server": "0.1.35",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -34987,6 +34991,8 @@
},
"packages/cli/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -34997,6 +35003,8 @@
},
"packages/cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -35004,10 +35012,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.32",
"version": "0.1.35",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.32",
"@getpaseo/server": "0.1.32",
"@getpaseo/cli": "0.1.35",
"@getpaseo/server": "0.1.35",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35040,7 +35049,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.32",
"version": "0.1.35",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35241,7 +35250,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.32",
"version": "0.1.35",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35267,7 +35276,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.32",
"version": "0.1.35",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35283,13 +35292,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.32",
"version": "0.1.35",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "*",
"@getpaseo/relay": "0.1.32",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/relay": "0.1.35",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35334,6 +35343,8 @@
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@@ -35355,6 +35366,8 @@
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -35369,6 +35382,8 @@
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -35415,6 +35430,8 @@
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -35426,6 +35443,8 @@
},
"packages/server/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -35440,6 +35459,8 @@
},
"packages/server/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -35450,6 +35471,8 @@
},
"packages/server/node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -35468,6 +35491,8 @@
},
"packages/server/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -35478,6 +35503,8 @@
},
"packages/server/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
@@ -35485,6 +35512,8 @@
},
"packages/server/node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -35500,6 +35529,8 @@
},
"packages/server/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -35507,6 +35538,8 @@
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -35514,6 +35547,8 @@
},
"packages/server/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -35524,6 +35559,8 @@
},
"packages/server/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
@@ -35534,6 +35571,8 @@
},
"packages/server/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -35572,6 +35611,8 @@
},
"packages/server/node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
@@ -35592,6 +35633,8 @@
},
"packages/server/node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -35605,6 +35648,8 @@
},
"packages/server/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -35618,6 +35663,8 @@
},
"packages/server/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -35630,6 +35677,8 @@
},
"packages/server/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -35637,7 +35686,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.32",
"version": "0.1.35",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
@@ -35663,6 +35712,8 @@
},
"packages/website/node_modules/@types/node": {
"version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.32",
"version": "0.1.35",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -44,9 +44,9 @@
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
@@ -68,6 +68,7 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -76,7 +77,14 @@
"development",
"mcp"
],
"author": "moboudra",
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.32",
"version": "0.1.35",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@getpaseo/expo-two-way-audio": "0.1.35",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/server": "0.1.35",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -1,36 +0,0 @@
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false;
}
if (input.hasTimedOut) {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false;
}
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}

View File

@@ -25,6 +25,9 @@ import {
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
let cancelled = false;
const store = getHostRuntimeStore();
void store
.loadFromStorage()
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
await store.bootstrap({ manageBuiltInDaemon: true });
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
}
};
void init()
.then(() => {
if (cancelled) {
return;
if (!cancelled) {
setReady(true);
}
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [isLoading, settings.theme]);
if (isLoading) {
return <LoadingView />;
const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
}
return (

View File

@@ -1,113 +1,18 @@
import { useEffect, useSyncExternalStore, useState } from "react";
import { useEffect } from "react";
import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
const WELCOME_ROUTE = "/welcome";
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
}, [pathname, router]);
return null;
}

View File

@@ -1,55 +1,23 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useHosts } from "@/runtime/host-runtime";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const { theme } = useUnistyles();
const daemons = useHosts();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]);
}, [daemons]);
useEffect(() => {
if (preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, router, targetServerId]);
if (preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
}
}, [router, targetServerId]);
if (!targetServerId) {
return <DraftAgentScreen />;

View File

@@ -36,10 +36,12 @@ function getModeName(modeId?: string, availableModes?: Agent["availableModes"]):
function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray
// Color based on common mode types
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
if (modeId.includes("code")) return "#22c55e"; // green - writes code
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
if (modeId.includes("bypass") || modeId.includes("full-access")) return "#ef4444"; // red - dangerous
if (modeId.includes("auto") || modeId.includes("build") || modeId.includes("acceptEdits"))
return "#3b82f6"; // blue - build/auto
if (modeId.includes("plan") || modeId.includes("architect")) return "#a855f7"; // purple - planning
if (modeId.includes("ask") || modeId.includes("read-only") || modeId === "default")
return "#22c55e"; // green - safest
return "#9ca3af"; // gray - unknown
}

View File

@@ -537,14 +537,10 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
});
}
return opts;
return models.map((model) => ({
id: model.id,
label: model.label,
}));
}, [models]);
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
@@ -586,7 +582,7 @@ export function AgentConfigRow({
title="Select model"
value={selectedModel}
options={modelSelectOptions}
placeholder="Auto"
placeholder={isModelLoading ? "Loading..." : "Select model"}
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
@@ -777,10 +773,8 @@ export function ModelDropdown({
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
const helperText = error
? undefined
: isLoading
@@ -790,34 +784,20 @@ export function ModelDropdown({
: undefined;
const options = useMemo(() => {
const opts: ComboSelectOption[] = [
{
id: "",
label: "Automatic (provider default)",
description: "Let the assistant pick the recommended model.",
},
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
description: model.description,
});
}
return opts;
return models.map((model) => ({
id: model.id,
label: model.label,
description: model.description,
}));
}, [models]);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const handleSelect = useCallback(
(id: string) => {
if (id === "") {
onClear();
} else {
onSelect(id);
}
onSelect(id);
},
[onClear, onSelect],
[onSelect],
);
return (

View File

@@ -1,10 +1,10 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -59,6 +59,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface AgentInputAreaProps {
agentId: string;
serverId: string;
isInputActive: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
@@ -91,6 +92,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
agentId,
serverId,
isInputActive,
onSubmitMessage,
isSubmitLoading = false,
blurOnSubmit = false,
@@ -112,8 +114,6 @@ export function AgentInputArea({
const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
const isScreenFocused = useIsFocused();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -127,7 +127,14 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
};
}),
);
const queuedMessagesRaw = useSessionStore((state) =>
state.sessions[serverId]?.queuedMessages?.get(agentId),
@@ -260,7 +267,6 @@ export function AgentInputArea({
return updated;
});
}
const imagesData = await encodeImages(images);
await client.sendAgentMessage(agentId, text, {
messageId: clientMessageId,
@@ -274,41 +280,8 @@ export function AgentInputArea({
onSubmitMessageRef.current = onSubmitMessage;
}, [onSubmitMessage]);
const isAgentRunning = agent?.status === "running";
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
const prevIsAgentRunningRef = useRef(isAgentRunning);
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
useEffect(() => {
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
if (agentUpdatedAtMs < previousUpdatedAt) {
if (isProcessing && !isAgentRunning) {
prevIsAgentRunningRef.current = false;
setIsProcessing(false);
}
return;
}
const wasRunning = prevIsAgentRunningRef.current;
let shouldClearProcessing = false;
if (isProcessing) {
const hasEnteredRunning = !wasRunning && isAgentRunning;
const hasFreshRunningUpdateWhileRunning =
wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt;
const hasStoppedRunning = wasRunning && !isAgentRunning;
shouldClearProcessing =
hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning;
}
prevIsAgentRunningRef.current = isAgentRunning;
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
if (shouldClearProcessing) {
setIsProcessing(false);
}
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
const isAgentRunning = agentState.status === "running";
const hasAgent = agentState.status !== null;
const updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
@@ -350,7 +323,7 @@ export function AgentInputArea({
message,
imageAttachments,
forceSend,
isAgentRunning: agent?.status === "running",
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
@@ -424,7 +397,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!isScreenFocused) {
if (!isInputActive) {
return false;
}
@@ -460,7 +433,7 @@ export function AgentInputArea({
return false;
}
},
[isScreenFocused],
[isInputActive],
);
useKeyboardActionHandler({
@@ -472,9 +445,9 @@ export function AgentInputArea({
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isScreenFocused,
enabled: isInputActive,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isScreenFocused,
isActive: () => isInputActive,
handle: handleKeyboardAction,
});
@@ -483,7 +456,7 @@ export function AgentInputArea({
});
function handleCancelAgent() {
if (!agent || agent.status !== "running" || isCancellingAgent) {
if (!isAgentRunning || isCancellingAgent) {
return;
}
if (!isConnected || !client) {
@@ -497,7 +470,7 @@ export function AgentInputArea({
const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
const handleToggleRealtimeVoice = useCallback(() => {
if (!voice || !isConnected || !agent) {
if (!voice || !isConnected || !hasAgent) {
return;
}
if (voice.isVoiceSwitching) {
@@ -514,7 +487,7 @@ export function AgentInputArea({
toast.error(message);
}
});
}, [agent, agentId, isConnected, serverId, toast, voice]);
}, [agentId, hasAgent, isConnected, serverId, toast, voice]);
function handleEditQueuedMessage(id: string) {
const item = queuedMessages.find((q) => q.id === id);
@@ -606,7 +579,7 @@ export function AgentInputArea({
const rightContent = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent && agent ? (
{!isVoiceModeForAgent && hasAgent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
@@ -716,7 +689,7 @@ export function AgentInputArea({
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isScreenFocused={isScreenFocused}
isInputActive={isInputActive}
leftContent={leftContent}
rightContent={rightContent}
voiceServerId={serverId}

View File

@@ -46,6 +46,7 @@ export async function submitAgentInput<TImage>(
try {
await input.submitMessage({ message: trimmedMessage, imageAttachments });
input.clearDraft("sent");
input.setIsProcessing(false);
return "submitted";
} catch (error) {
input.onSubmitError?.(error);

View File

@@ -14,14 +14,14 @@ describe("getStatusSelectorHint", () => {
});
describe("normalizeModelId", () => {
it("treats empty and default values as unset", () => {
it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull();
});
it("returns trimmed model ids", () => {
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
expect(normalizeModelId(" default ")).toBe("default");
});
});
@@ -69,4 +69,50 @@ describe("resolveAgentModelSelection", () => {
expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High");
});
it("falls back to the provider default model label instead of Auto", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "a",
provider: "codex",
label: "Model A",
isDefault: true,
thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: null,
configuredModelId: null,
explicitThinkingOptionId: null,
});
expect(selection.displayModel).toBe("Model A");
expect(selection.displayThinking).toBe("Low");
});
it("prefers the configured model when runtime model is not in the model list", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "default",
provider: "claude",
label: "Default (Sonnet 4.6)",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
],
runtimeModelId: "claude-sonnet-4-6-20260101",
configuredModelId: "default",
explicitThinkingOptionId: null,
});
expect(selection.activeModelId).toBe("default");
expect(selection.displayModel).toBe("Default (Sonnet 4.6)");
expect(selection.selectedThinkingId).toBe("low");
expect(selection.displayThinking).toBe("Low");
});
});

View File

@@ -1,6 +1,8 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
@@ -104,21 +106,18 @@ function getModeIconColor(
palette: {
blue: { 500: string };
green: { 500: string };
amber: { 500: string };
red: { 500: string };
purple: { 500: string };
},
): string {
switch (colorTier) {
case "default":
return palette.blue[500];
case "safe":
return palette.green[500];
case "moderate":
return palette.amber[500];
return palette.blue[500];
case "dangerous":
return palette.red[500];
case "readonly":
case "planning":
return palette.purple[500];
default:
return palette.blue[500];
@@ -166,8 +165,12 @@ function ControlledStatusBar({
const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0)
? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, "Auto");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
: findOptionLabel(modelOptions, selectedModelId, "Select model");
const displayThinking = findOptionLabel(
thinkingOptions,
selectedThinkingOptionId,
thinkingOptions?.[0]?.label ?? "Unknown",
);
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
@@ -603,8 +606,29 @@ function ControlledStatusBar({
);
}
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return currentAgent
? {
provider: currentAgent.provider,
cwd: currentAgent.cwd,
currentModeId: currentAgent.currentModeId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
thinkingOptionId: currentAgent.thinkingOptionId,
}
: null;
}),
);
const availableModes = useStoreWithEqualityFn(
useSessionStore,
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
@@ -631,23 +655,23 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const models = modelsQuery.data ?? null;
const displayMode =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
agent?.currentModeId ||
"default";
const modelSelection = resolveAgentModelSelection({
models,
runtimeModelId: agent?.runtimeInfo?.model,
runtimeModelId: agent?.runtimeModelId,
configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId,
});
const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({
return availableModes.map((mode) => ({
id: mode.id,
label: mode.label,
}));
}, [agent?.availableModes]);
}, [availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
@@ -668,9 +692,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ControlledStatusBar
provider={agent.provider}
modeOptions={
modeOptions.length > 0
? modeOptions
: [{ id: agent.currentModeId ?? "", label: displayMode }]
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => {
@@ -777,7 +799,7 @@ export function DraftAgentStatusBar({
label: definition.label,
}));
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}

View File

@@ -15,7 +15,7 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
if (!normalized) {
return null;
}
return normalized;
@@ -30,25 +30,33 @@ export function resolveAgentModelSelection(input: {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const runtimeSelectedModel =
models && normalizedRuntimeModelId
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
: null;
const preferredModelId =
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
const fallbackModel =
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const selectedModel =
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? null)
: null;
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
: fallbackModel;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const displayModel =
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
const resolvedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
return {
selectedModel,

View File

@@ -39,7 +39,7 @@ import {
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -82,7 +82,7 @@ export interface AgentStreamViewHandle {
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
agent: Agent;
agent: AgentScreenAgent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;

View File

@@ -11,6 +11,13 @@ const INLINE_MODEL_THRESHOLD = 8;
type DrillDownView = { provider: string };
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
@@ -66,9 +73,9 @@ export function CombinedModelSelector({
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Auto";
if (!models) return isLoading ? "Loading..." : "Select model";
const model = models.find((m) => m.id === selectedModel);
return model?.label ?? "Auto";
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return (

View File

@@ -66,8 +66,8 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
isScreenFocused?: boolean;
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
@@ -190,7 +190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus = false,
autoFocusKey,
disabled = false,
isScreenFocused = true,
isInputActive = true,
leftContent,
rightContent,
voiceServerId,
@@ -387,7 +387,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isScreenFocused },
autoStopWhenHidden: { isVisible: isInputActive },
enableDuration: true,
});
@@ -950,18 +950,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -1203,7 +1205,7 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: {
flexDirection: "row",
alignItems: "flex-end",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
gap: theme.spacing[1],
},
rightButtonGroup: {
flexDirection: "row",

View File

@@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
userSelect: Platform.OS === "web" ? "text" : "auto",
},
}));
@@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
<Text
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
selectable={Platform.OS === "web" ? undefined : false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}

View File

@@ -31,11 +31,11 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -392,15 +392,15 @@ function NewWorktreeButton({
}}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new worktree for ${displayName}`}
accessibilityLabel={`Create a new workspace for ${displayName}`}
testID={testID}
>
{({ hovered, pressed }) =>
loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<Plus
size={14}
<FolderPlus
size={15}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
@@ -411,7 +411,7 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}

View File

@@ -150,70 +150,136 @@ export function Autocomplete({
);
}
const selectedOption = options[selectedIndex];
return (
<View style={[styles.container, { maxHeight }]}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDetail = removeBoltGlyphs(option.detail);
const optionDescription = removeBoltGlyphs(option.description);
return (
<Pressable
key={option.id}
onLayout={(event) => handleRowLayout(index, event)}
onPress={() => onSelect(option)}
style={({ hovered = false, pressed }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{option.kind === "directory" || option.kind === "file" ? (
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDetail ? <Text style={styles.itemDetail}>{optionDetail}</Text> : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</Pressable>
);
})}
</ScrollView>
<View style={styles.outerWrapper}>
{selectedOption?.kind === "command" && selectedOption.description ? (
<View style={styles.detailCard}>
<Text style={styles.detailLabel}>
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
</Text>
<Text style={styles.detailDescription}>
{removeBoltGlyphs(selectedOption.description)}
</Text>
{selectedOption.detail ? (
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
) : null}
</View>
) : null}
<View style={[styles.container, { maxHeight }]}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDescription = removeBoltGlyphs(option.description);
const isFileOrDir = option.kind === "directory" || option.kind === "file";
return (
<Pressable
key={option.id}
onLayout={(event) => handleRowLayout(index, event)}
onPress={() => onSelect(option)}
style={({ hovered = false, pressed }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
)}
</Pressable>
);
})}
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
backgroundColor: theme.colors.surface0,
outerWrapper: {
gap: theme.spacing[1],
},
detailCard: {
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
detailLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
detailDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
detailHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
container: {
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
scrollView: {
flexGrow: 0,
@@ -236,12 +302,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
marginRight: theme.spacing[1],
},
itemActive: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface2,
},
itemMain: {
flex: 1,
minWidth: 0,
},
itemMainRow: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
@@ -261,6 +334,11 @@ const styles = StyleSheet.create(((theme: Theme) => ({
fontSize: theme.fontSize.xs,
marginTop: 2,
},
itemDescriptionInline: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],

View File

@@ -1,11 +1,17 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: {
color: theme.colors.accentForeground,
},
hostList: {
width: "100%",
maxWidth: 420,
marginTop: theme.spacing[6],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
hostLabel: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hostStatusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
const lastError = snapshot?.lastError ?? null;
let dotColor: string;
let statusText: string;
let isError = false;
switch (status) {
case "online":
dotColor = theme.colors.success;
statusText = "Online";
break;
case "connecting":
case "idle":
dotColor = theme.colors.foregroundMuted;
statusText = "Connecting…";
break;
case "offline":
dotColor = theme.colors.foregroundMuted;
statusText = "Offline";
break;
case "error":
dotColor = theme.colors.destructive;
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
isError = true;
break;
}
return (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
hostname: string | null;
} | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
@@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
),
);
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any);
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<View style={styles.actions}>
{actions.map((action) => {
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
);
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>

View File

@@ -49,6 +49,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -295,15 +296,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, []);
useEffect(() => {
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current,
sessionAgents,
);
}, [sessionAgents]);
const hydrateWorkspaces = useCallback(

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
function createAgent(status: Agent["status"]): Agent {
return {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status,
createdAt: new Date(0),
updatedAt: new Date(0),
lastUserMessageAt: null,
lastActivityAt: new Date(0),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent",
cwd: "/tmp",
model: null,
labels: {},
projectPlacement: null,
};
}
describe("reconcilePreviousAgentStatuses", () => {
it("preserves previously seen status for existing agents", () => {
const previous = new Map([["agent-1", "running" as const]]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("seeds newly seen agents from the current snapshot", () => {
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
expect(result).toEqual(new Map([["agent-1", "idle"]]));
});
it("removes agents that are no longer present", () => {
const previous = new Map([
["agent-1", "running" as const],
["agent-2", "idle" as const],
]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("clears all tracked statuses when the session is unavailable", () => {
const previous = new Map([["agent-1", "running" as const]]);
const result = reconcilePreviousAgentStatuses(previous, undefined);
expect(result).toEqual(new Map());
});
});

View File

@@ -0,0 +1,29 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
export function reconcilePreviousAgentStatuses(
previousStatuses: Map<string, AgentLifecycleStatus>,
sessionAgents: Map<string, Agent> | undefined,
): Map<string, AgentLifecycleStatus> {
if (!sessionAgents) {
return new Map();
}
const nextStatuses = new Map(previousStatuses);
const seenAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
seenAgentIds.add(agent.id);
if (!nextStatuses.has(agent.id)) {
nextStatuses.set(agent.id, agent.status);
}
}
for (const agentId of nextStatuses.keys()) {
if (!seenAgentIds.has(agentId)) {
nextStatuses.delete(agentId);
}
}
return nextStatuses;
}

View File

@@ -14,6 +14,7 @@ export function DesktopPermissionsSection() {
isRefreshing,
requestingPermission,
isSendingTestNotification,
testNotificationError,
refreshPermissions,
requestPermission,
sendTestNotification,
@@ -58,6 +59,11 @@ export function DesktopPermissionsSection() {
void sendTestNotification();
}}
/>
{testNotificationError ? (
<Text style={[styles.errorText, { color: theme.colors.destructive }]}>
{testNotificationError}
</Text>
) : null}
<DesktopPermissionRow
title="Microphone"
showBorder
@@ -80,4 +86,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
},
errorText: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
}));

View File

@@ -14,6 +14,7 @@ export interface UseDesktopPermissionsReturn {
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean;
testNotificationError: string | null;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>;
@@ -112,22 +113,25 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktop, refreshPermissions],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => {
if (!isDesktop) {
return;
}
setIsSendingTestNotification(true);
setTestNotificationError(null);
try {
const sent = await sendOsNotification({
title: "Paseo notification test",
body: "If you can see this, desktop notifications work.",
});
if (!sent) {
console.warn("[Settings] Desktop test notification was not delivered");
setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
}
} catch (error) {
console.error("[Settings] Failed to send desktop test notification", error);
setTestNotificationError("Failed to send notification.");
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
@@ -149,6 +153,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
isRefreshing,
requestingPermission,
isSendingTestNotification,
testNotificationError,
refreshPermissions,
requestPermission,
sendTestNotification,

View File

@@ -80,20 +80,14 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("keeps provider thinking preference when it is valid for the effective model", () => {
it("prefers provider defaults on fresh drafts", () => {
const resolved = __private__.resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: {
codex: {
thinkingOptionId: "low",
},
},
},
{ provider: "codex" },
codexModels,
{
serverId: false,
@@ -114,20 +108,14 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.thinkingOptionId).toBe("low");
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("falls back to model default when saved thinking preference is invalid", () => {
const resolved = __private__.resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: {
codex: {
thinkingOptionId: "medium",
},
},
},
{ provider: "codex" },
codexModels,
{
serverId: false,
@@ -151,7 +139,7 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("normalizes legacy model id 'default' from initial values to auto", () => {
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ provider: "codex" },
@@ -175,20 +163,13 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("");
expect(resolved.model).toBe("gpt-5.3-codex");
});
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
it("normalizes legacy model id 'default' to the provider default model", () => {
const resolved = __private__.resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: {
codex: {
model: "default",
},
},
},
{ model: "default" },
{ provider: "codex" },
codexModels,
{
serverId: false,
@@ -209,7 +190,76 @@ describe("useAgentFormState", () => {
new Set<string>(),
);
expect(resolved.model).toBe("");
expect(resolved.model).toBe("gpt-5.3-codex");
});
it("keeps an explicit initial thinking option when it is valid", () => {
const resolved = __private__.resolveFormState(
{ thinkingOptionId: "low" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("low");
});
it("leaves thinking unset when the model exposes options without a provider default", () => {
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "default",
label: "Default (Sonnet 4.6)",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
];
const resolved = __private__.resolveFormState(
undefined,
{ provider: "claude" },
claudeModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "claude",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
);
expect(resolved.model).toBe("default");
expect(resolved.thinkingOptionId).toBe("");
});
it("resolves provider only from allowed provider map", () => {

View File

@@ -11,7 +11,11 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
import {
useFormPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
// Explicit overrides from URL params or "New Agent" button
export interface FormInitialValues {
@@ -102,7 +106,7 @@ const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "
function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
if (!normalized) {
return "";
}
return normalized;
@@ -117,6 +121,10 @@ function resolveDefaultModel(
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
}
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
return resolveDefaultModel(availableModels)?.id ?? "";
}
function resolveEffectiveModel(
availableModels: AgentModelDefinition[] | null,
modelId: string,
@@ -134,9 +142,31 @@ function resolveEffectiveModel(
);
}
function resolveThinkingOptionId(args: {
availableModels: AgentModelDefinition[] | null;
modelId: string;
requestedThinkingOptionId: string;
}): string {
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
if (thinkingOptions.length === 0) {
return "";
}
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
if (
normalizedThinkingOptionId &&
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
) {
return normalizedThinkingOptionId;
}
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
}
/**
* Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > preferences > provider defaults > fallback
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
*
* Only resolves fields that haven't been user-modified.
*/
@@ -195,25 +225,22 @@ function resolveFormState(
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(availableModels);
if (initialModel) {
// If models aren't loaded yet, trust the initial value
// It will be validated once models load
if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel;
} else if (preferredModel && isValidModel(preferredModel)) {
result.model = preferredModel;
} else {
result.model = "";
result.model = defaultModelId;
}
} else if (preferredModel) {
// If models haven't loaded yet, optimistically apply the stored preference.
// We'll validate once models load and clear it if it isn't available.
if (!availableModels || isValidModel(preferredModel)) {
result.model = preferredModel;
} else {
result.model = "";
result.model = defaultModelId;
}
} else if (defaultModelId) {
result.model = defaultModelId;
} else {
result.model = "";
}
@@ -224,13 +251,17 @@ function resolveFormState(
typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? "";
if (!userModified.thinkingOptionId) {
const effectiveModelId = result.model.trim();
const preferredThinking = effectiveModelId
? providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? ""
: "";
if (initialThinkingOptionId.length > 0) {
result.thinkingOptionId = initialThinkingOptionId;
} else if (preferredThinkingOptionId.length > 0) {
result.thinkingOptionId = preferredThinkingOptionId;
} else if (preferredThinking.length > 0) {
result.thinkingOptionId = preferredThinking;
} else {
result.thinkingOptionId = "";
}
@@ -238,27 +269,17 @@ function resolveFormState(
// Validate thinking option once model metadata is available.
if (availableModels) {
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
if (thinkingOptions.length === 0) {
result.thinkingOptionId = "";
} else {
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
const defaultThinkingOptionId =
effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
result.thinkingOptionId = defaultThinkingOptionId;
}
}
result.thinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: result.model,
requestedThinkingOptionId: result.thinkingOptionId,
});
}
// 5. Resolve serverId (independent)
// Only use stored serverId if the host still exists in the registry
if (!userModified.serverId) {
if (initialValues?.serverId !== undefined) {
result.serverId = initialValues.serverId;
} else if (preferences?.serverId && validServerIds.has(preferences.serverId)) {
result.serverId = preferences.serverId;
}
// else keep current
}
@@ -267,8 +288,6 @@ function resolveFormState(
if (!userModified.workingDir) {
if (initialValues?.workingDir !== undefined) {
result.workingDir = initialValues.workingDir;
} else if (preferences?.workingDir) {
result.workingDir = preferences.workingDir;
}
// else keep current (empty string)
}
@@ -313,7 +332,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
preferences,
isLoading: isPreferencesLoading,
updatePreferences,
updateProviderPreferences,
} = useFormPreferences();
const daemons = useHosts();
@@ -537,107 +555,119 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
validServerIds,
]);
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
useEffect(() => {
if (!isVisible || !isCreateFlow) return;
if (isPreferencesLoading) return;
if (userModified.serverId) return;
const serverId = formState.serverId;
if (!serverId) return;
if (preferences?.serverId === serverId) return;
void updatePreferences({ serverId });
}, [
isVisible,
isCreateFlow,
isPreferencesLoading,
userModified.serverId,
formState.serverId,
preferences?.serverId,
updatePreferences,
]);
// User setters - mark fields as modified and persist to preferences
const setSelectedServerIdFromUser = useCallback(
(value: string | null) => {
setFormState((prev) => ({ ...prev, serverId: value }));
setUserModified((prev) => ({ ...prev, serverId: true }));
void updatePreferences({ serverId: value ?? undefined });
},
[updatePreferences],
[],
);
const setProviderFromUser = useCallback(
(provider: AgentProvider) => {
setFormState((prev) => ({ ...prev, provider }));
setUserModified((prev) => ({ ...prev, provider: true }));
void updatePreferences({ provider });
// When provider changes, reset mode and model to provider defaults
// (unless user has explicitly set them)
const providerModels = allProviderModels.get(provider) ?? null;
const providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
const isValidModel = (m: string) =>
providerModels?.some((am) => am.id === m) ?? false;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
const nextModelId =
preferredModel && (!providerModels || isValidModel(preferredModel))
? preferredModel
: defaultModelId;
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
const nextModeId =
providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)
? providerPrefs.mode
: providerDef?.defaultModeId ?? "";
const preferredThinking = nextModelId
? providerPrefs?.thinkingByModel?.[nextModelId]?.trim() ?? ""
: "";
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
modelId: nextModelId,
requestedThinkingOptionId: preferredThinking,
});
setUserModified((prev) => ({ ...prev, provider: true }));
void updatePreferences({ provider });
setFormState((prev) => ({
...prev,
provider,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
model: normalizeSelectedModelId(providerPrefs?.model),
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
modeId: nextModeId,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
}));
},
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
const providerDef = providerDefinitionMap.get(provider);
const providerPrefs = preferences?.providerPreferences?.[provider];
const providerModels = allProviderModels.get(provider) ?? null;
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels: providerModels,
modelId: nextModelId,
requestedThinkingOptionId: "",
});
setFormState((prev) => ({
...prev,
provider,
model: modelId,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
model: nextModelId,
modeId: providerDef?.defaultModeId ?? "",
thinkingOptionId: nextThinkingOptionId,
}));
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
void updatePreferences({ provider });
void updateProviderPreferences(provider, { model: modelId });
},
[
preferences?.providerPreferences,
providerDefinitionMap,
updatePreferences,
updateProviderPreferences,
],
[allProviderModels, providerDefinitionMap, updatePreferences],
);
const setModeFromUser = useCallback(
(modeId: string) => {
setFormState((prev) => ({ ...prev, modeId }));
setUserModified((prev) => ({ ...prev, modeId: true }));
void updateProviderPreferences(formState.provider, { mode: modeId });
},
[formState.provider, updateProviderPreferences],
[],
);
const setModelFromUser = useCallback(
(modelId: string) => {
const normalizedModelId = normalizeSelectedModelId(modelId);
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
const nextThinkingOptionId = resolveThinkingOptionId({
availableModels,
modelId: nextModelId,
requestedThinkingOptionId: userModified.thinkingOptionId
? formStateRef.current.thinkingOptionId
: "",
});
setFormState((prev) => ({
...prev,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
}));
setUserModified((prev) => ({ ...prev, model: true }));
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
},
[formState.provider, updateProviderPreferences],
[availableModels, userModified.thinkingOptionId],
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
void updateProviderPreferences(formState.provider, { thinkingOptionId });
},
[formState.provider, updateProviderPreferences],
[],
);
const setWorkingDir = useCallback((value: string) => {
@@ -648,9 +678,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
(value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
setUserModified((prev) => ({ ...prev, workingDir: true }));
void updatePreferences({ workingDir: value });
},
[updatePreferences],
[],
);
const setSelectedServerId = useCallback((value: string | null) => {
@@ -662,39 +691,42 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [providerModelsQuery]);
const persistFormPreferences = useCallback(async () => {
const providerPreferenceUpdates: {
mode: string;
model: string;
thinkingOptionId?: string;
} = {
mode: formState.modeId,
model: formState.model,
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
const nextProviderPrefs: ProviderPreferences = {
model: modelId || undefined,
mode: formState.modeId || undefined,
thinkingByModel: {
...existingProviderPrefs?.thinkingByModel,
...(modelId && formState.thinkingOptionId
? { [modelId]: formState.thinkingOptionId }
: {}),
},
};
if (userModified.thinkingOptionId) {
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
}
await updatePreferences({
workingDir: formState.workingDir,
provider: formState.provider,
serverId: formState.serverId ?? undefined,
providerPreferences: {
...preferences?.providerPreferences,
[formState.provider]: nextProviderPrefs,
},
});
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
}, [
formState.modeId,
availableModels,
formState.model,
formState.modeId,
formState.provider,
formState.serverId,
formState.thinkingOptionId,
formState.workingDir,
userModified.thinkingOptionId,
preferences?.providerPreferences,
updatePreferences,
updateProviderPreferences,
]);
const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? [];
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError =
@@ -711,7 +743,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setProviderFromUser,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: formState.model,
selectedModel: resolvedModelId,
setModelFromUser,
selectedThinkingOptionId: formState.thinkingOptionId,
setThinkingOptionFromUser,
@@ -737,7 +769,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
formState.serverId,
formState.provider,
formState.modeId,
formState.model,
resolvedModelId,
formState.thinkingOptionId,
formState.workingDir,
setSelectedServerId,
@@ -771,5 +803,7 @@ export type CreateAgentInitialValues = FormInitialValues;
export const __private__ = {
combineInitialValues,
resolveDefaultModel,
resolveFormState,
resolveThinkingOptionId,
};

View File

@@ -1,5 +1,17 @@
import { useRef } from "react";
import type { Agent } from "@/contexts/session-context";
export interface AgentScreenAgent {
serverId: string;
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
projectPlacement?: {
checkout?: {
cwd?: string;
isGit?: boolean;
};
} | null;
}
export type AgentScreenMissingState =
| { kind: "idle" }
@@ -8,8 +20,8 @@ export type AgentScreenMissingState =
| { kind: "error"; message: string };
export interface AgentScreenMachineInput {
agent: Agent | null;
placeholderAgent: Agent | null;
agent: AgentScreenAgent | null;
placeholderAgent: AgentScreenAgent | null;
missingAgentState: AgentScreenMissingState;
isConnected: boolean;
isArchivingCurrentAgent: boolean;
@@ -31,7 +43,7 @@ export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: Agent | null;
lastReadyAgent: AgentScreenAgent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
@@ -70,7 +82,7 @@ export type AgentScreenViewState =
}
| {
tag: "ready";
agent: Agent;
agent: AgentScreenAgent;
source: "authoritative" | "optimistic" | "stale";
sync: AgentScreenReadySyncState;
isArchiving: boolean;

View File

@@ -2,7 +2,6 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
@@ -10,13 +9,11 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingOptionId: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
});
const formPreferencesSchema = z.object({
workingDir: z.string().optional(),
provider: z.string().optional(),
serverId: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
});
@@ -35,12 +32,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
export interface UseFormPreferencesReturn {
preferences: FormPreferences;
isLoading: boolean;
getProviderPreferences: (provider: AgentProvider) => ProviderPreferences | undefined;
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
updateProviderPreferences: (
provider: AgentProvider,
updates: Partial<ProviderPreferences>,
) => Promise<void>;
}
export function useFormPreferences(): UseFormPreferencesReturn {
@@ -54,13 +46,6 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const getProviderPreferences = useCallback(
(provider: AgentProvider): ProviderPreferences | undefined => {
return preferences.providerPreferences?.[provider];
},
[preferences.providerPreferences],
);
const updatePreferences = useCallback(
async (updates: Partial<FormPreferences>) => {
const prev =
@@ -73,32 +58,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
[queryClient],
);
const updateProviderPreferences = useCallback(
async (provider: AgentProvider, updates: Partial<ProviderPreferences>) => {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next: FormPreferences = {
...prev,
providerPreferences: {
...prev.providerPreferences,
[provider]: {
...prev.providerPreferences?.[provider],
...updates,
},
},
};
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
return {
preferences,
isLoading: isPending,
getProviderPreferences,
updatePreferences,
updateProviderPreferences,
};
}

View File

@@ -89,6 +89,43 @@ describe("keyboard-action-dispatcher", () => {
expect(handle).toHaveBeenCalledTimes(1);
});
it("dispatches to the active mounted tab when a newer hidden tab is inactive", () => {
const calls: string[] = [];
const action: KeyboardActionDefinition = {
id: "message-input.dictation-toggle",
scope: "message-input",
};
dispatcher.registerHandler({
handlerId: "visible-tab",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => true,
handle: () => {
calls.push("visible-tab");
return true;
},
});
dispatcher.registerHandler({
handlerId: "hidden-tab",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => false,
handle: () => {
calls.push("hidden-tab");
return true;
},
});
const handled = dispatcher.dispatch(action);
expect(handled).toBe(true);
expect(calls).toEqual(["visible-tab"]);
});
it("tries lower-priority handlers when a higher one does not consume the action", () => {
const calls: string[] = [];
const action: KeyboardActionDefinition = {

View File

@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Bot } from "lucide-react-native";
import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
@@ -16,6 +18,7 @@ import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import {
useAgentScreenStateMachine,
type AgentScreenAgent,
type AgentScreenMissingState,
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -78,11 +81,21 @@ function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const agent = useSessionStore(
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null,
const descriptorState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
};
}),
);
const provider = agent?.provider ?? "codex";
const label = resolveWorkspaceAgentTabLabel(agent?.title);
const provider = descriptorState.provider;
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
return {
@@ -90,12 +103,12 @@ function useAgentPanelDescriptor(
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon,
statusBucket: agent
statusBucket: descriptorState.status
? deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
status: descriptorState.status,
pendingPermissionCount: descriptorState.pendingPermissionCount,
requiresAttention: descriptorState.requiresAttention,
attentionReason: descriptorState.attentionReason,
})
: null,
};
@@ -243,8 +256,25 @@ function AgentPanelBody({
addImagesRef.current = addImages;
}, []);
const agent = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined,
const agentState = useSessionStore(
useShallow((state) => {
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
archivedAt: agent?.archivedAt ?? null,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
};
}),
);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
@@ -318,52 +348,14 @@ function AgentPanelBody({
agentId,
client,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
requiresAttention: agentState.requiresAttention,
attentionReason: agentState.attentionReason,
isScreenFocused: isPaneFocused,
});
useEffect(() => {
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
}, [attentionController.clearOnAgentBlur]);
// ---------------------------------------------------------------------------
// DEBUG: track which selector values change between renders
// ---------------------------------------------------------------------------
const debugPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugPrevRef.current;
const curr: Record<string, unknown> = {
agent,
"agent?.status": agent?.status,
"agent?.cwd": agent?.cwd,
"agent?.updatedAt": agent?.updatedAt,
"agent?.requiresAttention": agent?.requiresAttention,
streamItemsRaw,
"streamItems.length": streamItems.length,
allPendingPermissions,
isInitializingFromMap,
historySyncGeneration,
hasAppliedAuthoritativeHistory,
agentHistorySyncGeneration,
hasSession,
isPaneFocused,
isConnected,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentPanelBody] values changed:", changed.join(", "), {
changed: Object.fromEntries(changed.map((k) => [k, { prev: prev[k], curr: curr[k] }])),
});
}
debugPrevRef.current = curr;
});
// ---------------------------------------------------------------------------
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
@@ -488,49 +480,34 @@ function AgentPanelBody({
}, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0;
const authoritativeStatus = agent?.status;
const authoritativeStatus = agentState.status;
const isAuthoritativeBootstrapping =
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
const showPendingCreateSubmitLoading =
isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping);
const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping;
const placeholderAgent: Agent | null = useMemo(() => {
const agent: AgentScreenAgent | null =
agentState.serverId && agentState.id && agentState.status && agentState.cwd
? {
serverId: agentState.serverId,
id: agentState.id,
status: agentState.status,
cwd: agentState.cwd,
projectPlacement,
}
: null;
const placeholderAgent: AgentScreenAgent | null = useMemo(() => {
if (!shouldUseOptimisticStream || !agentId) {
return null;
}
const now = new Date();
return {
serverId,
id: agentId,
provider: "claude",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: "claude",
sessionId: null,
model: null,
modeId: null,
},
title: "Agent",
cwd: ".",
model: null,
labels: {},
projectPlacement: null,
};
}, [agentId, serverId, shouldUseOptimisticStream]);
@@ -642,7 +619,7 @@ function AgentPanelBody({
if (!agentId) {
return;
}
if (agent || shouldUseOptimisticStream) {
if (agentState.id || shouldUseOptimisticStream) {
if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" });
}
@@ -717,7 +694,7 @@ function AgentPanelBody({
setMissingAgentState({ kind: "error", message });
});
}, [
agent,
agentState.id,
agentId,
client,
ensureAgentIsInitialized,
@@ -803,10 +780,11 @@ function AgentPanelBody({
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? (
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
<AgentInputArea
agentId={agentId}
serverId={serverId}
isInputActive={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
images={agentInputDraft.images}
@@ -831,7 +809,7 @@ function AgentPanelBody({
streamViewRef.current?.scrollToBottom("message-sent");
}}
/>
) : agentId && agent?.archivedAt ? (
) : agentId && agentState.archivedAt ? (
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
) : null}

View File

@@ -17,8 +17,15 @@ function useDraftPanelDescriptor() {
}
function DraftPanel() {
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
usePaneContext();
const {
serverId,
workspaceId,
tabId,
target,
isPaneFocused,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
@@ -27,6 +34,7 @@ function DraftPanel() {
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
isPaneFocused={isPaneFocused}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}

View File

@@ -1069,7 +1069,8 @@ export class HostRuntimeController {
}
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767";
const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const E2E_STORAGE_KEY = "@paseo:e2e";
@@ -1136,7 +1137,7 @@ export class HostRuntimeStore {
}
}
async bootstrap(): Promise<void> {
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
if (this.bootstrapAttempted) {
return;
}
@@ -1152,7 +1153,9 @@ export class HostRuntimeStore {
}
if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop();
if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
} else {
await this.bootstrapLocalhost();
}
@@ -1162,20 +1165,16 @@ export class HostRuntimeStore {
try {
const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen);
if (!connection) {
if (!connection || !daemon.serverId) {
return;
}
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
label: hostname ?? daemon.hostname ?? undefined,
serverId: daemon.serverId,
label: daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
}
}

View File

@@ -744,6 +744,20 @@ function DraftAgentScreenContent({
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
const createAgentClient = sessionClient;
const effectiveDraftModelId = useMemo(() => {
if (selectedModel.trim()) {
return selectedModel.trim();
}
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
}, [availableModels, selectedModel]);
const effectiveDraftThinkingOptionId = useMemo(() => {
if (selectedThinkingOptionId.trim()) {
return selectedThinkingOptionId.trim();
}
const selectedModelDefinition =
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
@@ -756,18 +770,18 @@ function DraftAgentScreenContent({
provider: selectedProvider,
cwd,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
};
}, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
isAttachWorktree,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
selectedWorktreePath,
workingDir,
]);
@@ -801,6 +815,12 @@ function DraftAgentScreenContent({
if (gitBlockingError) {
return gitBlockingError;
}
if (isModelLoading) {
return "Model defaults are still loading";
}
if (!effectiveDraftModelId) {
return "No model is available for the selected provider";
}
if (isAttachWorktree && !selectedWorktreePath) {
return "Select a worktree to attach";
}
@@ -832,8 +852,8 @@ function DraftAgentScreenContent({
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
".";
const provider = selectedProvider;
const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const model = effectiveDraftModelId || null;
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
@@ -869,14 +889,14 @@ function DraftAgentScreenContent({
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
};
const effectiveBaseBranch = baseBranch.trim();
@@ -1199,6 +1219,7 @@ function DraftAgentScreenContent({
<AgentInputArea
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isInputActive={isFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}

View File

@@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store";
import { encodeImages } from "@/utils/encode-images";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type {
AgentCapabilityFlags,
AgentSessionConfig,
@@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = {
workspaceId: string;
tabId: string;
draftId: string;
isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (input: { filePath: string }) => void;
};
@@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({
workspaceId,
tabId,
draftId,
isPaneFocused,
onCreated,
onOpenWorkspaceFile,
}: WorkspaceDraftAgentTabProps) {
@@ -92,6 +95,22 @@ export function WorkspaceDraftAgentTab({
setWorkingDir(workspaceId);
}, [setWorkingDir, workingDir, workspaceId]);
const effectiveDraftModelId = useMemo(() => {
if (selectedModel.trim()) {
return selectedModel.trim();
}
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
}, [availableModels, selectedModel]);
const effectiveDraftThinkingOptionId = useMemo(() => {
if (selectedThinkingOptionId.trim()) {
return selectedThinkingOptionId.trim();
}
const selectedModelDefinition =
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const {
formErrorMessage,
isSubmitting,
@@ -108,6 +127,12 @@ export function WorkspaceDraftAgentTab({
if (providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (isModelLoading) {
return "Model defaults are still loading";
}
if (!effectiveDraftModelId) {
return "No model is available for the selected provider";
}
if (!client) {
return "Host is not connected";
}
@@ -122,8 +147,8 @@ export function WorkspaceDraftAgentTab({
},
buildDraftAgent: (attempt) => {
const now = attempt.timestamp;
const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const model = effectiveDraftModelId || null;
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
serverId,
@@ -153,14 +178,14 @@ export function WorkspaceDraftAgentTab({
}
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: workspaceId,
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
};
const imagesData = await encodeImages(images);
@@ -186,17 +211,17 @@ export function WorkspaceDraftAgentTab({
provider: selectedProvider,
cwd: workspaceId,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
};
}, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
workspaceId,
]);
@@ -243,6 +268,7 @@ export function WorkspaceDraftAgentTab({
<AgentInputArea
agentId={tabId}
serverId={serverId}
isInputActive={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
@@ -251,7 +277,7 @@ export function WorkspaceDraftAgentTab({
images={draftInput.images}
onChangeImages={draftInput.setImages}
clearDraft={draftInput.clear}
autoFocus={!isSubmitting}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
statusControls={{

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { shouldAutoFocusWorkspaceDraftComposer } from "./workspace-draft-pane-focus";
describe("shouldAutoFocusWorkspaceDraftComposer", () => {
it("focuses the draft composer when the pane is focused and idle", () => {
expect(
shouldAutoFocusWorkspaceDraftComposer({
isPaneFocused: true,
isSubmitting: false,
}),
).toBe(true);
});
it("does not focus the draft composer when the pane is unfocused", () => {
expect(
shouldAutoFocusWorkspaceDraftComposer({
isPaneFocused: false,
isSubmitting: false,
}),
).toBe(false);
});
it("does not focus the draft composer while the draft is submitting", () => {
expect(
shouldAutoFocusWorkspaceDraftComposer({
isPaneFocused: true,
isSubmitting: true,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,6 @@
export function shouldAutoFocusWorkspaceDraftComposer(input: {
isPaneFocused: boolean;
isSubmitting: boolean;
}): boolean {
return input.isPaneFocused && !input.isSubmitting;
}

View File

@@ -37,4 +37,36 @@ describe("createMarkdownStyles", () => {
overflowWrap: "anywhere",
});
});
it("keeps assistant markdown text selectable on web", () => {
const styles = createMarkdownStyles(darkTheme);
expect(styles.body).toMatchObject({
userSelect: "text",
});
expect(styles.text).toMatchObject({
userSelect: "text",
});
expect(styles.heading1).toMatchObject({
userSelect: "text",
});
expect(styles.link).toMatchObject({
userSelect: "text",
});
expect(styles.code_inline).toMatchObject({
userSelect: "text",
});
expect(styles.code_block).toMatchObject({
userSelect: "text",
});
expect(styles.fence).toMatchObject({
userSelect: "text",
});
expect(styles.bullet_list_icon).toMatchObject({
userSelect: "text",
});
expect(styles.ordered_list_icon).toMatchObject({
userSelect: "text",
});
});
});

View File

@@ -1,6 +1,9 @@
import { Platform } from "react-native";
import type { Theme } from "./theme";
import { Fonts } from "@/constants/theme";
const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {};
/**
* Creates comprehensive markdown styles for react-native-markdown-display.
*
@@ -15,6 +18,7 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
body: {
...webSelectableTextStyle,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
@@ -24,6 +28,7 @@ export function createMarkdownStyles(theme: Theme) {
},
text: {
...webSelectableTextStyle,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
@@ -47,6 +52,7 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
heading1: {
...webSelectableTextStyle,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
@@ -59,6 +65,7 @@ export function createMarkdownStyles(theme: Theme) {
},
heading2: {
...webSelectableTextStyle,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
@@ -71,6 +78,7 @@ export function createMarkdownStyles(theme: Theme) {
},
heading3: {
...webSelectableTextStyle,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
@@ -80,6 +88,7 @@ export function createMarkdownStyles(theme: Theme) {
},
heading4: {
...webSelectableTextStyle,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
@@ -89,6 +98,7 @@ export function createMarkdownStyles(theme: Theme) {
},
heading5: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
@@ -98,6 +108,7 @@ export function createMarkdownStyles(theme: Theme) {
},
heading6: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foregroundMuted,
@@ -113,19 +124,23 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
strong: {
...webSelectableTextStyle,
fontWeight: theme.fontWeight.medium,
},
em: {
...webSelectableTextStyle,
fontStyle: "italic" as const,
},
s: {
...webSelectableTextStyle,
textDecorationLine: "line-through" as const,
color: theme.colors.foregroundMuted,
},
link: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
@@ -134,6 +149,7 @@ export function createMarkdownStyles(theme: Theme) {
},
blocklink: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
@@ -146,6 +162,7 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
code_inline: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1],
@@ -157,6 +174,7 @@ export function createMarkdownStyles(theme: Theme) {
},
code_block: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
@@ -167,6 +185,7 @@ export function createMarkdownStyles(theme: Theme) {
},
fence: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
@@ -200,6 +219,7 @@ export function createMarkdownStyles(theme: Theme) {
tbody: {},
th: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderBottomWidth: 1,
borderRightWidth: 1,
@@ -218,6 +238,7 @@ export function createMarkdownStyles(theme: Theme) {
},
td: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderRightWidth: 1,
borderColor: theme.colors.border,
@@ -260,6 +281,7 @@ export function createMarkdownStyles(theme: Theme) {
},
bullet_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
@@ -267,6 +289,7 @@ export function createMarkdownStyles(theme: Theme) {
},
ordered_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,

View File

@@ -20,12 +20,12 @@ describe("extractAgentModel", () => {
expect(extractAgentModel(agent)).toBe("gpt-5.1-codex");
});
it("treats legacy 'default' model ids as unset", () => {
it("preserves 'default' as a valid model id", () => {
const agent = {
model: "default",
runtimeInfo: { model: "default" },
} as Partial<Agent> as Agent;
expect(extractAgentModel(agent)).toBeNull();
expect(extractAgentModel(agent)).toBe("default");
});
});

View File

@@ -6,13 +6,13 @@ export function extractAgentModel(agent?: Agent | null): string | null {
const fallbackModel = agent.model;
if (typeof runtimeModel === "string") {
const normalized = runtimeModel.trim();
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
if (normalized.length > 0) {
return normalized;
}
}
if (typeof fallbackModel === "string") {
const normalized = fallbackModel.trim();
if (normalized.length > 0 && normalized.toLowerCase() !== "default") {
if (normalized.length > 0) {
return normalized;
}
}

View File

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { focusWithRetries } from "./web-focus";
describe("focusWithRetries", () => {
let frameQueue: FrameRequestCallback[] = [];
let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined;
beforeEach(() => {
frameQueue = [];
originalRequestAnimationFrame = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
frameQueue.push(callback);
return frameQueue.length;
}) as typeof requestAnimationFrame;
});
afterEach(() => {
if (originalRequestAnimationFrame) {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
return;
}
delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame })
.requestAnimationFrame;
});
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = frameQueue;
frameQueue = [];
for (const callback of callbacks) {
callback(index);
}
}
}
it("tries to focus immediately before waiting for animation frames", () => {
let focused = false;
const focus = vi.fn(() => {
focused = true;
});
const onSuccess = vi.fn();
focusWithRetries({
focus,
isFocused: () => focused,
onSuccess,
});
expect(focus).toHaveBeenCalledTimes(1);
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(frameQueue).toHaveLength(0);
});
it("keeps retrying on later animation frames until focus succeeds", () => {
let focused = false;
let attempts = 0;
const focus = vi.fn(() => {
attempts += 1;
if (attempts >= 3) {
focused = true;
}
});
const onSuccess = vi.fn();
focusWithRetries({
focus,
isFocused: () => focused,
onSuccess,
});
expect(focus).toHaveBeenCalledTimes(1);
expect(onSuccess).not.toHaveBeenCalled();
flushAnimationFrames(2);
expect(focus).toHaveBeenCalledTimes(2);
expect(onSuccess).not.toHaveBeenCalled();
flushAnimationFrames(2);
expect(focus).toHaveBeenCalledTimes(3);
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it("stops retrying after cancellation", () => {
const focus = vi.fn();
const cancel = focusWithRetries({
focus,
isFocused: () => false,
});
expect(focus).toHaveBeenCalledTimes(1);
cancel();
flushAnimationFrames(4);
expect(focus).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,9 +40,7 @@ export function focusWithRetries({
});
};
requestAnimationFrame(() => {
requestAnimationFrame(tick);
});
tick();
return () => {
cancelled = true;

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.32",
"version": "0.1.35",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.32",
"@getpaseo/server": "0.1.32",
"@getpaseo/relay": "0.1.35",
"@getpaseo/server": "0.1.35",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -17,6 +17,7 @@ import { addStopOptions, runStopCommand } from "./commands/agent/stop.js";
import { addSendOptions, runSendCommand } from "./commands/agent/send.js";
import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js";
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.js";
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
import { withOutput } from "./output/index.js";
import { onboardCommand } from "./commands/onboard.js";
@@ -93,6 +94,10 @@ export function createCli(): Command {
addWaitOptions(program.command("wait")),
).action(withOutput(runWaitCommand));
addJsonAndDaemonHostOptions(
addArchiveOptions(program.command("archive")),
).action(withOutput(runArchiveCommand));
// Top-level local daemon shortcuts
program.addCommand(onboardCommand());
program.addCommand(daemonStartCommand());

View File

@@ -1,4 +1,4 @@
import type { Command } from "commander";
import { Command } from "commander";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
@@ -24,6 +24,13 @@ export const archiveSchema: OutputSchema<AgentArchiveResult> = {
],
};
export function addArchiveOptions(cmd: Command): Command {
return cmd
.description('Archive an agent (soft-delete)')
.argument("<id>", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)");
}
export interface AgentArchiveOptions extends CommandOptions {
force?: boolean;
host?: string;

View File

@@ -1,6 +1,6 @@
import { Command } from "commander";
import { runModeCommand } from "./mode.js";
import { runArchiveCommand } from "./archive.js";
import { addArchiveOptions, runArchiveCommand } from "./archive.js";
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
import { addLsOptions, runLsCommand } from "./ls.js";
import { addRunOptions, runRunCommand } from "./run.js";
@@ -69,11 +69,7 @@ export function createAgentCommand(): Command {
).action(withOutput(runModeCommand));
addJsonAndDaemonHostOptions(
agent
.command("archive")
.description("Archive an agent (soft-delete)")
.argument("<id>", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)"),
addArchiveOptions(agent.command("archive")),
).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions(

View File

@@ -28,7 +28,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
};
export interface AgentSendOptions extends CommandOptions {
noWait?: boolean;
wait?: boolean;
image?: string[];
prompt?: string;
promptFile?: string;
@@ -193,7 +193,7 @@ export async function runSendCommand(
await client.sendAgentMessage(agentIdArg, promptInput, { images });
// If --no-wait, return immediately
if (options.noWait) {
if (options.wait === false) {
await client.close();
return {

View File

@@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -14,6 +14,7 @@ publish:
owner: getpaseo
repo: paseo
mac:
artifactName: "Paseo-${version}-${arch}.${ext}"
category: public.app-category.developer-tools
icon: assets/icon.icns
hardenedRuntime: true
@@ -30,11 +31,15 @@ linux:
category: Development
icon: assets
artifactName: "Paseo-${version}-${arch}.${ext}"
maintainer: "Mohamed Boudra <hello@moboudra.com>"
vendor: "Paseo"
extraResources:
- from: bin/paseo
to: bin/paseo
target:
- AppImage
- deb
- rpm
- tar.gz
win:
icon: assets/icon.ico

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.32",
"version": "0.1.35",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.32",
"@getpaseo/server": "0.1.32",
"@getpaseo/cli": "0.1.35",
"@getpaseo/server": "0.1.35",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -25,5 +25,15 @@
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"wait-on": "8.0.5"
}
},
"homepage": "https://paseo.sh",
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"license": "AGPL-3.0-or-later"
}

View File

@@ -7,8 +7,125 @@ const WRAPPER_SCRIPT = `#!/bin/bash
exec "$(dirname "$(readlink -f "$0")")/${EXECUTABLE_NAME}.bin" --no-sandbox "$@"
`;
// electron-builder arch enum → Node.js arch string
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
const RIPGREP_PLATFORM_DIR = {
darwin: { arm64: "arm64-darwin", x64: "x64-darwin" },
linux: { arm64: "arm64-linux", x64: "x64-linux" },
win32: { arm64: "arm64-win32", x64: "x64-win32" },
};
function rmSafe(target) {
fs.rmSync(target, { recursive: true, force: true });
}
function pruneChildrenExcept(parent, keep) {
if (!fs.existsSync(parent)) return;
for (const entry of fs.readdirSync(parent)) {
if (!keep.has(entry)) {
rmSafe(path.join(parent, entry));
}
}
}
function pruneOnnxRuntime(nodeModules, platform, arch) {
const onnxBin = path.join(nodeModules, "onnxruntime-node", "bin", "napi-v6");
if (!fs.existsSync(onnxBin)) return;
const otherPlatforms = ["darwin", "linux", "win32"].filter((p) => p !== platform);
for (const p of otherPlatforms) {
rmSafe(path.join(onnxBin, p));
}
pruneChildrenExcept(path.join(onnxBin, platform), new Set([arch]));
if (platform === "linux") {
const archDir = path.join(onnxBin, "linux", arch);
if (fs.existsSync(archDir)) {
for (const name of fs.readdirSync(archDir)) {
if (name.includes("cuda") || name.includes("tensorrt")) {
fs.rmSync(path.join(archDir, name), { force: true });
}
}
}
}
}
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
if (!keepName) return;
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
}
function pruneNodePty(nodeModules, platform, arch) {
const prebuilds = path.join(nodeModules, "node-pty", "prebuilds");
pruneChildrenExcept(prebuilds, new Set([`${platform}-${arch}`]));
if (platform !== "win32") {
rmSafe(path.join(nodeModules, "node-pty", "third_party"));
}
}
function pruneSharpLibvips(nodeModules, platform, arch) {
const prefix = `sharp-libvips-${platform}-${arch}`;
const imgDir = path.join(nodeModules, "@img");
if (!fs.existsSync(imgDir)) return;
for (const entry of fs.readdirSync(imgDir)) {
if (entry.startsWith("sharp-") && entry !== prefix && !entry.startsWith(`sharp-${platform}-${arch}`)) {
rmSafe(path.join(imgDir, entry));
}
}
}
function pruneNativeModules(appOutDir, platform, arch) {
const resourcesDir =
platform === "darwin"
? path.join(appOutDir, `${EXECUTABLE_NAME}.app`, "Contents", "Resources")
: path.join(appOutDir, "resources");
const nodeModules = path.join(resourcesDir, "app.asar.unpacked", "node_modules");
if (!fs.existsSync(nodeModules)) return;
const before = dirSizeSync(nodeModules);
pruneOnnxRuntime(nodeModules, platform, arch);
pruneClaudeAgentSdk(nodeModules, platform, arch);
pruneNodePty(nodeModules, platform, arch);
pruneSharpLibvips(nodeModules, platform, arch);
const after = dirSizeSync(nodeModules);
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);
console.log(`Pruned native modules: ${savedMB} MB removed (${fmtMB(before)}${fmtMB(after)})`);
}
function dirSizeSync(dir) {
let total = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
if (entry.isFile()) {
try {
total += fs.statSync(path.join(entry.parentPath || entry.path, entry.name)).size;
} catch {}
}
}
return total;
}
function fmtMB(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== "linux") return;
const platform = context.electronPlatformName;
const arch = ARCH_MAP[context.arch] || process.arch;
pruneNativeModules(context.appOutDir, platform, arch);
if (platform !== "linux") return;
const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox");
if (fs.existsSync(chromeSandbox)) {

View File

@@ -168,6 +168,30 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
};
}
function resolveNodeExecPath(): string {
if (app.isPackaged && process.platform === "darwin") {
const marker = ".app/Contents/MacOS/";
const markerIndex = process.execPath.indexOf(marker);
if (markerIndex !== -1) {
const bundleRoot = process.execPath.substring(0, markerIndex + ".app".length);
const name = path.basename(process.execPath);
const helperPath = path.join(
bundleRoot,
"Contents",
"Frameworks",
`${name} Helper.app`,
"Contents",
"MacOS",
`${name} Helper`,
);
if (existsSync(helperPath)) {
return helperPath;
}
}
}
return process.execPath;
}
export function createNodeEntrypointInvocation(input: {
entrypoint: NodeEntrypointSpec;
argvMode: NodeEntrypointArgvMode;
@@ -175,7 +199,7 @@ export function createNodeEntrypointInvocation(input: {
baseEnv: NodeJS.ProcessEnv;
}): NodeEntrypointInvocation {
return createSharedNodeEntrypointInvocation({
execPath: process.execPath,
execPath: resolveNodeExecPath(),
isPackaged: app.isPackaged,
packagedRunnerPath: app.isPackaged
? assertPathExists({

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.32",
"version": "0.1.35",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,10 +1,15 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.32",
"private": true,
"version": "0.1.35",
"type": "module",
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"types": "./src/index.ts",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.32",
"version": "0.1.35",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,339 +0,0 @@
# Voice Assistant System Prompt
## 1. Core Voice Rules (NON-NEGOTIABLE)
### Voice Context
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
### Voice Message Envelope
Some user utterances will be wrapped in an XML tag like:
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
- Treat the inner text as what the user said (STT output).
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
**Critical constraints:**
- User typically codes from their **phone** using voice
- **No visual feedback** - they can't see command output unless at laptop
- Input comes through **speech-to-text (STT)** which makes errors
- Output is spoken via **text-to-speech (TTS)**
- User may be mobile, away from desk, multitasking
### Communication Rules
**1-3 sentences maximum per response. Always.**
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
- **Progressive disclosure** - answer what's asked, let user ask for more
- **Start high-level** - give the gist, not every detail
- **Natural pauses** - leave room for user to respond or redirect
**Good example:**
```
User: "List my agents"
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
User: "How's the auth agent doing?"
You: "It finished adding the login flow and is waiting for your approval on the database migration."
```
**Bad example:**
```
User: "List my agents"
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
```
### Handling STT Errors
Speech-to-text makes mistakes. Fix them silently using context.
**Common errors:**
- Homophones: "list" → "missed", "code" → "load"
- Project names: "faro" → "pharaoh", "mcp" → "empty"
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
**How to handle:**
1. Use context to fix obvious mistakes silently
2. Ask for clarification only when truly ambiguous
3. Never lecture about the error - just handle it
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
**Examples:**
- User: "Run empty install" → Interpret as "Run npm install"
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
### Immediate Silence Protocol
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
- "I'm not talking to you"
- "Shut up" / "Be quiet" / "Stop talking"
- "Not you"
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
## 2. Delegation Pattern
### Core Rule: Always Work Through Coding Agents
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
### Safe Operations (Execute Immediately)
These orchestration tools only read or summarize state:
- `list_agents()` discover who exists before delegating
- `get_agent_activity()` pull the curated activity/readout for a specific agent
- `wait_for_agent()` block until an agent requests permission or completes the current run
Call them without asking when context requires it. Never fabricate their output.
### Delegated Operations (Announce + Execute)
- `create_agent()` Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
- `send_agent_prompt()` Route the users request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` Only when the user directs you to or the agent is stuck. Confirm destructive actions.
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
### When to Ask vs Act
Ask only when the routing decision is truly ambiguous. Otherwise:
- Default to the most recently addressed agent.
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
### Tool Results Reporting
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
## 4. Agent Integrations
### Your Role: Orchestrator
You orchestrate work. Agents execute.
**First action when agent work is mentioned: Call `list_agents()`**
Load the agent list before any agent interaction. Always.
#### Focus Management
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
- Always narrate hand-offs: “Okay, handing that to Planner.”
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
**Confirm before destructive agent operations:**
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
- Killing agents: "Kill agent [id] working on [task]?"
**Delegate vs execute:**
- Everything touching code, git, or shell runs through agents
- Keep lightweight questions or summaries in-orchestrator when no action is needed
- Active agent context → Send prompt to that agent (respect focus)
### Available Agents (Source of Truth)
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
**Claude Code (`claude`)**
- Default mode: `plan`
- Alternate mode: `bypassPermissions`
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
**Codex (`codex`)**
- Default mode: `auto`
- Other modes: `read-only`, `full-access`
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
### Creating Agents
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
```javascript
// Claude Code with planning
create_agent({
cwd: "~/dev/paseo",
agentType: "claude",
initialPrompt: "add dark mode toggle to settings page",
initialMode: "plan"
})
// Codex for quick edits
create_agent({
cwd: "~/dev/paseo",
agentType: "codex",
initialPrompt: "clean up the logging",
initialMode: "auto"
})
```
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
### Working with Agents
**Send prompts to agents:**
```javascript
// Send task (non-blocking by default)
send_agent_prompt({
agentId: "abc123",
prompt: "explain how authentication works"
})
// Returns immediately, agent processes in background
// Send task and wait for completion
send_agent_prompt({
agentId: "abc123",
prompt: "fix the bug in auth.ts",
maxWait: 60000 // Wait up to 60 seconds
})
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
send_agent_prompt({
agentId: "abc123",
prompt: "implement user registration",
sessionMode: "bypassPermissions"
})
```
**Check agent status:**
```javascript
// Get current status
get_agent_status({ agentId: "abc123" })
// Returns: { status: "processing", info: {...} }
// Get agent activity (curated, human-readable)
get_agent_activity({
agentId: "abc123",
format: "curated" // Clean summary of what agent did
})
// List all agents
list_agents()
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
```
**Control agents:**
```javascript
// Change session mode (safe, no confirmation needed)
set_agent_mode({
agentId: "abc123",
modeId: "plan"
})
// Cancel current task (safe, no confirmation needed)
cancel_agent({ agentId: "abc123" })
// Kill agent (REQUIRES confirmation first)
kill_agent({ agentId: "abc123" })
```
### Agent Workflow Pattern
```javascript
// 1. Load agents first
list_agents()
// 2. If creating new agent, confirm first
// You: "Create agent in ~/dev/project for authentication?"
// User: "yes"
// 3. Create with type + mode
create_agent({
cwd: "~/dev/project",
agentType: "claude",
initialPrompt: "add authentication",
initialMode: "plan"
})
// 4. Monitor or send follow-up tasks
get_agent_activity({ agentId })
send_agent_prompt({ agentId, prompt: "add tests" })
```
## 5. Git & GitHub
### Git Worktree Utilities
Custom utilities for safe worktree management:
**create-worktree:**
- Creates new git worktree with new branch
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
- Outputs WORKTREE_PATH for you to parse
**delete-worktree:**
- Preserves the branch, only deletes directory
- Safe to use - won't lose work
- Run from within worktree directory
### GitHub CLI (gh)
Already authenticated. Use for:
- Creating PRs: `gh pr create`
- Viewing PRs: `gh pr view`
- Managing issues: `gh issue list`
- Checking CI: `gh pr checks`
## 6. Projects & Context
### Project Locations
All projects in `~/dev`:
**paseo**
- Location: `~/dev/paseo`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
- Bare repo: `~/dev/faro`
- Main checkout: `~/dev/faro/main`
**Blank.page** (Minimal browser text editor)
- Location: `~/dev/blank.page/editor`
### Decision Rules
**Agent work mentioned?**
1. Call `list_agents()` first
2. Reuse existing agent if task relates to its work
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
**Creating/killing agents?**
- Ask: "Create agent in [dir] for [task]?" when intent isnt explicit
- Ask: "Kill agent [id]?"
- Wait for "yes"
**Task routing:**
- All coding tasks → Delegate to an agent
- Active agent + related work → Delegate to that agent
- If the user explicitly mentions another agent, switch focus before delegating
**Context tracking:**
- Track active agents and their directories
- Use conversation context to resolve ambiguity
- Fix STT errors silently
- Maintain a recency-based focus pointer and narrate any focus change out loud
### Core Reminders
- Call actual tools, never just describe
- 1-3 sentences max per response
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
- Default to action when context is clear

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.32",
"version": "0.1.35",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -13,8 +13,7 @@
"dist/scripts",
"src/server/speech/providers/local/sherpa/assets",
"README.md",
".env.example",
"agent-prompt.md"
".env.example"
],
"exports": {
".": {
@@ -64,8 +63,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "*",
"@getpaseo/relay": "0.1.32",
"@getpaseo/highlight": "0.1.35",
"@getpaseo/relay": "0.1.35",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",

View File

@@ -73,6 +73,29 @@ describe("curateAgentActivity", () => {
expect(result).toContain("[Shell] npm test");
});
it("renders terminal tool calls as one-line command summaries", () => {
const timeline: AgentTimelineItem[] = [
toolCallItem({
callId: "terminal-1",
name: "terminal",
detail: {
type: "plain_text",
label: `skills/paseo-chat/bin/chat.sh post --room storage-revamp --body $'first line
second line'`,
icon: "square_terminal",
},
}),
];
const result = curateAgentActivity(timeline);
expect(result).toContain(
"[Terminal] skills/paseo-chat/bin/chat.sh post --room storage-revamp --body $'first line second line'",
);
expect(result).not.toContain("[Interacted with terminal]");
});
it("does not infer summary from raw input when detail is missing", () => {
const timeline: AgentTimelineItem[] = [
toolCallItem({

View File

@@ -4,6 +4,7 @@ import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
const DEFAULT_MAX_ITEMS = 40;
const MAX_TOOL_INPUT_CHARS = 400;
const MAX_TOOL_SUMMARY_CHARS = 200;
function appendText(buffer: string, text: string): string {
const normalized = text.trim();
@@ -45,6 +46,20 @@ function formatToolInputJson(input: unknown): string | null {
}
}
function formatToolSummary(summary: string | undefined): string | null {
if (typeof summary !== "string") {
return null;
}
const normalized = summary.replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
if (normalized.length <= MAX_TOOL_SUMMARY_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_TOOL_SUMMARY_CHARS - 3)}...`;
}
function hasNonEmptyObject(value: unknown): boolean {
return (
typeof value === "object" &&
@@ -215,7 +230,7 @@ export function curateAgentActivity(
metadata: item.metadata,
});
const displayName = display.displayName;
const summary = display.summary;
const summary = formatToolSummary(display.summary);
if (isLikelyExternalToolName(item.name) && inputJson) {
lines.push(`[${displayName}] ${inputJson}`);
break;

View File

@@ -137,10 +137,7 @@ function startAgentRun(
logger: Logger,
options?: { replaceRunning?: boolean },
): void {
const snapshot = agentManager.getAgent(agentId);
const shouldReplace =
options?.replaceRunning &&
Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun));
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt)
: agentManager.streamAgent(agentId, prompt);
@@ -527,7 +524,7 @@ export async function createAgentManagementMcpServer(
throw new Error(`Agent ${agentId} not found`);
}
if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -40,8 +40,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
} = overrides;
const sessionValue = lifecycle === "closed" ? null : (restOverrides.session ?? ({} as any));
const pendingRunValue =
restOverrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
const activeForegroundTurnIdValue =
restOverrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
const lastErrorValue =
restOverrides.lastError ?? (lifecycle === "error" ? "encountered error" : undefined);
@@ -69,7 +69,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
],
currentModeId: "plan",
pendingPermissions: pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
activeForegroundTurnId: activeForegroundTurnIdValue,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: [],
runtimeInfo: {
provider: "claude",

View File

@@ -259,23 +259,25 @@ export type AgentTimelineItem =
export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
| { type: "turn_started"; provider: AgentProvider }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage }
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
| {
type: "turn_failed";
provider: AgentProvider;
error: string;
code?: string;
diagnostic?: string;
turnId?: string;
}
| { type: "turn_canceled"; provider: AgentProvider; reason: string }
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider }
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest }
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string }
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest; turnId?: string }
| {
type: "permission_resolved";
provider: AgentProvider;
requestId: string;
resolution: AgentPermissionResponse;
turnId?: string;
}
| {
type: "attention_required";
@@ -382,12 +384,17 @@ export type AgentSessionConfig = {
internal?: boolean;
};
export interface AgentLaunchContext {
env?: Record<string, string>;
}
export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator<AgentStreamEvent>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
@@ -398,19 +405,8 @@ export interface AgentSession {
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;
/**
* List available slash commands for this session.
* Commands are provider-specific - Claude supports skills and built-in commands.
*/
listCommands?(): Promise<AgentSlashCommand[]>;
/**
* Update the model used for subsequent turns (if supported by provider).
*/
setModel?(modelId: string | null): Promise<void>;
/**
* Update the thinking/effort setting used for subsequent turns (if supported).
* Normalized to a string option id (provider-specific interpretation).
*/
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
@@ -421,10 +417,14 @@ export interface ListModelsOptions {
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig): Promise<AgentSession>;
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;

View File

@@ -15,12 +15,12 @@ import type {
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "pendingRun"
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
pendingRun?: ManagedAgent["pendingRun"];
activeForegroundTurnId?: string | null;
runtimeInfo?: ManagedAgent["runtimeInfo"];
attention?: ManagedAgent["attention"];
};
@@ -42,8 +42,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
mcpServers: configOverrides.mcpServers,
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const pendingRun =
overrides.pendingRun ?? (lifecycle === "running" ? (async function* noop() {})() : null);
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
const agent: ManagedAgent = {
id: overrides.id ?? "agent-test",
@@ -65,7 +65,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
pendingRun,
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
attention: overrides.attention ?? { requiresAttention: false },
runtimeInfo: overrides.runtimeInfo ?? {

View File

@@ -199,10 +199,7 @@ function startAgentRun(
logger: Logger,
options?: { replaceRunning?: boolean },
): void {
const snapshot = agentManager.getAgent(agentId);
const shouldReplace =
options?.replaceRunning &&
Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun));
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt)
: agentManager.streamAgent(agentId, prompt);
@@ -696,7 +693,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
throw new Error(`Agent ${agentId} not found`);
}
if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
@@ -15,6 +16,16 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
function modelMatchesFamily(
model: AgentModelDefinition,
family: "sonnet" | "haiku",
): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
return haystacks.some((text) => text.includes(family));
}
describe("provider model catalogs (e2e)", () => {
let ctx: DaemonTestContext;
@@ -32,11 +43,8 @@ describe("provider model catalogs (e2e)", () => {
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
const descriptions = result.models.map((model) =>
`${model.label} ${model.description ?? ""}`.toLowerCase(),
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
}, 180_000);
test.runIf(hasCodex)(

View File

@@ -111,6 +111,26 @@ describe("applyProviderEnv", () => {
expect(env.PATH).toBe("/custom/path");
});
test("strips parent Claude Code session env vars", () => {
const base = {
PATH: "/usr/bin",
CLAUDECODE: "1",
CLAUDE_CODE_ENTRYPOINT: "sdk-ts",
CLAUDE_CODE_SSE_PORT: "11803",
CLAUDE_AGENT_SDK_VERSION: "0.2.71",
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true",
};
const env = applyProviderEnv(base, undefined, {});
expect(env.PATH).toBe("/usr/bin");
expect(env.CLAUDECODE).toBeUndefined();
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined();
expect(env.CLAUDE_AGENT_SDK_VERSION).toBeUndefined();
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
});
});
describe("findExecutable", () => {

View File

@@ -125,16 +125,31 @@ export function resolveShellEnv(): Record<string, string> {
return cachedShellEnv;
}
// Env vars that indicate a running Claude Code session. If the daemon itself is
// launched from inside Claude Code (e.g. by a Paseo agent), these leak into
// child processes and cause "cannot be launched inside another session" errors.
const PARENT_SESSION_ENV_VARS = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SSE_PORT",
"CLAUDE_AGENT_SDK_VERSION",
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
];
export function applyProviderEnv(
baseEnv: Record<string, string | undefined>,
runtimeSettings?: ProviderRuntimeSettings,
shellEnv?: Record<string, string>,
): Record<string, string | undefined> {
return {
const merged: Record<string, string | undefined> = {
...baseEnv,
...(shellEnv ?? resolveShellEnv()),
...(runtimeSettings?.env ?? {}),
};
for (const key of PARENT_SESSION_ENV_VARS) {
delete merged[key];
}
return merged;
}
/**

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js";
export type AgentModeColorTier = "default" | "safe" | "moderate" | "dangerous" | "readonly";
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning";
export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff";
export interface AgentModeVisuals {
@@ -44,7 +44,7 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
icon: "ShieldCheck",
colorTier: "readonly",
colorTier: "planning",
},
{
id: "bypassPermissions",
@@ -62,7 +62,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck",
colorTier: "readonly",
colorTier: "safe",
},
{
id: "auto",
@@ -86,14 +86,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
label: "Build",
description: "Allows edits and tool execution for implementation work",
icon: "ShieldCheck",
colorTier: "default",
colorTier: "moderate",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
icon: "ShieldCheck",
colorTier: "readonly",
colorTier: "planning",
},
];

View File

@@ -0,0 +1,500 @@
/**
* Integration tests for the agent event stream redesign (Unit 3).
*
* These tests verify the behavioral guarantees of the new provider contract
* (`startTurn` + `subscribe`) as specified in docs/design/agent-event-stream-redesign.md.
*
* All tests use REAL Claude SDK sessions — no mocks.
*
* CREDENTIALS: These tests require a running `claude` CLI and either
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
* They are skipped automatically when credentials are unavailable.
*/
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isCommandAvailable } from "../../provider-launch-config.js";
import { ClaudeAgentClient } from "../claude-agent.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRun = isCommandAvailable("claude") && hasClaudeCredentials;
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
type EventWithTurnId = AgentStreamEvent & { turnId: string };
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
}
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
}
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
return events.filter(
(e) =>
e.type === "timeline" &&
e.item.type === "user_message" &&
e.item.text === text,
);
}
async function createSession(params?: {
cwdPrefix?: string;
}): Promise<{ cwd: string; session: AgentSession }> {
const cwd = tmpCwd(params?.cwdPrefix ?? "event-stream-integration-");
const session = await client.createSession({
provider: "claude",
cwd,
title: "event-stream integration",
modeId: "acceptEdits",
model: "haiku",
});
return { cwd, session };
}
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
}
async function startTurnAndCollectEvents(
session: AgentSession,
prompt: string,
options?: { extraMs?: number; timeoutMs?: number },
): Promise<{ turnId: string; events: AgentStreamEvent[] }> {
const { extraMs = 0, timeoutMs = 45_000 } = options ?? {};
return await new Promise((resolve, reject) => {
const events: AgentStreamEvent[] = [];
let turnId: string | null = null;
let settled = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out after ${timeoutMs}ms waiting for terminal event`));
}, timeoutMs);
const finish = () => {
if (settled || !turnId) {
return;
}
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve({ turnId, events });
};
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (!turnId) {
return;
}
if (!isTerminalEvent(event) || !hasTurnId(event) || event.turnId !== turnId) {
return;
}
if (extraMs > 0) {
setTimeout(finish, extraMs);
return;
}
finish();
});
void session
.startTurn(prompt)
.then((result) => {
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
}
// ---------------------------------------------------------------------------
// Invariant assertions — run after every test
// ---------------------------------------------------------------------------
function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[]): void {
// Invariant 1: For each foreground turnId, at most ONE user_message event.
// The manager records foreground prompts separately, so provider echoes may be suppressed.
for (const turnId of foregroundTurnIds) {
const turnEvents = eventsForTurn(events, turnId);
const userMsgs = turnEvents.filter(
(e) => e.type === "timeline" && e.item.type === "user_message",
);
expect(
userMsgs.length,
`Expected at most 1 user_message for turnId ${turnId}, got ${userMsgs.length}`,
).toBeLessThanOrEqual(1);
}
// Invariant 2: Every turn_started has exactly one matching terminal
const turnStartedIds = events
.filter((e) => e.type === "turn_started" && hasTurnId(e))
.map((e) => (e as EventWithTurnId).turnId);
for (const turnId of turnStartedIds) {
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
expect(
terminals.length,
`Expected exactly 1 terminal for turnId ${turnId}, got ${terminals.length}`,
).toBe(1);
}
// Invariant 3: After terminal for a foreground turnId, no later event with
// that turnId appears (would indicate stale routing to autonomous)
for (const turnId of foregroundTurnIds) {
const allWithTurn = events
.map((e, i) => ({ event: e, index: i }))
.filter(({ event }) => hasTurnId(event) && event.turnId === turnId);
const terminalEntry = allWithTurn.find(({ event }) => isTerminalEvent(event));
if (!terminalEntry) continue;
const afterTerminal = allWithTurn.filter(({ index }) => index > terminalEntry.index);
expect(
afterTerminal.length,
`No events should appear for foreground turnId ${turnId} after terminal`,
).toBe(0);
}
// Invariant 4: Autonomous turns have distinct turnIds from foreground turns
const allTurnIds = new Set(
events.filter(hasTurnId).map((e) => e.turnId),
);
const autonomousTurnIds = [...allTurnIds].filter(
(id) => !foregroundTurnIds.includes(id),
);
for (const autoId of autonomousTurnIds) {
expect(foregroundTurnIds).not.toContain(autoId);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Agent event stream redesign — integration", () => {
test.skipIf(!canRun)("Test 1: Basic foreground turn", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(turnStarted).toBeDefined();
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 2: No duplicate user_messages — THE BUG", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events.slice(terminalIdx + 1).filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 3: Lifecycle doesn't get stuck in running", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
).length,
).toBe(0);
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 4: Autonomous run", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test.skipIf(!canRun)("Test 5: Interruption", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 6: Sequential foreground turns", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
const allEvents = [...events1, ...events2];
expect(turnId1).not.toBe(turnId2);
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
.length,
).toBe(0);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test.skipIf(!canRun)("Test 7: Fast-fail", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length,
).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 8: User message dedup by text", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
});

View File

@@ -0,0 +1,153 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../agent-sdk-types.js";
import { ClaudeAgentClient } from "./claude-agent.js";
function createQueryMock(events: unknown[]) {
let index = 0;
return {
next: vi.fn(async () =>
index < events.length
? { done: false, value: events[index++] }
: { done: true, value: undefined },
),
return: vi.fn(async () => ({ done: true, value: undefined })),
interrupt: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
}
describe("Claude agent env", () => {
test("forwards launch-context env through Claude process env", async () => {
let capturedEnv: Record<string, string | undefined> | undefined;
const launchContext: AgentLaunchContext = {
env: {
PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201",
PASEO_TEST_FLAG: "launch-value",
},
};
const queryFactory = vi.fn(
({ options }: { options: { env?: Record<string, string | undefined> } }) => {
capturedEnv = options.env;
return createQueryMock([
{
type: "system",
subtype: "init",
session_id: "managed-agent-env-session",
permissionMode: "default",
model: "opus",
},
{
type: "assistant",
message: { content: "done" },
},
{
type: "result",
subtype: "success",
usage: {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
},
total_cost_usd: 0,
},
]);
},
);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: queryFactory as never,
});
const session = await client.createSession(
{
provider: "claude",
cwd: process.cwd(),
},
launchContext,
);
try {
const result = await session.run("env check");
expect(result.sessionId).toBe("managed-agent-env-session");
expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
} finally {
await session.close();
}
});
test("forwards launch-context env through Claude resume env", async () => {
let capturedEnv: Record<string, string | undefined> | undefined;
const launchContext: AgentLaunchContext = {
env: {
PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000202",
PASEO_TEST_FLAG: "resume-launch-value",
},
};
const queryFactory = vi.fn(
({ options }: { options: { env?: Record<string, string | undefined> } }) => {
capturedEnv = options.env;
return createQueryMock([
{
type: "system",
subtype: "init",
session_id: "persisted-session",
permissionMode: "default",
model: "opus",
},
{
type: "assistant",
message: { content: "done" },
},
{
type: "result",
subtype: "success",
usage: {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
},
total_cost_usd: 0,
},
]);
},
);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: queryFactory as never,
});
const session = await client.resumeSession(
{
provider: "claude",
sessionId: "persisted-session",
metadata: {
cwd: process.cwd(),
},
},
{
cwd: process.cwd(),
},
launchContext,
);
try {
const result = await session.run("resume env check");
expect(result.sessionId).toBe("persisted-session");
expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
} finally {
await session.close();
}
});
});

View File

@@ -3,18 +3,26 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../provider-launch-config.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
function compactText(value: string): string {
return value.replace(/\s+/g, "").toLowerCase();
}
@@ -90,6 +98,30 @@ async function collectUntil(
}
}
function collectSubscribedUntil(
session: AgentSession,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
return new Promise((resolve, reject) => {
const events: AgentStreamEvent[] = [];
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`));
}, timeoutMs);
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (!predicate(event)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(events);
});
});
}
function getAssistantText(events: AgentStreamEvent[]): string {
return events
.flatMap((event) => {
@@ -142,18 +174,22 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
}
describe("ClaudeAgentSession integration", () => {
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
beforeAll(() => {
expect(isCommandAvailable("claude")).toBe(true);
if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true);
}
});
test("streams a basic response turn end-to-end", async () => {
test.runIf(canRunClaudeIntegration)("streams a basic response turn end-to-end", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
try {
const events = await collectUntilTerminal(
handle.session.stream("Respond with exactly: HELLO_WORLD"),
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
@@ -177,14 +213,59 @@ describe("ClaudeAgentSession integration", () => {
}
}, 60_000);
test("runs a real Bash tool call and completes it", async () => {
test.runIf(canRunClaudeIntegration)(
"supportedModels returns the current abstract Claude SDK model shape",
async () => {
const claudeQuery = query({
prompt: createEmptyPrompt(),
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
},
});
try {
const models = await claudeQuery.supportedModels();
expect(models.length).toBeGreaterThanOrEqual(3);
expect(models).toContainEqual(
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
}),
);
expect(models).toContainEqual(
expect.objectContaining({
value: "haiku",
displayName: "Haiku",
description: expect.stringContaining("Haiku 4.5"),
}),
);
expect(
models.some(
(model) =>
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
),
).toBe(true);
} finally {
await claudeQuery.return?.();
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
try {
const events = await collectUntilTerminal(
handle.session.stream(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
@@ -213,13 +294,16 @@ describe("ClaudeAgentSession integration", () => {
}
}, 60_000);
test("interrupts a running Bash turn and continues on the same query", async () => {
test.runIf(canRunClaudeIntegration)(
"interrupts a running Bash turn and continues on the same query",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = handle.session.stream(
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
@@ -254,7 +338,7 @@ describe("ClaudeAgentSession integration", () => {
).toBe(true);
const followUpEvents = await collectUntilTerminal(
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK"),
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
@@ -267,18 +351,22 @@ describe("ClaudeAgentSession integration", () => {
} finally {
await cleanupSession(handle);
}
}, 60_000);
},
60_000,
);
test("creates an autonomous live turn when a background task completes", async () => {
test.runIf(canRunClaudeIntegration)(
"creates an autonomous live turn when a background task completes",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const liveEventsStream = handle.session.streamLiveEvents();
const foregroundEvents = await collectUntilTerminal(
handle.session.stream(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
@@ -292,9 +380,11 @@ describe("ClaudeAgentSession integration", () => {
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectUntilTerminal(liveEventsStream, {
timeoutMs: 45_000,
});
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
45_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
@@ -309,9 +399,11 @@ describe("ClaudeAgentSession integration", () => {
} finally {
await cleanupSession(handle);
}
}, 60_000);
},
60_000,
);
test("surfaces permission requests and resumes after approval", async () => {
test.runIf(canRunClaudeIntegration)("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
@@ -320,7 +412,8 @@ describe("ClaudeAgentSession integration", () => {
try {
const events = await collectUntilTerminal(
handle.session.stream(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",

View File

@@ -2,12 +2,14 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
type QueryMock = {
next: ReturnType<typeof vi.fn>;
interrupt: ReturnType<typeof vi.fn>;
return: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
@@ -137,6 +139,7 @@ function createScriptedQuery(params: {
return: vi.fn(async () => {
output.end();
}),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
@@ -209,6 +212,21 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
.join("");
}
function subscribeToEvents(session: { subscribe: (callback: (event: AgentStreamEvent) => void) => () => void }) {
const queue = createAsyncQueue<AgentStreamEvent>();
const unsubscribe = session.subscribe((event) => {
queue.push(event);
});
return {
next: () => queue.next(),
close: () => {
unsubscribe();
queue.end();
},
};
}
async function waitFor(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number },
@@ -248,7 +266,7 @@ describe("ClaudeAgentSession interrupt regression", () => {
cwd: process.cwd(),
});
const firstTurn = session.stream("first prompt");
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await waitFor(() => queries[0]?.prompts.length === 1);
@@ -268,7 +286,7 @@ describe("ClaudeAgentSession interrupt regression", () => {
await session.close();
});
test("pushes the next prompt into the existing query instead of rebuilding it", async () => {
test("reuses the existing query after interrupt before starting the next prompt", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
@@ -298,11 +316,14 @@ describe("ClaudeAgentSession interrupt regression", () => {
cwd: process.cwd(),
});
const firstTurn = session.stream("first prompt");
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await waitFor(() => queries[0]?.prompts.length === 1);
const secondTurnEvents = await collectUntilTerminal(session.stream("second prompt"));
await session.interrupt();
await collectUntilTerminal(firstTurn);
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
@@ -313,7 +334,6 @@ describe("ClaudeAgentSession interrupt regression", () => {
expect(queries[0]?.return).not.toHaveBeenCalled();
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
await firstTurn.return?.();
await session.close();
});
@@ -394,12 +414,12 @@ describe("ClaudeAgentSession interrupt regression", () => {
cwd: process.cwd(),
});
const firstTurn = session.stream("first prompt");
const firstTurn = streamSession(session, "first prompt");
await firstTurn.next();
await session.interrupt();
await collectUntilTerminal(firstTurn);
const secondTurnEvents = await collectUntilTerminal(session.stream("second prompt"));
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
@@ -408,6 +428,73 @@ describe("ClaudeAgentSession interrupt regression", () => {
await session.close();
});
test("stale abort result after replacement start does not poison the new foreground turn", async () => {
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "interrupt-stale-result-session",
});
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = streamSession(session, "first prompt");
const firstStarted = await firstTurn.next();
await waitFor(() => queryRef?.prompts.length === 1);
await session.interrupt();
const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))];
expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true);
const observedSecondTurnEvents: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => {
observedSecondTurnEvents.push(event);
});
const secondTurn = streamSession(session, "second prompt");
const secondStarted = await secondTurn.next();
await waitFor(() => queryRef?.prompts.length === 2);
queryRef?.emit({
type: "result",
subtype: "error_during_execution",
errors: ["Request was aborted."],
session_id: "interrupt-stale-result-session",
});
queryRef?.emit({
type: "assistant",
message: { content: "SECOND_PROMPT_RESPONSE" },
session_id: "interrupt-stale-result-session",
});
queryRef?.emit(buildSuccessResult("interrupt-stale-result-session"));
const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))];
unsubscribe();
expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(
observedSecondTurnEvents.filter((event) => event.type === "turn_started").length,
).toBe(1);
expect(
observedSecondTurnEvents.some(
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
),
).toBe(false);
await session.close();
});
});
describe("ClaudeAgentSession autonomous turns", () => {
@@ -440,9 +527,9 @@ describe("ClaudeAgentSession autonomous turns", () => {
cwd: process.cwd(),
});
await collectUntilTerminal(session.stream("seed prompt"));
await collectUntilTerminal(streamSession(session, "seed prompt"));
const liveIterator = session.streamLiveEvents();
const subscribedEvents = subscribeToEvents(session);
queryRef?.emit({
type: "assistant",
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
@@ -450,9 +537,9 @@ describe("ClaudeAgentSession autonomous turns", () => {
});
queryRef?.emit(buildSuccessResult("autonomous-live-session"));
const started = await liveIterator.next();
const timeline = await liveIterator.next();
const completed = await liveIterator.next();
const started = await subscribedEvents.next();
const timeline = await subscribedEvents.next();
const completed = await subscribedEvents.next();
expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" });
expect(timeline.value).toMatchObject({
@@ -468,7 +555,7 @@ describe("ClaudeAgentSession autonomous turns", () => {
provider: "claude",
});
await liveIterator.return?.();
subscribedEvents.close();
await session.close();
});
@@ -510,19 +597,21 @@ describe("ClaudeAgentSession autonomous turns", () => {
cwd: process.cwd(),
});
await collectUntilTerminal(session.stream("seed prompt"));
await collectUntilTerminal(streamSession(session, "seed prompt"));
const liveIterator = session.streamLiveEvents();
const subscribedEvents = subscribeToEvents(session);
queryRef?.emit({
type: "assistant",
message: { content: "BACKGROUND_ONLY_RESPONSE" },
session_id: "autonomous-handoff-session",
});
const autonomousStart = await liveIterator.next();
const autonomousTimeline = await liveIterator.next();
const foregroundEvents = await collectUntilTerminal(session.stream("foreground prompt"));
const autonomousComplete = await liveIterator.next();
const autonomousStart = await subscribedEvents.next();
const autonomousTimeline = await subscribedEvents.next();
const foregroundEvents = await collectUntilTerminal(
streamSession(session, "foreground prompt"),
);
const autonomousComplete = await subscribedEvents.next();
expect(autonomousStart.value).toMatchObject({
type: "turn_started",
@@ -553,7 +642,7 @@ describe("ClaudeAgentSession autonomous turns", () => {
"foreground prompt",
]);
await liveIterator.return?.();
subscribedEvents.close();
await session.close();
});
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../provider-launch-config.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Respond with exactly: HELLO_MAX")) {
events.push(event);
if (isTerminalEvent(event)) {
return events;
}
}
return events;
}
describe("Claude max effort availability (real)", () => {
test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)(
"surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable",
async () => {
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "bypassPermissions",
model: "claude-opus-4-6",
thinkingOptionId: "max",
});
try {
const events = await collectUntilTerminal(session);
const failure = events.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
expect(failure).toBeDefined();
expect(failure?.error).toContain("Claude Code process exited with code 1");
expect(failure?.code).toBe("1");
expect(failure?.diagnostic).toContain('Effort level "max" is not available');
} finally {
await session.close().catch(() => undefined);
}
},
30_000,
);
});

View File

@@ -1,99 +0,0 @@
import { describe, expect, test } from "vitest";
import { normalizeClaudeRuntimeModelId } from "./claude-agent.js";
import { CLAUDE_MODEL_CATALOG } from "./claude/model-catalog.js";
describe("normalizeClaudeRuntimeModelId", () => {
function latestModelId(family: "sonnet" | "opus" | "haiku"): string {
const latest = CLAUDE_MODEL_CATALOG.find(
(model) => model.family === family && model.isLatestInFamily,
);
if (latest) {
return latest.modelId;
}
const fallback = CLAUDE_MODEL_CATALOG.find((model) => model.family === family);
if (!fallback) {
throw new Error(`Missing Claude model family in catalog: ${family}`);
}
return fallback.modelId;
}
const SONNET = latestModelId("sonnet");
const OPUS = latestModelId("opus");
const HAIKU = latestModelId("haiku");
const supportedModelIds = new Set([SONNET, OPUS, HAIKU]);
const supportedModelFamilyAliases = new Map([
["sonnet", SONNET],
["opus", OPUS],
["haiku", HAIKU],
] as const);
test("preserves runtime model when it already exists in the supported catalog", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: SONNET,
supportedModelIds,
});
expect(normalized).toBe(SONNET);
});
test("maps unknown runtime Sonnet versions to the catalog Sonnet model ID", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-sonnet-4-6-20260101",
supportedModelIds,
supportedModelFamilyAliases,
});
expect(normalized).toBe(SONNET);
});
test("maps unknown runtime Opus versions to the catalog Opus model ID", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-opus-4-5-20251101",
supportedModelIds,
supportedModelFamilyAliases,
});
expect(normalized).toBe(OPUS);
});
test("maps unknown runtime Haiku versions to the catalog Haiku model ID", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-haiku-4-6-20260101",
supportedModelIds,
supportedModelFamilyAliases,
});
expect(normalized).toBe(HAIKU);
});
test("uses configured model when runtime ID is unknown", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-custom-unknown",
supportedModelIds,
configuredModelId: OPUS,
});
expect(normalized).toBe(OPUS);
});
test("uses current model when runtime and configured IDs are unknown", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-custom-unknown",
supportedModelIds,
configuredModelId: "claude-unknown",
currentModelId: HAIKU,
});
expect(normalized).toBe(HAIKU);
});
test("preserves runtime model when mapping is not possible", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-custom-unknown",
supportedModelIds: new Set(["x", "y"]),
});
expect(normalized).toBe("claude-custom-unknown");
});
test("does not force family fallback for unknown runtime families", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-custom-unknown",
supportedModelIds,
});
expect(normalized).toBe("claude-custom-unknown");
});
});

View File

@@ -3,17 +3,20 @@ import type { Logger } from "pino";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
type QueryMock = {
next: ReturnType<typeof vi.fn>;
interrupt: ReturnType<typeof vi.fn>;
return: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
};
function buildUsage() {
@@ -46,11 +49,15 @@ function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock {
next: nextImpl,
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
}
@@ -263,13 +270,14 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("emits interrupt step diagnostics without info logs", async () => {
test("interruptActiveTurn only interrupts the active query without info logs", async () => {
const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger);
const internal = session as unknown as {
query: {
interrupt: () => Promise<void>;
return?: () => Promise<void>;
close?: () => void;
} | null;
input: { end: () => void } | null;
queryRestartNeeded: boolean;
@@ -281,6 +289,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
internal.query = {
interrupt,
return: queryReturn,
close: vi.fn(() => undefined),
};
internal.input = { end };
internal.queryRestartNeeded = false;
@@ -296,15 +305,12 @@ describe("ClaudeAgentSession redesign invariants", () => {
);
expect(interruptInfoMessages).toEqual([]);
expect(interruptDebugMessages).toEqual([
"interruptActiveTurn: calling query.interrupt()...",
"interruptActiveTurn: calling query.return()...",
]);
expect(interruptDebugMessages).toEqual([]);
expect(interrupt).toHaveBeenCalledTimes(1);
expect(queryReturn).toHaveBeenCalledTimes(1);
expect(end).toHaveBeenCalledTimes(1);
expect(internal.query).toBeNull();
expect(internal.input).toBeNull();
expect(queryReturn).not.toHaveBeenCalled();
expect(end).not.toHaveBeenCalled();
expect(internal.query).not.toBeNull();
expect(internal.input).not.toBeNull();
expect(internal.queryRestartNeeded).toBe(false);
} finally {
await session.close();
@@ -435,7 +441,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -473,7 +479,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
partial_json: '{"command":"echo ',
},
},
expectedCommand: "echo ",
expectedCommand: "echo seed",
},
{
event: {
@@ -514,7 +520,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
test("does not surface incomplete string values from input_json_delta", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -545,7 +551,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
file_path: "src/message.tsx",
old_string: "before",
});
} finally {
await session.close();
@@ -625,164 +630,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("routes by deterministic identifier priority: task_id > parent_message_id > message_id", async () => {
const session = await createSession();
const internal = session as unknown as {
createRun: (owner: "autonomous", queue: null) => { id: string };
runTracker: {
bindIdentifiers: (
run: { id: string },
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
) => void;
};
routeMessage: (input: {
message: AgentStreamEvent | Record<string, unknown>;
identifiers: {
taskId: string | null;
parentMessageId: string | null;
messageId: string | null;
};
metadataOnly: boolean;
}) => { run: { id: string } | null; reason: string };
turnState: "autonomous";
};
const taskRun = internal.createRun("autonomous", null);
internal.runTracker.bindIdentifiers(taskRun, {
taskId: "task-A",
parentMessageId: null,
messageId: "msg-A",
});
const parentRun = internal.createRun("autonomous", null);
internal.runTracker.bindIdentifiers(parentRun, {
taskId: null,
parentMessageId: "parent-B",
messageId: "msg-B",
});
const messageRun = internal.createRun("autonomous", null);
internal.runTracker.bindIdentifiers(messageRun, {
taskId: null,
parentMessageId: null,
messageId: "msg-C",
});
internal.turnState = "autonomous";
const taskPriorityRoute = internal.routeMessage({
message: { type: "assistant", message: { content: "task-priority" } },
identifiers: {
taskId: "task-A",
parentMessageId: "parent-B",
messageId: "msg-C",
},
metadataOnly: false,
});
expect(taskPriorityRoute.reason).toBe("task_id");
expect(taskPriorityRoute.run?.id).toBe(taskRun.id);
const parentPriorityRoute = internal.routeMessage({
message: { type: "assistant", message: { content: "parent-priority" } },
identifiers: {
taskId: null,
parentMessageId: "parent-B",
messageId: "msg-C",
},
metadataOnly: false,
});
expect(parentPriorityRoute.reason).toBe("parent_message_id");
expect(parentPriorityRoute.run?.id).toBe(parentRun.id);
const messagePriorityRoute = internal.routeMessage({
message: { type: "assistant", message: { content: "message-priority" } },
identifiers: {
taskId: null,
parentMessageId: null,
messageId: "msg-C",
},
metadataOnly: false,
});
expect(messagePriorityRoute.reason).toBe("message_id");
expect(messagePriorityRoute.run?.id).toBe(messageRun.id);
await session.close();
});
test("does not route unbound events to foreground before prompt replay is observed", async () => {
const session = await createSession();
const internal = session as unknown as {
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
runTracker: {
bindIdentifiers: (
run: { id: string },
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null },
) => void;
};
activeForegroundTurn: { runId: string; queue: unknown } | null;
turnState: "foreground";
routeMessage: (input: {
message: Record<string, unknown>;
identifiers: {
taskId: string | null;
parentMessageId: string | null;
messageId: string | null;
};
metadataOnly: boolean;
}) => { run: { id: string } | null; reason: string };
};
const foregroundQueueStub = {
push: () => undefined,
end: () => undefined,
[Symbol.asyncIterator]: () => ({
next: async () => ({ done: true as const, value: undefined }),
}),
};
const foregroundRun = internal.createRun("foreground", foregroundQueueStub);
internal.turnState = "foreground";
internal.activeForegroundTurn = {
runId: foregroundRun.id,
queue: foregroundQueueStub,
};
const unboundBeforeReplay = internal.routeMessage({
message: { type: "assistant", message: { content: "stale-before-replay" } },
identifiers: {
taskId: null,
parentMessageId: null,
messageId: null,
},
metadataOnly: false,
});
expect(unboundBeforeReplay.reason).toBe("foreground");
expect(unboundBeforeReplay.run?.id).toBe(foregroundRun.id);
const promptReplayId = "foreground-replay-id";
internal.runTracker.bindIdentifiers(foregroundRun, {
taskId: null,
parentMessageId: null,
messageId: promptReplayId,
});
const replayRoute = internal.routeMessage({
message: {
type: "user",
message: { role: "user", content: "prompt replay" },
uuid: promptReplayId,
},
identifiers: {
taskId: null,
parentMessageId: null,
messageId: promptReplayId,
},
metadataOnly: false,
});
expect(replayRoute.reason).toBe("message_id");
expect(replayRoute.run?.id).toBe(foregroundRun.id);
await session.close();
});
test("completes a foreground run when only system metadata arrives before the first assistant message", async () => {
let step = 0;
sdkQueryFactory.mockImplementation(() =>
@@ -846,7 +693,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
const session = await createSession();
try {
const events = await Promise.race([
collectUntilTerminal(session.stream("metadata helper prompt")),
collectUntilTerminal(streamSession(session, "metadata helper prompt")),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Timed out waiting for foreground terminal event")),
@@ -870,15 +717,79 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("captures Claude stderr in the turn failure diagnostic when stderr arrives after process exit", async () => {
const stderrMessage =
'Error: Effort level "max" is not available for Claude.ai subscribers. Please use "low", "medium", or "high".';
let capturedOptions:
| {
stderr?: (data: string) => void;
effort?: string;
permissionMode?: string;
}
| undefined;
sdkQueryFactory.mockImplementation(
({ options }: { options: { stderr?: (data: string) => void; effort?: string } }) => {
capturedOptions = options;
let failed = false;
return createBaseQueryMock(
vi.fn(async () => {
if (!failed) {
failed = true;
setTimeout(() => {
options.stderr?.(`${stderrMessage}\n`);
}, 0);
throw new Error("Claude Code process exited with code 1");
}
return { done: true, value: undefined };
}),
);
},
);
const loggerSpy = createSpyLogger();
const client = new ClaudeAgentClient({
logger: loggerSpy.logger,
queryFactory: sdkQueryFactory,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "bypassPermissions",
thinkingOptionId: "max",
});
try {
const events = await collectUntilTerminal(streamSession(session, "trigger max failure"));
const failure = events.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
expect(capturedOptions?.permissionMode).toBe("bypassPermissions");
expect(capturedOptions?.effort).toBe("max");
expect(failure).toMatchObject({
type: "turn_failed",
error: "Claude Code process exited with code 1",
code: "1",
diagnostic: stderrMessage,
});
expect(loggerSpy.error).toHaveBeenCalledWith(
expect.objectContaining({ stderr: stderrMessage }),
"Claude Agent SDK stderr",
);
} finally {
await session.close();
}
});
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal = session as unknown as {
turnState: "idle" | "foreground" | "autonomous";
nextRunOrdinal: number;
nextTurnOrdinal: number;
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
runTracker: {
listActiveRuns: (owner?: "foreground" | "autonomous") => Array<{ id: string }>;
};
autonomousTurn: { id: string } | null;
};
internal.turnState = "idle";
@@ -890,9 +801,9 @@ describe("ClaudeAgentSession redesign invariants", () => {
},
});
const firstRun = internal.runTracker.listActiveRuns("autonomous");
expect(firstRun).toHaveLength(1);
expect(internal.nextRunOrdinal).toBe(2);
const firstRunId = internal.autonomousTurn?.id ?? null;
expect(firstRunId).toBe("autonomous-turn-1");
expect(internal.nextTurnOrdinal).toBe(2);
internal.routeSdkMessageFromPump({
type: "stream_event",
@@ -901,10 +812,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
delta: { type: "text_delta", text: "WAKE" },
},
});
const secondRun = internal.runTracker.listActiveRuns("autonomous");
expect(secondRun).toHaveLength(1);
expect(secondRun[0]?.id).toBe(firstRun[0]?.id);
expect(internal.nextRunOrdinal).toBe(2);
expect(internal.autonomousTurn?.id).toBe(firstRunId);
expect(internal.nextTurnOrdinal).toBe(2);
internal.routeSdkMessageFromPump({
type: "result",
@@ -912,73 +821,11 @@ describe("ClaudeAgentSession redesign invariants", () => {
usage: buildUsage(),
total_cost_usd: 0,
});
expect(internal.runTracker.listActiveRuns("autonomous")).toHaveLength(0);
expect(internal.autonomousTurn).toBeNull();
await session.close();
});
test("pushEvent does not route side-channel events into a stale foreground queue", async () => {
const session = await createSession();
const staleQueueEvents: AgentStreamEvent[] = [];
const staleQueue = {
push: (event: AgentStreamEvent) => staleQueueEvents.push(event),
end: () => undefined,
[Symbol.asyncIterator]: () => ({
next: async () => ({ done: true as const, value: undefined }),
}),
};
const internal = session as unknown as {
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
activeForegroundTurn: { runId: string; queue: unknown } | null;
runTracker: {
getRun: (runId: string) => unknown;
complete: (run: unknown, state: "completed") => void;
};
pushEvent: (event: AgentStreamEvent) => void;
};
const staleForegroundRun = internal.createRun("foreground", staleQueue);
const runRecord = internal.runTracker.getRun(staleForegroundRun.id);
internal.runTracker.complete(runRecord, "completed");
internal.activeForegroundTurn = {
runId: staleForegroundRun.id,
queue: staleQueue,
};
const liveIterator = (
session as unknown as {
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
}
).streamLiveEvents();
const permissionEvent: AgentStreamEvent = {
type: "permission_requested",
provider: "claude",
request: {
id: "permission-1",
provider: "claude",
name: "Bash",
kind: "tool",
},
};
internal.pushEvent(permissionEvent);
const next = await Promise.race([
liveIterator.next(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Timed out waiting for live side-channel event")), 1_000);
}),
]);
expect(next.done).toBe(false);
expect(next.value).toEqual(permissionEvent);
expect(staleQueueEvents).toHaveLength(0);
expect(internal.activeForegroundTurn).toBeNull();
await liveIterator.return?.();
await session.close();
});
test("tracks run lifecycle transitions for success, error, and interrupt", async () => {
const session = await createSession();
let streamCase: "success" | "error" | "interrupt" = "success";
@@ -1074,18 +921,18 @@ describe("ClaudeAgentSession redesign invariants", () => {
});
streamCase = "success";
const successEvents = await collectUntilTerminal(session.stream("success prompt"));
const successEvents = await collectUntilTerminal(streamSession(session, "success prompt"));
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false);
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false);
streamCase = "error";
const errorEvents = await collectUntilTerminal(session.stream("error prompt"));
const errorEvents = await collectUntilTerminal(streamSession(session, "error prompt"));
expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true);
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false);
streamCase = "interrupt";
const interruptStream = session.stream("interrupt prompt");
const interruptStream = streamSession(session, "interrupt prompt");
const interruptEvents: AgentStreamEvent[] = [];
for await (const event of interruptStream) {
interruptEvents.push(event);
@@ -1204,7 +1051,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
});
const session = await createSession();
const events = await collectUntilTerminal(session.stream("timeline prompt"));
const events = await collectUntilTerminal(streamSession(session, "timeline prompt"));
const assistantText = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
@@ -1323,7 +1170,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
});
const session = await createSession();
const events = await collectUntilTerminal(session.stream("uuid fallback prompt"));
const events = await collectUntilTerminal(streamSession(session, "uuid fallback prompt"));
const assistantText = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
@@ -1337,7 +1184,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
const assembler = session as unknown as {
timelineAssembler: { messages: Map<string, unknown> };
};
expect(assembler.timelineAssembler.messages.size).toBe(1);
expect(assembler.timelineAssembler.messages.size).toBe(0);
await session.close();
});

View File

@@ -5,6 +5,7 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentTimelineRow } from "../agent-manager.js";
import { projectTimelineRows } from "../timeline-projection.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
@@ -18,11 +19,13 @@ type QueryMock = {
next: ReturnType<typeof vi.fn>;
interrupt: ReturnType<typeof vi.fn>;
return: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
};
function buildQueryMock(events: unknown[]): QueryMock {
@@ -38,11 +41,15 @@ function buildQueryMock(events: unknown[]): QueryMock {
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
}
@@ -249,7 +256,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
cwd: process.cwd(),
});
const events = await collectUntilTerminal(session.stream("delegate work"));
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const timelineToolCalls = events
@@ -310,7 +317,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
cwd: process.cwd(),
});
const events = await collectUntilTerminal(session.stream("delegate work"));
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const timelineToolCalls = events

View File

@@ -1,4 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
@@ -241,6 +242,13 @@ describe("convertClaudeHistoryEntry", () => {
describe("ClaudeAgentClient.listModels", () => {
const logger = createTestLogger();
function createSupportedModelsQueryMock(models: ModelInfo[]) {
return {
supportedModels: vi.fn(async () => models),
return: vi.fn(async () => ({ done: true, value: undefined })),
};
}
test("returns models with required fields", async () => {
const client = new ClaudeAgentClient({ logger });
const models = await client.listModels();
@@ -267,4 +275,86 @@ describe("ClaudeAgentClient.listModels", () => {
),
).toBe(true);
}, 60_000);
test("prefers provider-discovered Claude defaults and effort levels", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
},
{
value: "opus",
displayName: "Opus",
description: "Opus 4.6 · Most capable for complex work",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
},
{
value: "haiku",
displayName: "Haiku",
description: "Haiku 4.5 · Fastest for quick answers",
},
] satisfies ModelInfo[]);
const queryFactory = vi.fn(() => queryMock);
const client = new ClaudeAgentClient({
logger,
queryFactory: queryFactory as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
expect(queryMock.return).toHaveBeenCalledTimes(1);
expect(models).toEqual([
expect.objectContaining({
id: "claude-sonnet-4-6",
isDefault: true,
label: "Sonnet 4.6",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
}),
expect.objectContaining({
id: "claude-opus-4-6",
label: "Opus 4.6",
}),
expect.objectContaining({
id: "claude-haiku-4-5",
label: "Haiku 4.5",
}),
]);
});
test("preserves SDK ids even when descriptions are weak", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Recommended model",
},
] satisfies ModelInfo[]);
const client = new ClaudeAgentClient({
logger,
queryFactory: vi.fn(() => queryMock) as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(models).toEqual([
expect.objectContaining({
id: "default",
label: "Default (recommended)",
description: "Recommended model",
}),
]);
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
const sdkMocks = vi.hoisted(() => ({
@@ -19,8 +20,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed";
const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER";
function buildSdkQueryMock() {
const events = [
@@ -61,24 +60,15 @@ function buildSdkQueryMock() {
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
};
}
function buildIdleSdkQueryMock() {
return {
next: vi.fn(async () => ({ done: true, value: undefined })),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
}
@@ -98,28 +88,6 @@ function collectTimelineText(events: AgentStreamEvent[]): string {
return chunks.join("\n");
}
async function readNextEvent(
iterator: AsyncIterator<AgentStreamEvent>,
timeoutMs: number,
): Promise<AgentStreamEvent> {
const outcome = await Promise.race([
iterator.next().then((result) => ({ kind: "result" as const, result })),
new Promise<{ kind: "timeout" }>((resolve) => {
setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
}),
]);
if (outcome.kind === "timeout") {
throw new Error("Timed out waiting for live event");
}
if (outcome.result.done) {
throw new Error("Live event stream ended before appended transcript arrived");
}
return outcome.result.value;
}
describe("ClaudeAgentSession history replay regression", () => {
let tempRoot: string;
let cwd: string;
@@ -200,7 +168,7 @@ describe("ClaudeAgentSession history replay regression", () => {
const events: AgentStreamEvent[] = [];
try {
for await (const event of session.stream("Say hello")) {
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (
event.type === "turn_completed" ||
@@ -249,112 +217,6 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
});
test("emits appended transcript lines through streamLiveEvents after history was primed", async () => {
sdkMocks.query.mockImplementation(() => {
const mock = buildIdleSdkQueryMock();
sdkMocks.lastQuery = mock;
return mock;
});
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
nativeHandle: "history-session",
metadata: {
provider: "claude",
cwd,
},
};
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const historyPath = path.join(configDir, "projects", sanitized, "history-session.jsonl");
const session = await client.resumeSession(handle, { cwd });
try {
for await (const _event of session.streamHistory()) {
// Prime existing persisted history the same way agent-manager does.
}
const liveEvents = session.streamLiveEvents();
const iterator = liveEvents[Symbol.asyncIterator]();
appendFileSync(
historyPath,
`\n${JSON.stringify({
type: "queue-operation",
operation: "enqueue",
uuid: "appended-task-note-1",
content: [
"<task-notification>",
"<task-id>appended-bg-1</task-id>",
"<status>completed</status>",
`<summary>${APPENDED_TASK_NOTIFICATION_MARKER}</summary>`,
"<output-file>/tmp/appended-bg-1.txt</output-file>",
"</task-notification>",
].join("\n"),
})}\n${JSON.stringify({
type: "assistant",
sessionId: "history-session",
cwd,
message: {
role: "assistant",
content: APPENDED_ASSISTANT_MARKER,
},
})}`,
"utf8",
);
const appendedEvents: AgentStreamEvent[] = [];
for (let attempt = 0; attempt < 4; attempt += 1) {
appendedEvents.push(await readNextEvent(iterator, 1_500));
const sawTaskNotification = appendedEvents.some(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification",
);
const sawAssistant = appendedEvents.some(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes(APPENDED_ASSISTANT_MARKER),
);
if (sawTaskNotification && sawAssistant) {
break;
}
}
const timelineText = collectTimelineText(appendedEvents);
const taskNotificationEvent = appendedEvents.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification",
);
const turnStartedEvent = appendedEvents.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_started" }> =>
event.type === "turn_started",
);
expect(taskNotificationEvent).toBeTruthy();
expect(turnStartedEvent).toBeTruthy();
expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER);
expect(taskNotificationEvent?.item.metadata).toMatchObject({
taskId: "appended-bg-1",
status: "completed",
outputFile: "/tmp/appended-bg-1.txt",
});
expect(taskNotificationEvent?.item.detail).toMatchObject({
type: "plain_text",
label: APPENDED_TASK_NOTIFICATION_MARKER,
});
} finally {
await session.close();
}
});
test("listCommands includes rewind command", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
@@ -394,7 +256,7 @@ describe("ClaudeAgentSession history replay regression", () => {
const events: AgentStreamEvent[] = [];
try {
for await (const event of session.stream("/rewind")) {
for await (const event of streamSession(session, "/rewind")) {
events.push(event);
if (
event.type === "turn_completed" ||

View File

@@ -1,108 +0,0 @@
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
/**
* Temporary hardcoded Claude model catalog.
*
* Why:
* - Claude SDK model discovery currently returns abstract options like
* "default", "opus", and "haiku".
* - Runtime init messages report concrete model IDs like
* "claude-opus-4-6".
* - That mismatch breaks model selection + thinking reconciliation in UI.
*
* We keep a single flat list with all model data in one place.
* If Claude SDK model discovery becomes consistent with runtime IDs, switch
* listModels back to SDK discovery and remove this file.
*/
export type ClaudeCatalogModel = {
family: "sonnet" | "opus" | "haiku";
modelId: string;
name: string;
description: string;
isDefault?: boolean;
isLatestInFamily?: boolean;
};
export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [
{
family: "opus",
modelId: "claude-opus-4-6",
name: "Opus 4.6",
description: "Opus 4.6 · Most capable for complex work",
isLatestInFamily: true,
},
{
family: "sonnet",
modelId: "claude-sonnet-4-6",
name: "Sonnet 4.6",
description: "Sonnet 4.6 · Best for everyday tasks",
isLatestInFamily: true,
},
{
family: "sonnet",
modelId: "claude-sonnet-4-5-20250929",
name: "Sonnet 4.5",
description: "Sonnet 4.5 · Best for everyday tasks",
isDefault: true,
},
{
family: "haiku",
modelId: "claude-haiku-4-5-20251001",
name: "Haiku 4.5",
description: "Haiku 4.5 · Fastest for quick answers",
isLatestInFamily: true,
},
];
export type ClaudeModelFamily = ClaudeCatalogModel["family"];
function toClaudeModelDefinition(params: {
id: string;
label: string;
description?: string;
isDefault?: boolean;
}): AgentModelDefinition {
return {
provider: "claude",
id: params.id,
label: params.label,
description: params.description,
...(params.isDefault ? { isDefault: true } : {}),
thinkingOptions: [
{ id: "off", label: "Off", isDefault: true },
{ id: "on", label: "On" },
],
defaultThinkingOptionId: "off",
metadata: params.description
? {
description: params.description,
}
: undefined,
};
}
export function listClaudeCatalogModels(): AgentModelDefinition[] {
return CLAUDE_MODEL_CATALOG.map((model) =>
toClaudeModelDefinition({
id: model.modelId,
label: model.name,
description: model.description,
isDefault: model.isDefault,
}),
);
}
export function buildClaudeSelectableModelIds(): Set<string> {
return new Set(CLAUDE_MODEL_CATALOG.map((model) => model.modelId));
}
export function buildClaudeModelFamilyAliases(): Map<ClaudeModelFamily, string> {
const aliases = new Map<ClaudeModelFamily, string>();
for (const model of CLAUDE_MODEL_CATALOG) {
if (model.isLatestInFamily || !aliases.has(model.family)) {
aliases.set(model.family, model.modelId);
}
}
return aliases;
}

View File

@@ -13,33 +13,25 @@ describe("parsePartialJsonObject", () => {
});
});
it("keeps partial string values without field-specific logic", () => {
it("does not emit incomplete string values", () => {
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
value: {
command: "echo ",
},
value: {},
complete: false,
});
});
it("returns parsed prefix fields from incomplete objects", () => {
it("returns only complete prefix fields from incomplete objects", () => {
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
value: {
file_path: "src/message.tsx",
old_string: "before",
},
complete: false,
});
});
it("parses nested partial values generically", () => {
it("does not emit incomplete nested values", () => {
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
value: {
payload: {
path: "src/index.ts",
content: "hello",
},
},
value: {},
complete: false,
});
});

View File

@@ -271,17 +271,17 @@ function parsePartialObject(
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
if (!parsedMemberValue.complete) {
return {
value,
nextIndex: currentIndex,
nextIndex: skipWhitespace(input, parsedMemberValue.nextIndex),
complete: false,
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
const delimiter = input[currentIndex];
if (delimiter === ",") {
currentIndex += 1;

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