Compare commits

...

55 Commits

Author SHA1 Message Date
Mohamed Boudra
40f8a8414a chore(release): cut 0.1.59 2026-04-16 22:10:49 +07:00
Mohamed Boudra
9386901896 docs: add 0.1.59 changelog entry for Opus 4.7 2026-04-16 21:58:00 +07:00
Mohamed Boudra
daefa8fb7f feat(server): add Opus 4.7 to claude provider with xhigh effort
Adds claude-opus-4-7 and claude-opus-4-7[1m] alongside the existing 4.6
entries (4.6 stays default). Opus 4.7 introduces a new "xhigh" effort
level between high and max; the option only surfaces on 4.7 models.

The SDK 0.2.71 effort union doesn't yet include xhigh, so the value is
cast at the assignment site.
2026-04-16 21:52:40 +07:00
github-actions[bot]
754f1facb6 fix: update lockfile signatures and Nix hash 2026-04-16 12:18:57 +00:00
Mohamed Boudra
727aa0cdac chore(release): cut 0.1.58 2026-04-16 19:17:37 +07:00
Mohamed Boudra
a387eaf26a docs: trim 0.1.58 changelog and add scannable-bullets guidance
Split the OpenCode reliability bullet into four per-PR entries, shorten
the Windows and provider-models bullets, and drop low-signal qualifiers.
Add a "Changelog conciseness" section to the release playbook so the
next agent knows bullets must be scannable in one glance.
2026-04-16 19:16:42 +07:00
Mohamed Boudra
a11c246bdc fix(server): share send-prompt path between MCP and Session
Extract `unarchiveAgentState` and `sendPromptToAgent` into `mcp-shared`
so every surface (app/WS, MCP, CLI-through-MCP) runs the same sequence:
unarchive → ensureAgentLoaded → optional mode change → recordUserMessage
→ startAgentRun. MCP `send_agent_prompt` previously skipped unarchive and
cold-agent rehydration, so sending to an archived agent over MCP left it
hidden from `paseo ls` and failed entirely when the agent wasn't already
in memory. Session now delegates to the shared function and its private
`unarchiveAgentState` is gone.
2026-04-16 18:58:31 +07:00
Mohamed Boudra
2e95003154 fix(server): include stderr in claude auth status diagnostic
Some status output (e.g. "Not logged in") goes to stderr. Capture both
stdout and stderr, including when the command exits non-zero, so the
diagnostic surfaces the full picture.
2026-04-16 18:54:56 +07:00
Mohamed Boudra
1b4483615a fix(server): surface raw claude auth status in diagnostic
Return stdout verbatim instead of parsing JSON fields. Parser was fragile
to upstream schema changes and added no value — users can read the raw
output directly.
2026-04-16 18:52:28 +07:00
Mohamed Boudra
4d0fe57a7a fix(server): stop detecting Windows PowerShell shims 2026-04-16 18:36:06 +07:00
Mohamed Boudra
84db0cecd7 docs: expand Windows entry and rename header to 0.1.58 2026-04-16 18:29:41 +07:00
Mohamed Boudra
14ac6984f3 fix(server): extend refresh_providers_snapshot timeout to 60s
Provider snapshot refresh was timing out at 5s for providers that need
longer to enumerate models/auth. Bump to 60s.
2026-04-16 18:29:40 +07:00
Mohamed Boudra
b6147e39ab feat(server): show Claude auth status in provider diagnostic
Runs `claude auth status` and surfaces the parsed result (auth method,
subscription, email/org) in the Claude Code provider diagnostic panel.
2026-04-16 18:29:38 +07:00
Mohamed Boudra
a2b743a6d3 fix(server): emit valid MCP list_agents payloads
Explicit `undefined` keys on optional snapshot fields were converted
to `null` by `ensureValidJson`, failing `AgentSnapshotPayloadSchema`
validation on the wire. Stop writing those keys so they remain absent,
which is what `.optional()` expects.

Also adds a test that parses `list_agents` output through the schema
to catch this class of bug.
2026-04-16 18:29:34 +07:00
Mohamed Boudra
7442323ca2 fix(server): preserve JSON args for Windows PowerShell shims 2026-04-16 18:04:17 +07:00
Mohamed Boudra
086b3098b1 fix(server): support Windows PowerShell shims in shared launcher 2026-04-16 17:58:12 +07:00
Mohamed Boudra
e0f82e7ad0 test(server): reproduce Windows ps1 shim launch gap 2026-04-16 17:52:44 +07:00
Mohamed Boudra
9cee1d8196 fix(server): restore .cmd shell routing for Windows EINVAL 2026-04-16 17:05:08 +07:00
Mohamed Boudra
c2a8e56d33 test(server): reproduce Windows EINVAL for .cmd shim with shell:false 2026-04-16 17:00:01 +07:00
Mohamed Boudra
de33dfd034 fix(server): share Windows launch handling across providers 2026-04-16 16:44:24 +07:00
Mohamed Boudra
917c2f56f4 test(server): reproduce Windows spawn EINVAL with cmd shim + spaces + JSON args 2026-04-16 16:37:17 +07:00
github-actions[bot]
a3822ca299 fix: update lockfile signatures and Nix hash 2026-04-16 09:17:53 +00:00
Mohamed Boudra
41fd45588f chore(release): cut 0.1.57 2026-04-16 16:16:30 +07:00
Mohamed Boudra
54b8126deb docs: add 0.1.57 changelog 2026-04-16 16:15:38 +07:00
Mohamed Boudra
7c50b7a080 fix(server): harden Codex/Windows startup and provider resolution (#454)
* fix(server): harden Codex/Windows startup and provider resolution

Addresses #452, #443, #353, #418, #403, #221, #307, and locks in #284.

- Replace custom where.exe/which output parsing with npm `which@^5` plus a
  spawn probe. findExecutable now enumerates all PATH+PATHEXT candidates
  and returns the first invokable one. A WindowsApps-ACL'd codex.exe no
  longer wins over a working codex.cmd (#452).
- Make default provider isAvailable() check the binary instead of always
  returning true. Codex/Claude/OpenCode default isAvailable() now defer
  to isCommandAvailable() so missing CLIs surface as unavailable instead
  of throwing later from spawn (#221, #443).
- Gate AgentManager.resumeAgentFromPersistence on isAvailable() so a
  persisted agent record with a missing binary cannot reach provider
  spawn during rehydration. The daemon stays up and the agent reports
  unavailable (#443, #353, #418, #403).
- Drop --path-format=absolute from rev-parse callers and validate stdout
  through a shared parser that rejects multi-line output and unknown-flag
  echoes. --show-toplevel is absolute by default in modern Git;
  --git-common-dir is resolved against the command cwd. Fixes workspace
  registration on pre-2.31 Git that echoed the unknown flag and produced
  a two-line "path" (#307).

Tests:
- Real-FS executable.test.ts using temp PATH fixtures, covering the .cmd
  fallback after .exe pre-spawn failure (Windows-only) plus the
  null-on-no-invokable-candidate case.
- provider-availability.test.ts builds real provider clients against a
  temp-dir-only PATH for Codex/Claude/OpenCode.
- bootstrap-provider-availability.test.ts builds the daemon and triggers
  ensureAgentLoaded so it actually exercises resumeAgentFromPersistence.
- claude-agent.spawn.test.ts asserts shell: false reaches spawnProcess
  from the Claude SDK spawn override, locking in 39b56af4 for #284.
- checkout-git-rev-parse.test.ts covers nested-checkout resolution and
  the old-Git multi-line stdout case via a tightly-scoped runGitCommand
  fake.

CI:
- server-tests-windows now also runs the new and modified test files so
  the Windows behaviors are exercised on windows-latest.

* fix: update lockfile signatures and Nix hash

* fix(server): handle synchronous spawn UNKNOWN on Windows

On Windows, child_process.spawn() throws synchronously when invoked on
a corrupt or invalid .exe (e.g., a WindowsApps stub the current user
cannot execute, or a zero-byte file). The executable probe did not
guard the spawn call, so the synchronous throw rejected the probe
promise instead of resolving false, preventing findExecutable() from
trying the next candidate. This is the root cause of the daemon-crash
pattern in #452: codex.exe from WindowsApps would hard-fail before the
.cmd shim ever got a chance.

Wrap spawn() in try/catch and settle false on sync throw. The existing
error-event and exit-event handlers already cover async failure modes;
sync throw just needed one more guard.

Also adjust three Windows-only test comparisons that were asserting
platform-dependent string equality:
- executable.test: compare .cmd paths case-insensitively (which@5
  preserves PATHEXT casing, which is uppercase in production).
- workspace-registry-model.test: expect normalizeWorkspaceId(path),
  not the hardcoded POSIX form.
- checkout-git-rev-parse.test: normalize separators/case when
  comparing git's Windows forward-slash output against realpathSync.

* test(server): canonicalize repo root via git on Windows to fix short-name mismatch

realpathSync on Windows preserves 8.3 short names (e.g. RUNNER~1) while git's
rev-parse --show-toplevel always returns the long-name form (runneradmin).
Use git as the canonicalizer on both sides of the assertion so the comparison
holds regardless of how Windows exposes the temp directory path.

* fix(server): Claude is always available in default mode; SDK bundles cli.js

The Phase 2 change wrongly tied Claude's default-mode isAvailable() to
isCommandAvailable("claude"). Claude's default runtime does not use an
external `claude` binary — @anthropic-ai/claude-agent-sdk ships its own
cli.js and spawnClaudeCodeProcess runs it via process.execPath. The
previous `return true` was correct; restore it.

Update provider-availability.test.ts to assert the truthful behavior:
Claude reports available even when no `claude` binary is on PATH.

Codex and OpenCode genuinely require their binaries on PATH, so their
availability checks remain unchanged.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-16 16:36:13 +08:00
Mohamed Boudra
11b407c804 fix(app): hide branch icon on non-git workspace headers
Gate the GitBranch icon behind isGitCheckout, and keep the header
skeleton visible until the checkout status query resolves so the icon
doesn't pop in and cause layout shift.
2026-04-16 13:14:00 +07:00
Mohamed Boudra
814098852c feat: provider model freshness TTL and diagnostic UI (#426)
* feat: add server-side TTL for provider snapshots and model list UI

Provider snapshots cached forever, causing newly available models
(e.g. OpenCode Go minimax, glm) to never appear in the picker.

Server: ProviderSnapshotManager now returns stale data immediately
and kicks off a background re-warm when the snapshot is older than
5 minutes. Injectable TTL/clock for testing.

App: Provider settings rows show model count and are tappable to
open a diagnostic sheet with a read-only model list (label + ID).

* feat: add per-provider refresh for models and diagnostic

* refactor: await refresh completion and clean up diagnostic sheet state

- ProviderSnapshotManager.refresh is now async; session.ts awaits it so
  the RPC ACK reflects real completion instead of just queuing work.
- Preserve models/modes/fetchedAt on entries during targeted refresh so
  the list no longer flashes empty mid-refresh.
- Show "Updated Xs ago" next to the Models count, plus loading and
  error states for the list body.
- Split resetSnapshotToLoading into full vs targeted branches.
- Flatten nested ternary in model list rendering into renderModelsBody.
- Drop redundant local refreshing state and cargo useMemo wrappers.

* fix(app): widen isImeComposingKeyboardEvent type to accept optional fields

TextInputKeyPressEventData has isComposing and keyCode as optional, so
Pick<KeyboardEvent, ...> was too narrow and broke typecheck on main.
2026-04-16 13:14:02 +08:00
Li Mu Zhi
5c6f175db5 fix: make code file preview text selectable on iOS (#447)
Add `selectable` prop to the code line Text component so users can
long-press to select and copy text on iOS. On web/desktop this is
already the default behavior via CSS user-select.

Markdown file preview has a similar issue but requires a different
fix (custom rules for react-native-markdown-display), left for a
follow-up.

Refs #238
Related #21

Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
2026-04-16 11:06:16 +08:00
Mohamed Boudra
278acf5fe1 docs: credit external contributors with PR/GitHub links in changelog
- Add attribution format requiring PR link and contributor GitHub for each bullet
- Skip attribution for core team (@boudra); changelog highlights community work
- One bullet can reference multiple PRs and contributors; de-duplicate names
- Document ordering: user-facing features first, then QoL, then internal-with-user-benefit
2026-04-16 10:03:27 +07:00
Aaron Florey
165ae58f75 fix(server): Improve OpenCode permission prompt context (#398)
Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer.
2026-04-16 11:01:56 +08:00
amir
a1f3256923 style(app): theme native scrollbars across all web views (#399)
* style(app): theme native scrollbars in tool call detail views

Apply CSS scrollbar-color to all ScrollViews in ToolCallDetailsContent
and DiffViewer so the browser scrollbar matches the app's dark theme
instead of showing the default white native scrollbar.

* refactor: split useWebScrollbarStyle into .web.ts/.native.ts platform files

- Native shim returns undefined without calling useUnistyles
- Web variant uses properly typed WebScrollbarStyle interface instead of `as any`
- Add .d.ts for TypeScript module resolution

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-16 10:59:31 +08:00
Mohamed Boudra
86bb5cc827 fix: make MCP tools work for archived agents, matching CLI code paths (#423)
Extract shared functions from session.ts (toAgentPersistenceHandle,
buildStoredAgentPayload, ensureAgentLoaded) so both CLI/WebSocket
handlers and MCP tools use the same code paths for agent lookup.

Fix get_agent_status, get_agent_activity, and list_agents MCP tools
to fall back to persistent storage for archived agents. Add
includeArchived param to list_agents. Fix setupFinishNotification
to not wake archived callers. Delete dead agent-management-mcp.ts.
2026-04-16 10:44:57 +08:00
Mohamed Boudra
483312e1dc feat: add disallowedTools to provider config
Allow providers to specify tools that should be disabled via a
disallowedTools array in config.json. This is useful for providers
that extend "claude" but point to third-party API endpoints that
don't support Anthropic-only server-side tools like WebSearch.

Closes #390
2026-04-16 09:42:54 +07:00
Edvard Chen
0b3c29b2ec fix(desktop): allow localhost origins in dev (#419)
Desktop dev uses Electron pages on random localhost ports, so the daemon must accept those websocket origins during local development.
2026-04-16 10:34:30 +08:00
Aaron Florey
1885d8602f feat(app): Render markdown files in file pane (#427)
Detect .md and .markdown files in the shared file pane and render them as markdown instead of syntax highlighted code. This makes README-style files easier to read without changing the existing preview flow for other text files or .mdx.

Keep the change focused to the app renderer by reusing the existing markdown stack and adding a narrow extension check with targeted coverage.
2026-04-16 10:33:49 +08:00
Aaron Florey
57312d4f75 fix(server): Map OpenCode todo and compaction events (#429)
Translate OpenCode todo and compaction events into Paseo timeline items so issue #106 uses the existing todo list and compaction UI without changing the client model.

Keep session.status handling limited to existing terminal states and add focused translator coverage for each mapped event.

Fixes #106

Co-authored-by: OpenCode <noreply@openai.com>
2026-04-16 10:29:05 +08:00
Rui Fan
ffbb2ffd06 fix: retry file explorer init when client reconnects after page refresh (#442)
hasInitializedRef was set to true before confirming the directory
listing request actually succeeded. On page refresh the WebSocket
client is still reconnecting, so requestDirectoryListing returned
early with "Host is not connected" and the ref stayed true —
preventing any retry once the client became available.

Fix: make requestDirectoryListing return Promise<boolean> and reset
hasInitializedRef on failure so the init effect re-runs automatically
when requestDirectoryListing is recreated after the client reconnects.

Fixes #441

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:28:02 +08:00
Edvard Chen
45a5ba8637 Fix duplicate command args for generic ACP providers (#444)
* Fix duplicate command args for generic ACP providers

* Fix desktop IME enter handling
2026-04-16 10:23:05 +08:00
github-actions[bot]
b8ade39f73 fix: update lockfile signatures and Nix hash 2026-04-15 14:03:10 +00:00
Mohamed Boudra
deca82653e chore(release): cut 0.1.57-rc.1 2026-04-15 21:02:07 +07:00
Mohamed Boudra
4e9d80d118 fix: prevent model name truncation in combobox item rows 2026-04-15 19:25:24 +07:00
Li Mu Zhi
918949c7fa fix: file preview shows stale content when re-opening a file (#411)
* fix: file preview shows stale content when re-opening a file

The file content query inherits `refetchOnMount: false` and
`gcTime: Infinity` from the global QueryClient defaults, so
re-opening a previously viewed file never re-fetches its content.

Set `staleTime: 0` and `refetchOnMount: true` on the file preview
query so it always fetches the latest content when the component
mounts.

Fixes #351

* fix: keep original staleTime, only add refetchOnMount

staleTime: 5_000 is reasonable — avoids redundant fetches when
quickly toggling the same file. The actual fix only needs
refetchOnMount: true to override the global default of false.

---------

Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
2026-04-15 13:24:14 +08:00
José Albornoz
c1df523a77 fix: update lockfile signatures and Nix hash (#412)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 13:14:47 +08:00
Mohamed Boudra
7a8f65805d refactor: rename allowedHosts to hostnames (#413)
* refactor: rename allowedHosts to hostnames for DNS rebinding config

The old name confused users who thought it was related to CORS.
"hostnames" communicates that you're declaring the daemon's own
hostnames, not granting access to external parties.

Backward compatible: old config files (daemon.allowedHosts), env var
(PASEO_ALLOWED_HOSTS), CLI flag (--allowed-hosts), and Nix option
(allowedHosts) all still work as deprecated aliases.

* fix(nix): update npmDepsHash to match current package-lock.json
2026-04-15 12:47:49 +08:00
Mohamed Boudra
89b87c0235 docs: add local test suite execution rules 2026-04-15 10:40:24 +07:00
Mohamed Boudra
fc96b260d1 refactor: rename isInputActive to isPaneFocused for clarity
The prop represents whether the pane/panel is focused, not whether the
text input is focused. Rename across all 5 files to match the caller
names and reduce confusion.
2026-04-15 10:36:36 +07:00
Mohamed Boudra
3fe79535cc fix: only show ⌘L focus hint when agent panel is active 2026-04-15 10:33:42 +07:00
Mohamed Boudra
d40e4b6f0d feat: add ⌘L shortcut to focus message input with hint
Show a muted "⌘L to focus" hint in the top-right of the message input
when unfocused and empty on web. Registers Cmd+L (Mac) / Ctrl+L
(non-Mac) bindings for the existing message-input.focus action.
2026-04-15 09:57:05 +07:00
Aaron Florey
8f04e65cf1 fix(opencode): Wait for SSE after slash command timeouts (#407)
Treat OpenCode slash-command header timeouts as recoverable transport failures so Paseo waits for the SSE terminal event instead of failing the turn immediately.

Add a regression test for the timeout path to keep slash commands aligned with the existing event-stream completion flow.
2026-04-15 10:52:58 +08:00
Aaron Florey
c5295a4ab3 fix(server): archive OpenCode sessions on close (#408)
Reconcile OpenCode provider state when Paseo closes or archives an agent so upstream sessions do not remain active after local teardown.

Keep the close path idempotent by attempting an abort first and then marking the upstream session archived even when the run is already idle or missing.

Fixes #400

Co-authored-by: OpenCode <noreply@openai.com>
2026-04-15 10:52:15 +08:00
Mohamed Boudra
f59af4c810 fix: catch unhandled timeout rejection in attention clear
clearAgentAttention returns a promise that was never caught,
causing uncaught rejection errors on daemon timeout.
2026-04-15 09:16:09 +07:00
Mohamed Boudra
e012c8f52e feat: return agent snapshots from cancel and clear-attention RPCs
Convert cancel_agent_request and clear_agent_attention from
fire-and-forget to request/response RPCs that return the authoritative
agent snapshot. This enables client-side self-healing when agent_update
messages are missed (e.g. agent appears stuck running when it isn't).
2026-04-14 23:21:37 +07:00
Mohamed Boudra
312a6e22fe fix: flush head before applying canonical catch-up entries on mobile
On mobile, the server drops live stream events for backgrounded or
unfocused agents.  When the user returns, a seq gap triggers a
catch-up fetch whose canonical entries are appended to the tail —
but the head was never flushed.  Stale live items from before the
gap stayed in the head and rendered after the newer catch-up entries,
breaking chronological order.  Worse, subsequent live events of the
same kind (e.g. assistant_message) would append to the stale head
item, garbling message content.

Now the incremental path in processTimelineResponse flushes head →
tail before reducing canonical entries, keeping the timeline ordered
and the head clean for new live events.
2026-04-14 23:03:23 +07:00
Mohamed Boudra
7f92f7f51d fix: stabilize branch switcher layout on mobile
Always render the GitBranch icon regardless of branch availability so
the layout doesn't shift when the branch name loads. Only the chevron
remains dynamic. Also removes vertical padding on mobile to tighten
the gap between the branch row and project subtitle.
2026-04-14 22:27:06 +07:00
Mohamed Boudra
200453f032 docs: add 0.1.56 changelog 2026-04-14 21:17:12 +07:00
124 changed files with 5143 additions and 2134 deletions

View File

@@ -99,7 +99,22 @@ jobs:
- name: Run Windows-critical server tests
working-directory: packages/server
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
run: >
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
app-tests:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,45 @@
# Changelog
## 0.1.59 - 2026-04-16
### Added
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
- Extra High reasoning effort for Opus 4.7, between High and Max.
## 0.1.58 - 2026-04-16
### Added
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
- `disallowedTools` option in provider config to block specific tools from an agent.
### Improved
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey))
- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423))
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
### Fixed
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
- Workspace headers no longer show a branch icon for non-git workspaces.
- Branch switcher layout is stable on mobile.
- Model names no longer truncate mid-word in the picker rows.
- Messages appear in the correct order after reconnecting on mobile.
- Clearing agent attention no longer throws on timeout.
## 0.1.56 - 2026-04-14
### Fixed
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
- A single problematic project can no longer prevent the rest of your workspaces from loading.
## 0.1.55 - 2026-04-14
### Added

View File

@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:

View File

@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
## Agent authentication

View File

@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
listen: "127.0.0.1:0", // OS picks a free port
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,

View File

@@ -107,6 +107,7 @@ Required fields for custom providers:
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Automated setup is also available: `npx @z_ai/coding-helper`
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
@@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
- API keys must be created in the **Singapore region**
- The coding plan is for personal use only in interactive coding tools
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
---
@@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields:
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
@@ -460,6 +463,29 @@ Each entry in the `models` array:
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
Use `disallowedTools` to disable unsupported tools:
```json
{
"agents": {
"providers": {
"my-proxy": {
"extends": "claude",
"label": "My Proxy",
"env": {
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
},
"disallowedTools": ["WebSearch"]
}
}
}
}
```
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`

View File

@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }

View File

@@ -139,6 +139,43 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
```markdown
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
```
Rules:
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
## Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.

View File

@@ -9,6 +9,10 @@ let
cfg = config.services.paseo;
in
{
imports = [
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
];
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
@@ -58,12 +62,12 @@ in
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
hostnames = 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).
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
@@ -141,10 +145,10 @@ in
"/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;
} // lib.optionalAttrs (cfg.hostnames == true) {
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // cfg.environment;
serviceConfig = {

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# 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-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE=";
npmDepsHash = "sha256-rpXng5y99bDt7a3f6vI+EWrVahlXINdHzCf7ml+ZnFs=";
# 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).

63
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.56",
"version": "0.1.59",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.56",
"version": "0.1.59",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34906,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.56",
"version": "0.1.59",
"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.56",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/expo-two-way-audio": "0.1.59",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/server": "0.1.59",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35056,11 +35056,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/relay": "0.1.59",
"@getpaseo/server": "0.1.59",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35101,11 +35101,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.56",
"version": "0.1.59",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/cli": "0.1.59",
"@getpaseo/server": "0.1.59",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35139,7 +35139,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.56",
"version": "0.1.59",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35340,7 +35340,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35366,7 +35366,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35382,14 +35382,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/relay": "0.1.56",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/relay": "0.1.59",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35417,6 +35417,7 @@
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.1"
@@ -35640,6 +35641,15 @@
"node": ">= 0.8"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -35794,6 +35804,21 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -35817,7 +35842,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.56",
"version": "0.1.59",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.56",
"version": "0.1.59",
"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.56",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/expo-two-way-audio": "0.1.59",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/server": "0.1.59",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -51,6 +51,17 @@
[contenteditable='true'] {
-webkit-app-region: no-drag !important;
}
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid #20744A;
outline-offset: 2px;
}
</style>
</head>
<body>

View File

@@ -16,6 +16,35 @@ import {
import { X } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
function handleEscKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
const top = escStack[escStack.length - 1];
if (!top) return;
event.stopPropagation();
event.preventDefault();
top();
}
function pushEscHandler(handler: EscHandler): () => void {
escStack.push(handler);
if (!escListenerAttached && typeof window !== "undefined") {
window.addEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = true;
}
return () => {
const index = escStack.lastIndexOf(handler);
if (index !== -1) escStack.splice(index, 1);
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
window.removeEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = false;
}
};
}
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
...StyleSheet.absoluteFillObject,
@@ -48,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.surface2,
},
title: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -83,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[6],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
flex: 1,
padding: theme.spacing[6],
gap: theme.spacing[4],
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
padding: theme.spacing[6],
gap: theme.spacing[4],
},
}));
function SheetBackground({ style }: BottomSheetBackgroundProps) {
@@ -106,9 +155,11 @@ export interface AdaptiveModalSheetProps {
visible: boolean;
onClose: () => void;
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
scrollable?: boolean;
}
export function AdaptiveModalSheet({
@@ -116,9 +167,11 @@ export function AdaptiveModalSheet({
visible,
onClose,
children,
headerActions,
snapPoints,
stackBehavior,
testID,
scrollable = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
@@ -157,6 +210,11 @@ export function AdaptiveModalSheet({
[],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
}, [visible, isMobile, onClose]);
if (isMobile) {
return (
<BottomSheetModal
@@ -174,18 +232,25 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
</BottomSheetModal>
);
}
@@ -199,19 +264,26 @@ export function AdaptiveModalSheet({
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
</View>
</View>
);

View File

@@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
interface AgentInputAreaProps {
agentId: string;
serverId: string;
isInputActive: boolean;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
@@ -98,7 +98,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
agentId,
serverId,
isInputActive,
isPaneFocused,
onSubmitMessage,
isSubmitLoading = false,
blurOnSubmit = false,
@@ -421,7 +421,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!isInputActive) {
if (!isPaneFocused) {
return false;
}
@@ -461,7 +461,7 @@ export function AgentInputArea({
return false;
}
},
[isInputActive],
[isPaneFocused],
);
useKeyboardActionHandler({
@@ -475,9 +475,9 @@ export function AgentInputArea({
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isInputActive,
enabled: isPaneFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isInputActive,
isActive: () => isPaneFocused,
handle: handleKeyboardAction,
});
@@ -736,7 +736,7 @@ export function AgentInputArea({
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isInputActive={isInputActive}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}

View File

@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
export interface Artifact {
id: string;
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
const webScrollbarStyle = useWebScrollbarStyle();
if (!artifact) {
return null;
}
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
{/* Content */}
<ScrollView
style={styles.contentScroll}
style={[styles.contentScroll, webScrollbarStyle]}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>

View File

@@ -43,12 +43,17 @@ export function BranchSwitcher({
queryClient,
});
if (!currentBranchName) {
return (
const titleContent = (
<>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
);
</>
);
if (!currentBranchName) {
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
}
return (
@@ -63,10 +68,7 @@ export function BranchSwitcher({
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
</Pressable>
<Combobox
@@ -117,7 +119,10 @@ const styles = StyleSheet.create((theme) => ({
xs: -theme.spacing[2],
md: 0,
},
paddingVertical: theme.spacing[1],
paddingVertical: {
xs: 0,
md: theme.spacing[1],
},
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,

View File

@@ -1,4 +1,5 @@
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
interface DiffScrollProps {
children: React.ReactNode;
@@ -14,12 +15,14 @@ export function DiffScroll({
style,
contentContainerStyle,
}: DiffScrollProps) {
const webScrollbarStyle = useWebScrollbarStyle();
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
style={[style, webScrollbarStyle]}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
>

View File

@@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -23,6 +24,7 @@ export function DiffViewer({
fillAvailableHeight = false,
}: DiffViewerProps) {
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const webScrollbarStyle = useWebScrollbarStyle();
if (!diffLines.length) {
return (
@@ -38,6 +40,7 @@ export function DiffViewer({
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.verticalContent}
nestedScrollEnabled
@@ -47,6 +50,7 @@ export function DiffViewer({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
>

View File

@@ -167,23 +167,32 @@ export function FileExplorerPane({
if (hasInitializedRef.current) {
return;
}
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
// If the root listing fails (e.g. client not yet connected), we reset the
// flag so the next time requestDirectoryListing is recreated (when client
// becomes available) this effect retries automatically.
hasInitializedRef.current = true;
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}).then((succeeded) => {
if (!succeeded) {
hasInitializedRef.current = false;
return;
}
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}
});
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
});
});

View File

@@ -0,0 +1,4 @@
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import {
ActivityIndicator,
Image as RNImage,
@@ -12,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
highlightCode,
darkHighlightColors,
@@ -20,7 +22,9 @@ import {
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
<Text selectable style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
@@ -117,19 +121,23 @@ function FilePreviewBody({
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
@@ -154,6 +162,28 @@ function FilePreviewBody({
}
if (preview.kind === "text") {
if (isMarkdownFile) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
@@ -188,6 +218,7 @@ function FilePreviewBody({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
@@ -264,6 +295,7 @@ export function FilePane({
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
refetchOnMount: true,
});
return (
@@ -328,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
previewMarkdownScrollContent: {
padding: theme.spacing[4],
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],

View File

@@ -42,7 +42,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
@@ -75,8 +78,8 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isInputActive?: boolean;
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
isPaneFocused?: 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 before the voice button (e.g., context window meter) */
@@ -206,7 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus = false,
autoFocusKey,
disabled = false,
isInputActive = true,
isPaneFocused = true,
leftContent,
beforeVoiceContent,
rightContent,
@@ -233,7 +236,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const queueKeys = useShortcutKeys("message-input-queue");
const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
@@ -427,7 +432,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isInputActive },
autoStopWhenHidden: { isVisible: isPaneFocused },
enableDuration: true,
});
@@ -891,7 +896,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
@@ -997,10 +1002,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}}
style={[
@@ -1025,6 +1032,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
<Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
</Text>
) : null}
</View>
{/* Button row */}
@@ -1270,6 +1282,14 @@ const styles = StyleSheet.create(((theme: any) => ({
textInputScrollWrapper: {
position: "relative",
},
focusHintText: {
position: "absolute",
top: 0,
right: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
opacity: 0.5,
},
textInput: {
width: "100%",
color: theme.colors.foreground,

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { AlertCircle, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { formatTimeAgo } from "@/utils/time";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo(
() => snapshotEntries?.find((entry) => entry.provider === provider),
[snapshotEntries, provider],
);
const models = providerEntry?.models ?? [];
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
// clockTick triggers re-computation on timer
}, [providerEntry?.fetchedAt, clockTick]);
setLoading(true);
setDiagnostic(null);
const q = query.trim().toLowerCase();
const filteredModels = q
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
: models;
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
}, [client, provider]);
const fetchDiagnostic = useCallback(
async (options?: { keepCurrent?: boolean }) => {
if (!client || !provider) return;
setLoading(true);
if (!options?.keepCurrent) {
setDiagnostic(null);
}
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
},
[client, provider],
);
const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]);
void fetchDiagnostic({ keepCurrent: true });
}, [fetchDiagnostic, provider, refresh]);
useEffect(() => {
if (visible) {
fetchDiagnostic();
} else {
setDiagnostic(null);
setQuery("");
}
}, [visible, fetchDiagnostic]);
function renderModelsBody() {
if (models.length === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models</Text>
</View>
);
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyState}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
);
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>No models match your search</Text>
</View>
);
}
return filteredModels.map((model: AgentModelDefinition, index) => (
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
));
}
return (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
scrollable={false}
headerActions={
<Pressable
onPress={handleRefresh}
disabled={refreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
sheetStyles.iconButton,
(hovered || pressed) && sheetStyles.iconButtonHovered,
refreshInFlight ? sheetStyles.disabled : null,
]}
accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
<SpinningRefreshIcon
spinning={refreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
}
>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
{loading && !diagnostic ? (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
style={sheetStyles.codeScroll}
contentContainerStyle={sheetStyles.codeContent}
showsVerticalScrollIndicator={false}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text style={sheetStyles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
)}
</View>
</View>
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
<Text style={sheetStyles.countText}>{models.length}</Text>
{fetchedAtLabel ? (
<>
<Text style={sheetStyles.metaDot}>·</Text>
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
</>
) : null}
</View>
</View>
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
placeholder="Search models"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
// @ts-expect-error - outlineStyle is web-only
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
/>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
) : null}
</View>
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
loadingContainer: {
section: {
gap: theme.spacing[2],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
mutedText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
iconButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
codeBlock: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
maxHeight: 180,
},
codeScroll: {
maxHeight: 180,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
modelsSection: {
flex: 1,
minHeight: 0,
gap: theme.spacing[2],
},
modelsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
modelsHeaderMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
metaDot: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
countText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
modelsScroll: {
flex: 1,
minHeight: 0,
},
modelsScrollContent: {
paddingBottom: theme.spacing[2],
},
modelRow: {
paddingVertical: theme.spacing[3],
},
modelRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
modelLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
modelId: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontFamily: Fonts.mono,
marginTop: 2,
},
emptyState: {
paddingVertical: theme.spacing[6],
alignItems: "center",
gap: theme.spacing[2],
},
loadingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
scrollContainer: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4],
},
diagnosticText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontFamily: "monospace",
lineHeight: theme.fontSize.sm * 1.6,
},
}));

View File

@@ -0,0 +1,67 @@
import { useEffect } from "react";
import { RefreshCw } from "lucide-react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
interface SpinningRefreshIconProps {
spinning: boolean;
size: number;
color: string;
}
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
const rotation = useSharedValue(0);
useEffect(() => {
if (spinning) {
rotation.value = 0;
rotation.value = withRepeat(
withTiming(360, {
duration: 1000,
easing: Easing.linear,
}),
-1,
false,
);
return;
}
cancelAnimation(rotation);
const remainder = rotation.value % 360;
if (Math.abs(remainder) < 0.001) {
rotation.value = 0;
return;
}
rotation.value = withTiming(360, {
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
easing: Easing.linear,
});
}, [rotation, spinning]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View
style={[
{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
},
animatedStyle,
]}
>
<RefreshCw size={size} color={color} />
</Animated.View>
);
}

View File

@@ -6,6 +6,7 @@ import { Fonts } from "@/constants/theme";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -30,6 +31,7 @@ export function ToolCallDetailsContent({
showLoadingSkeleton = false,
}: ToolCallDetailsContentProps) {
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
const webScrollbarStyle = useWebScrollbarStyle();
// Compute diff lines for edit type
const diffLines = useMemo(() => {
@@ -65,6 +67,7 @@ export function ToolCallDetailsContent({
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -74,6 +77,7 @@ export function ToolCallDetailsContent({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
@@ -99,6 +103,7 @@ export function ToolCallDetailsContent({
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -108,6 +113,7 @@ export function ToolCallDetailsContent({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
@@ -137,6 +143,7 @@ export function ToolCallDetailsContent({
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -146,6 +153,7 @@ export function ToolCallDetailsContent({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
@@ -181,12 +189,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -204,12 +218,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -236,12 +256,18 @@ export function ToolCallDetailsContent({
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -286,12 +312,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
</Text>
@@ -356,7 +388,7 @@ export function ToolCallDetailsContent({
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
style={[styles.jsonScroll, webScrollbarStyle]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
@@ -378,7 +410,7 @@ export function ToolCallDetailsContent({
<ScrollView
horizontal
nestedScrollEnabled
style={[styles.jsonScroll, styles.jsonScrollError]}
style={[styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>

View File

@@ -890,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
flexShrink: 0,
},
comboboxItemDescription: {
fontSize: theme.fontSize.xs,

View File

@@ -33,6 +33,7 @@ import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -348,6 +349,7 @@ export function ContextMenuContent({
testID?: string;
}>): ReactElement | null {
const context = useContextMenuContext("ContextMenuContent");
const webScrollbarStyle = useWebScrollbarStyle();
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
@@ -537,6 +539,7 @@ export function ContextMenuContent({
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
>
{children}

View File

@@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -267,6 +268,7 @@ export function DropdownMenuContent({
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -411,6 +413,7 @@ export function DropdownMenuContent({
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
>
{children}

View File

@@ -1,7 +1,12 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
applyStreamEvent,
flushHeadToTail,
hydrateStreamState,
reduceStreamUpdate,
} from "@/types/stream";
import {
classifySessionTimelineSeq,
type SessionTimelineSeqDecision,
@@ -214,12 +219,24 @@ export function processTimelineResponse(
}
if (acceptedUnits.length > 0) {
// Flush head to tail before appending canonical entries so that
// chronological ordering is preserved. This matters on mobile where
// the server drops live events for backgrounded/unfocused agents:
// when the client catches up, the head may still hold stale live
// items from before the gap that must be committed ahead of the
// canonical gap-fill entries.
const baseTail =
currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail;
if (currentHead.length > 0) {
nextHead = [];
}
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
baseTail,
);
}

View File

@@ -59,7 +59,7 @@ export function useAgentAttentionClear({
return;
}
deferredFocusEntryClearRef.current = false;
client.clearAgentAttention(resolvedAgentId);
client.clearAgentAttention(resolvedAgentId).catch(() => {});
},
[agentId, attentionReason, client, isConnected, requiresAttention],
);

View File

@@ -82,9 +82,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
);
const requestDirectoryListing = useCallback(
async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => {
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<boolean> => {
if (!workspaceStateKey) {
return;
return false;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
@@ -113,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: "Workspace is unavailable",
pendingRequest: null,
}));
return;
return false;
}
if (!client) {
@@ -123,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: "Host is not connected",
pendingRequest: null,
}));
return;
return false;
}
try {
@@ -150,6 +153,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return nextState;
});
return true;
} catch (error) {
updateExplorerState((state) => ({
...state,
@@ -157,6 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: error instanceof Error ? error.message : "Failed to list directory",
pendingRequest: null,
}));
return false;
}
},
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],

View File

@@ -27,6 +27,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
export function useKeyboardShortcuts({
enabled,
@@ -323,6 +324,12 @@ export function useKeyboardShortcuts({
return;
}
// During IME composition, Enter confirms the candidate selection and must
// not route through global shortcuts like message send.
if (isImeComposingKeyboardEvent(event)) {
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
@@ -14,9 +14,10 @@ interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
isFetching: boolean;
isRefreshing: boolean;
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
refresh: (providers?: AgentProvider[]) => Promise<void>;
invalidate: () => void;
}
@@ -43,6 +44,16 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
},
});
const refreshMutation = useMutation({
mutationFn: async (providers?: AgentProvider[]) => {
if (!client) {
return;
}
await client.refreshProvidersSnapshot({ providers });
},
});
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) {
return;
@@ -60,12 +71,12 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
});
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
const refresh = useCallback(() => {
if (!client) {
return;
}
void client.refreshProvidersSnapshot();
}, [client]);
const refresh = useCallback(
async (providers?: AgentProvider[]) => {
await refreshSnapshot(providers);
},
[refreshSnapshot],
);
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
@@ -75,6 +86,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
isFetching: snapshotQuery.isFetching,
isRefreshing,
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,

View File

@@ -0,0 +1 @@
export * from "./use-web-scrollbar-style.web";

View File

@@ -0,0 +1,5 @@
import type { ViewStyle } from "react-native";
export function useWebScrollbarStyle(): ViewStyle | undefined {
return undefined;
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from "react";
import type { ViewStyle } from "react-native";
import { useUnistyles } from "react-native-unistyles";
// CSS scrollbar properties are supported by React Native Web at runtime
// but are not included in React Native's ViewStyle type definition.
interface WebScrollbarStyle extends ViewStyle {
scrollbarColor: string;
scrollbarWidth: string;
}
export function useWebScrollbarStyle(): WebScrollbarStyle {
const { theme } = useUnistyles();
return useMemo(
(): WebScrollbarStyle => ({
scrollbarColor: `${theme.colors.scrollbarHandle} transparent`,
scrollbarWidth: "thin",
}),
[theme.colors.scrollbarHandle],
);
}

View File

@@ -791,6 +791,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Message input ---
{
id: "message-input-focus-cmd-l-mac",
action: "message-input.action",
combo: "Cmd+L",
when: { mac: true, commandCenter: false },
payload: { type: "message-input", kind: "focus" },
help: {
id: "focus-message-input",
section: "agent-input",
label: "Focus message input",
keys: ["mod", "L"],
},
},
{
id: "message-input-focus-ctrl-l-non-mac",
action: "message-input.action",
combo: "Ctrl+L",
when: { mac: false, commandCenter: false, terminal: false },
payload: { type: "message-input", kind: "focus" },
help: {
id: "focus-message-input",
section: "agent-input",
label: "Focus message input",
keys: ["mod", "L"],
},
},
{
id: "message-input-voice-toggle-cmd-shift-d-mac",
action: "message-input.action",

View File

@@ -762,7 +762,7 @@ function AgentPanelBody({
<AgentInputArea
agentId={agentId}
serverId={serverId}
isInputActive={isPaneFocused}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
images={agentInputDraft.images}

View File

@@ -1232,7 +1232,7 @@ function DraftAgentScreenContent({
<AgentInputArea
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isInputActive={isFocused}
isPaneFocused={isFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}

View File

@@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getProviderIcon } from "@/components/provider-icons";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
import { StatusBadge } from "@/components/ui/status-badge";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { isWeb } from "@/constants/platform";
@@ -521,9 +522,11 @@ interface ProvidersSectionProps {
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(routeServerId);
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = routeServerId.length > 0;
@@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
{hasServer && isConnected ? (
<Pressable
onPress={refresh}
disabled={isFetching}
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
onPress={() => {
void refresh();
}}
disabled={providerRefreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
settingsStyles.sectionHeaderLink,
styles.providerRefreshButton,
(hovered || pressed) && styles.providerRefreshButtonHovered,
providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null,
]}
accessibilityRole="button"
accessibilityLabel="Refresh providers"
>
<Text
style={{
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
}}
>
Refresh
</Text>
<SpinningRefreshIcon
spinning={providerRefreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
) : null}
</View>
@@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
? entry.error.trim()
: null;
const modelCount = entry?.models?.length ?? 0;
return (
<View key={def.id} style={styles.audioRow}>
<Pressable
key={def.id}
style={styles.audioRow}
onPress={() => setDiagnosticProvider(def.id)}
accessibilityRole="button"
>
<View style={styles.audioRowContent}>
<View
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
@@ -584,31 +601,27 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
{providerError}
</Text>
) : null}
{status === "ready" && modelCount > 0 ? (
<Text style={styles.audioRowSubtitle}>
{modelCount === 1 ? "1 model" : `${modelCount} models`}
</Text>
) : null}
</View>
<View style={styles.providerActions}>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
<Button
variant="secondary"
size="sm"
onPress={() => setDiagnosticProvider(def.id)}
>
Diagnostic
</Button>
</View>
</View>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
</Pressable>
);
})}
</View>
@@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({
formButtonPrimaryText: {
color: theme.colors.palette.white,
},
providerRefreshButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
justifyContent: "center",
},
providerRefreshButtonHovered: {
backgroundColor: theme.colors.surface2,
},
providerRefreshButtonDisabled: {
opacity: 0.5,
},
// Audio settings card
audioCard: {
overflow: "hidden",
@@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,

View File

@@ -10,6 +10,7 @@ import { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isWeb } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
type StartupSplashScreenProps = {
bootstrapState?: {
@@ -161,6 +162,7 @@ const styles = StyleSheet.create((theme) => ({
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
@@ -282,7 +284,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.errorScreen}>
<TitlebarDragRegion />
<ScrollView
style={styles.errorScrollView}
style={[styles.errorScrollView, webScrollbarStyle]}
contentContainerStyle={styles.errorScrollContent}
showsVerticalScrollIndicator
>
@@ -303,7 +305,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
style={[styles.logsScroll, webScrollbarStyle]}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>

View File

@@ -342,7 +342,7 @@ export function WorkspaceDraftAgentTab({
<AgentInputArea
agentId={tabId}
serverId={serverId}
isInputActive={isPaneFocused}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}

View File

@@ -731,12 +731,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
};
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
const isCheckoutQueryEnabled =
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
isAbsolutePath(normalizedWorkspaceId);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
enabled:
Boolean(client && isConnected) &&
normalizedWorkspaceId.length > 0 &&
isAbsolutePath(normalizedWorkspaceId),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -745,6 +746,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
},
staleTime: 15_000,
});
const isCheckoutStatusLoading =
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
const workspaceDescriptor = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
@@ -758,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null;
const isWorkspaceHeaderLoading = workspaceHeader === null;
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(

View File

@@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
/**
* Flush head items to tail, avoiding duplicates.
*/
function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
if (head.length === 0) {
return tail;
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isImeComposingKeyboardEvent } from "./keyboard-ime";
describe("isImeComposingKeyboardEvent", () => {
it("ignores events while IME composition is active", () => {
expect(
isImeComposingKeyboardEvent({
isComposing: true,
keyCode: 13,
} as KeyboardEvent),
).toBe(true);
});
it("ignores Chromium IME fallback events with keyCode 229", () => {
expect(
isImeComposingKeyboardEvent({
isComposing: false,
keyCode: 229,
} as KeyboardEvent),
).toBe(true);
});
it("keeps regular keyboard events eligible for shortcuts", () => {
expect(
isImeComposingKeyboardEvent({
isComposing: false,
keyCode: 13,
} as KeyboardEvent),
).toBe(false);
});
});

View File

@@ -0,0 +1,6 @@
export function isImeComposingKeyboardEvent(event: {
isComposing?: boolean;
keyCode?: number;
}): boolean {
return Boolean(event.isComposing) || event.keyCode === 229;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.56",
"version": "0.1.59",
"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.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/relay": "0.1.59",
"@getpaseo/server": "0.1.59",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,4 +1,4 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
@@ -124,10 +124,27 @@ export function createCli(): Command {
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(withOutput(runDaemonRestartCommand));
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(
withOutput((...args) => {
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
return runDaemonRestartCommand(
{
...options,
hostnames:
typeof options.hostnames === "string"
? options.hostnames
: typeof options.allowedHosts === "string"
? options.allowedHosts
: undefined,
},
command,
);
}),
);
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand());

View File

@@ -1,4 +1,4 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
@@ -36,10 +36,27 @@ export function createDaemonCommand(): Command {
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(withOutput(runRestartCommand));
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(
withOutput((...args) => {
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
return runRestartCommand(
{
...options,
hostnames:
typeof options.hostnames === "string"
? options.hostnames
: typeof options.allowedHosts === "string"
? options.allowedHosts
: undefined,
},
command,
);
}),
);
return daemon;
}

View File

@@ -13,7 +13,7 @@ export interface DaemonStartOptions {
relay?: boolean;
mcp?: boolean;
injectMcp?: boolean;
allowedHosts?: string;
hostnames?: string;
}
export interface LocalDaemonPidInfo {
@@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
} else if (options.port) {
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
}
if (options.allowedHosts) {
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
if (options.hostnames) {
childEnv.PASEO_HOSTNAMES = options.hostnames;
}
return childEnv;
}
@@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
...envWithHome(options.home),
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
PASEO_LISTEN: undefined,
PASEO_HOSTNAMES: undefined,
PASEO_ALLOWED_HOSTS: undefined,
};
const home = resolvePaseoHome(env);

View File

@@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
};
if (startOptions.listen && startOptions.port) {

View File

@@ -1,4 +1,4 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import chalk from "chalk";
import {
startLocalDaemonForeground,
@@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js";
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
type RawStartCommandOptions = StartOptions & {
allowedHosts?: string;
};
export function startCommand(): Command {
return new Command("start")
.description("Start the local Paseo daemon")
@@ -20,11 +24,15 @@ export function startCommand(): Command {
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(async (options: StartOptions) => {
await runStart(options);
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.action(async (options: RawStartCommandOptions) => {
await runStart({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
}

View File

@@ -1,5 +1,5 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
import { Command } from "commander";
import { Command, Option } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import {
@@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions {
voice?: "ask" | "enable" | "disable";
}
type RawOnboardOptions = OnboardOptions & {
allowedHosts?: string;
};
type OnboardPersistedConfig = PersistedConfig & {
features?: PersistedConfig["features"] & {
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
@@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number {
return Math.ceil(seconds * 1000);
}
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {};
if (options.listen) {
@@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
cliOverrides.relayEnabled = false;
}
if (options.allowedHosts) {
const raw = options.allowedHosts.trim();
cliOverrides.allowedHosts =
if (options.hostnames) {
const raw = options.hostnames.trim();
cliOverrides.hostnames =
raw.toLowerCase() === "true"
? true
: raw
@@ -297,13 +301,17 @@ export function onboardCommand(): Command {
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
.action(async (options: OnboardOptions) => {
await runOnboard(options);
.action(async (options: RawOnboardOptions) => {
await runOnboard({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
}

View File

@@ -31,6 +31,16 @@ type ProviderModel = {
};
const EXPECTED_CLAUDE_MODELS = [
{
id: "claude-opus-4-7[1m]",
model: "Opus 4.7 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-opus-4-7",
model: "Opus 4.7",
descriptionFragment: "Latest release",
},
{
id: "claude-opus-4-6[1m]",
model: "Opus 4.6 1M",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.56",
"version": "0.1.59",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/cli": "0.1.59",
"@getpaseo/server": "0.1.59",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

View File

@@ -13,6 +13,11 @@ npm run build:main
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
export EXPO_PORT
# Allow any origin in dev so Electron on random localhost ports can reach
# the daemon websocket. Safe here because this script is development-only
# and the daemon still binds to localhost.
export PASEO_CORS_ORIGINS="*"
echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev"
echo "══════════════════════════════════════════════════════"

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.56",
"version": "0.1.59",
"type": "module",
"publishConfig": {
"access": "public"

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.56",
"version": "0.1.59",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/relay": "0.1.56",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/relay": "0.1.59",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -93,6 +93,7 @@
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.1"

View File

@@ -1187,8 +1187,28 @@ export class DaemonClient {
}
}
clearAgentAttention(agentId: string | string[]): void {
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
async clearAgentAttention(agentId: string | string[]): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "clear_agent_attention",
agentId,
requestId,
});
await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "clear_agent_attention_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
}
sendHeartbeat(params: {
@@ -1692,7 +1712,27 @@ export class DaemonClient {
}
async cancelAgent(agentId: string): Promise<void> {
this.sendSessionMessage({ type: "cancel_agent_request", agentId });
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "cancel_agent_request",
agentId,
requestId,
});
await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "cancel_agent_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
@@ -2716,6 +2756,7 @@ export class DaemonClient {
async refreshProvidersSnapshot(options?: {
cwd?: string;
providers?: AgentProvider[];
requestId?: string;
}): Promise<RefreshProvidersSnapshotPayload> {
return this.sendCorrelatedSessionRequest({
@@ -2723,9 +2764,10 @@ export class DaemonClient {
message: {
type: "refresh_providers_snapshot_request",
cwd: options?.cwd,
providers: options?.providers,
},
responseType: "refresh_providers_snapshot_response",
timeout: 5000,
timeout: 60000,
});
}

View File

@@ -0,0 +1,80 @@
import type { Logger } from "pino";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "../persistence-hooks.js";
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
export interface EnsureAgentLoadedDeps {
agentManager: AgentManager;
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
logger: Logger;
}
export async function ensureAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps,
): Promise<ManagedAgent> {
const existing = deps.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
}
const initPromise = (async () => {
const record = await deps.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await deps.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
} else {
const config = buildSessionConfig(record, {
validProviders,
logger: deps.logger,
});
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels });
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
await deps.agentManager.hydrateTimelineFromProvider(agentId);
return deps.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentInitializations.get(agentId);
if (current === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -800,6 +800,12 @@ export class AgentManager {
: overrides;
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.resumeSession(handle, resumeOverrides, launchContext);
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
}

View File

@@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${disabledPort}`,
paseoHome: disabledPaseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
mcpInjectIntoAgents: false,
staticDir: disabledStaticDir,
@@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,

View File

@@ -13,6 +13,9 @@ import type {
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
import type { Logger } from "pino";
import { buildProviderRegistry } from "./provider-registry.js";
import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js";
export type { ManagedAgent };
@@ -91,7 +94,7 @@ export function toAgentPayload(
model: agent.config.model ?? null,
thinkingOptionId,
effectiveThinkingOptionId,
runtimeInfo,
...(runtimeInfo ? { runtimeInfo } : {}),
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
@@ -128,6 +131,93 @@ export function toAgentPayload(
return payload;
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
logger: Logger,
): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
} as const;
const createdAt = new Date(record.createdAt);
const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record));
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id);
const runtimeInfo = record.runtimeInfo
? {
provider: coerceAgentProvider(
logger,
providerRegistry,
record.runtimeInfo.provider,
record.id,
),
sessionId: record.runtimeInfo.sessionId,
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
? { model: record.runtimeInfo.model ?? null }
: {}),
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId")
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
: {}),
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId")
? { modeId: record.runtimeInfo.modeId ?? null }
: {}),
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
}
: undefined;
return {
id: record.id,
provider,
cwd: record.cwd,
model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
runtimeInfo,
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
}),
...(runtimeInfo ? { runtimeInfo } : {}),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
status: record.lastStatus,
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
pendingPermissions: [],
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
title: record.title ?? record.config?.title ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
labels: record.labels,
};
}
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
const timestamps = [record.updatedAt, record.lastActivityAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map((value) => ({
raw: value,
parsed: Date.parse(value),
}))
.filter((value) => !Number.isNaN(value.parsed));
if (timestamps.length === 0) {
return record.updatedAt;
}
timestamps.sort((a, b) => b.parsed - a.parsed);
return timestamps[0].raw;
}
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (Object.prototype.hasOwnProperty.call(config, "title")) {

View File

@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
logger,
});
let allowedHosts: string[] | undefined;
let mcpAllowedHosts: string[] | undefined;
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
const createAgentMcpTransport = async (callerAgentId?: string) => {
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
agentMcpTransports.delete(sessionId);
},
enableDnsRebindingProtection: true,
...(allowedHosts ? { allowedHosts } : {}),
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
});
transport.onclose = () => {
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
});
});
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
mcpAllowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
const url = `http://127.0.0.1:${port}/mcp/agents`;
return {

View File

@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { AgentSnapshotPayloadSchema } from "../../shared/messages.js";
type TestDeps = {
agentManager: AgentManager;
@@ -29,10 +31,17 @@ function createTestDeps(): TestDeps {
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
notifyAgentState: vi.fn(),
getAgent: vi.fn(),
listAgents: vi.fn().mockReturnValue([]),
getTimeline: vi.fn().mockReturnValue([]),
resumeAgentFromPersistence: vi.fn(),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
respondToPermission: vi.fn(),
cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
};
const agentStorageSpies = {
@@ -40,7 +49,7 @@ function createTestDeps(): TestDeps {
setTitle: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn(),
list: vi.fn().mockResolvedValue([]),
remove: vi.fn(),
};
@@ -68,6 +77,43 @@ function createProviderDefinition(overrides: Partial<ProviderDefinition>): Provi
};
}
function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
const now = "2026-04-11T00:00:00.000Z";
return {
id: "stored-agent",
provider: "claude",
cwd: "/tmp/stored-project",
createdAt: now,
updatedAt: now,
lastActivityAt: now,
lastUserMessageAt: null,
title: "Stored agent",
labels: {},
lastStatus: "closed",
lastModeId: "default",
config: {
modeId: "default",
model: "claude-sonnet-4-20250514",
},
runtimeInfo: {
provider: "claude",
sessionId: "session-123",
model: "claude-sonnet-4-20250514",
},
features: [],
persistence: {
provider: "claude",
sessionId: "session-123",
},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: "2026-04-12T00:00:00.000Z",
...overrides,
};
}
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -483,4 +529,276 @@ describe("agent snapshot MCP serialization", () => {
});
expect(Array.isArray(structured.agents[0].features)).toBe(true);
});
it("returns archived agent snapshots from storage for get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
});
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(record);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["get_agent_status"];
const response = await tool.callback({ agentId: "archived-agent" });
expect(response.structuredContent).toEqual({
status: "closed",
snapshot: expect.objectContaining({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
title: "Stored agent",
status: "closed",
}),
});
expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent");
});
it("does not expose internal stored agents from get_agent_status", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue(null);
spies.agentStorage.get.mockResolvedValue(
createStoredRecord({
id: "internal-agent",
internal: true,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["get_agent_status"];
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
"Agent internal-agent not found",
);
});
it("includes stored non-archived agents in list_agents by default", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const liveAgent = {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent;
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "closed-agent", archivedAt: null }),
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
createStoredRecord({ id: "live-agent", archivedAt: null }),
createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({});
expect(response.structuredContent.agents).toEqual([
expect.objectContaining({ id: "live-agent" }),
expect.objectContaining({ id: "closed-agent", archivedAt: null }),
]);
});
it("includes archived stored agents in list_agents when requested", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const liveAgent = {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent;
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
createStoredRecord({
id: "internal-archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
internal: true,
}),
createStoredRecord({ id: "not-archived-agent", archivedAt: null }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({ includeArchived: true });
expect(response.structuredContent.agents).toEqual([
expect.objectContaining({ id: "live-agent" }),
expect.objectContaining({
id: "archived-agent",
archivedAt: "2026-04-12T00:00:00.000Z",
}),
expect.objectContaining({
id: "not-archived-agent",
archivedAt: null,
}),
]);
});
it("emits list_agents payloads that satisfy the declared output schema", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const liveAgent = {
id: "live-agent",
provider: "claude",
cwd: "/tmp/live-project",
config: {},
runtimeInfo: undefined,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
features: [],
pendingPermissions: new Map(),
persistence: null,
labels: {},
attention: { requiresAttention: false },
} as unknown as ManagedAgent;
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "stored-non-archived", archivedAt: null }),
createStoredRecord({ id: "stored-archived", archivedAt: "2026-04-12T00:00:00.000Z" }),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({ includeArchived: true });
const parsed = z.array(AgentSnapshotPayloadSchema).safeParse(response.structuredContent.agents);
if (!parsed.success) {
throw new Error(
`list_agents response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
);
}
});
it("loads archived agents before reading get_agent_activity", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const record = createStoredRecord({ id: "archived-activity-agent" });
const snapshot = {
id: "archived-activity-agent",
currentModeId: "default",
} as ManagedAgent;
spies.agentManager.getAgent
.mockReturnValueOnce(null)
.mockReturnValue(snapshot)
.mockReturnValue(snapshot);
spies.agentStorage.get.mockResolvedValue(record);
spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot);
spies.agentManager.getTimeline.mockReturnValue([
{
kind: "status",
timestamp: "2026-04-11T00:00:00.000Z",
text: "Agent resumed",
},
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
logger,
providerRegistry: {
claude: createProviderDefinition({}),
} as any,
});
const tool = (server as any)._registeredTools["get_agent_activity"];
const response = await tool.callback({ agentId: "archived-activity-agent" });
expect(response.structuredContent).toEqual(
expect.objectContaining({
agentId: "archived-activity-agent",
updateCount: 1,
currentModeId: "default",
}),
);
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
"archived-activity-agent",
);
});
});

View File

@@ -12,9 +12,10 @@ import {
AgentPermissionResponseSchema,
AgentSnapshotPayloadSchema,
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -39,6 +40,7 @@ import {
parseDurationString,
resolveProviderAndModel,
sanitizePermissionRequest,
sendPromptToAgent,
setupFinishNotification,
serializeSnapshotWithMetadata,
startAgentRun,
@@ -198,6 +200,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
version: "2.0.0",
});
const requireProviderRegistry = (): Record<AgentProvider, ProviderDefinition> => {
if (!providerRegistry) {
throw new Error("Provider registry is required to load stored agent records");
}
return providerRegistry;
};
const resolveCallerAgent = () => {
if (!callerAgentId) {
return null;
@@ -594,6 +603,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (notifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: snapshot.id,
callerAgentId,
logger: childLogger,
@@ -759,33 +769,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => {
const snapshot = agentManager.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
}
if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
}
if (sessionMode) {
await agentManager.setAgentMode(agentId, sessionMode);
}
try {
agentManager.recordUserMessage(agentId, prompt, {
emitState: false,
});
} catch (error) {
childLogger.error({ err: error, agentId }, "Failed to record user message");
}
startAgentRun(agentManager, agentId, prompt, childLogger, {
replaceRunning: true,
await sendPromptToAgent({
agentManager,
agentStorage,
agentId,
userMessageText: prompt,
prompt,
sessionMode,
logger: childLogger,
});
if (notifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
logger: childLogger,
@@ -849,19 +850,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
async ({ agentId }) => {
const snapshot = agentManager.getAgent(agentId);
if (!snapshot) {
if (snapshot) {
const structuredSnapshot = await serializeSnapshotWithMetadata(
agentStorage,
snapshot,
childLogger,
);
return {
content: [],
structuredContent: ensureValidJson({
status: snapshot.lifecycle,
snapshot: structuredSnapshot,
}),
};
}
const record = await agentStorage.get(agentId);
if (!record || record.internal) {
throw new Error(`Agent ${agentId} not found`);
}
const structuredSnapshot = await serializeSnapshotWithMetadata(
agentStorage,
snapshot,
const structuredSnapshot = buildStoredAgentPayload(
record,
requireProviderRegistry(),
childLogger,
);
return {
content: [],
structuredContent: ensureValidJson({
status: snapshot.lifecycle,
status: structuredSnapshot.status,
snapshot: structuredSnapshot,
}),
};
@@ -873,21 +890,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "List agents",
description: "List all live agents managed by the server.",
inputSchema: {},
inputSchema: {
includeArchived: z.boolean().optional().default(false),
},
outputSchema: {
agents: z.array(AgentSnapshotPayloadSchema),
},
},
async () => {
const snapshots = agentManager.listAgents();
const agents = await Promise.all(
snapshots.map((snapshot) =>
async ({ includeArchived }) => {
const liveSnapshots = agentManager.listAgents();
const liveAgents = await Promise.all(
liveSnapshots.map((snapshot) =>
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
),
);
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
const storedRecords = await agentStorage.list();
const storedAgents = storedRecords
.filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt)
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
return {
content: [],
structuredContent: ensureValidJson({ agents }),
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }),
};
},
);
@@ -1562,6 +1588,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async ({ agentId, limit }) => {
await ensureAgentLoaded(agentId, {
agentManager,
agentStorage,
logger: childLogger,
});
const timeline = agentManager.getTimeline(agentId);
const snapshot = agentManager.getAgent(agentId);

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { setupFinishNotification } from "./mcp-shared.js";
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
describe("setupFinishNotification", () => {
it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent = {
id: "child-agent",
lifecycle: "idle",
config: { title: "Child Agent" },
} as ManagedAgent;
const agentManager = {
getAgent: vi.fn((agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return {
id: "caller-agent",
lifecycle: "idle",
config: { title: "Caller Agent" },
} as ManagedAgent;
}
return null;
}),
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
hasInFlightRun: vi.fn().mockReturnValue(false),
streamAgent: vi.fn(() => (async function* noop() {})()),
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
} as unknown as AgentManager;
const agentStorage = {
get: vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
),
} as unknown as AgentStorage;
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
logger: createTestLogger(),
});
expect(subscriber).not.toBeNull();
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
await vi.waitFor(() => {
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
});
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,15 @@
import { z } from "zod";
import type { Logger } from "pino";
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
import type {
AgentPromptInput,
AgentPermissionRequest,
AgentRunOptions,
} from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
@@ -94,6 +99,7 @@ export function resolveProviderAndModel(params: {
export type StartAgentRunOptions = {
replaceRunning?: boolean;
runOptions?: AgentRunOptions;
};
/**
@@ -171,9 +177,10 @@ export function startAgentRun(
options?: StartAgentRunOptions,
): void {
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
const runOptions = options?.runOptions;
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt)
: agentManager.streamAgent(agentId, prompt);
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt, runOptions);
void (async () => {
try {
for await (const _ of iterator) {
@@ -185,20 +192,107 @@ export function startAgentRun(
})();
}
/**
* Clear the archived flag from a stored agent record.
* Shared across Session (app/WS), MCP, and CLI so every surface that acts on
* an archived agent unarchives it the same way.
*/
export async function unarchiveAgentState(
agentStorage: AgentStorage,
agentManager: AgentManager,
agentId: string,
): Promise<boolean> {
const record = await agentStorage.get(agentId);
if (!record || !record.archivedAt) {
return false;
}
const updatedAt = new Date().toISOString();
await agentStorage.upsert({
...record,
archivedAt: null,
updatedAt,
});
agentManager.notifyAgentState(agentId);
return true;
}
export interface SendPromptToAgentParams {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentId: string;
/** Raw user text to record in the timeline. */
userMessageText: string;
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
prompt: AgentPromptInput;
messageId?: string;
runOptions?: AgentRunOptions;
/** Optional mode to set on the agent before the run starts. */
sessionMode?: string;
logger: Logger;
}
/**
* Full send-prompt orchestration: unarchive → load → (optional mode change) →
* record user message → start run.
*
* Every surface that sends a prompt to an agent (Session/WS, MCP, CLI-through-MCP)
* MUST go through this so behavior can never drift between them.
*/
export async function sendPromptToAgent(params: SendPromptToAgentParams): Promise<void> {
const {
agentManager,
agentStorage,
agentId,
userMessageText,
prompt,
messageId,
runOptions,
sessionMode,
logger,
} = params;
await unarchiveAgentState(agentStorage, agentManager, agentId);
await ensureAgentLoaded(agentId, {
agentManager,
agentStorage,
logger,
});
if (sessionMode) {
await agentManager.setAgentMode(agentId, sessionMode);
}
try {
agentManager.recordUserMessage(agentId, userMessageText, {
messageId,
emitState: false,
});
} catch (error) {
logger.error({ err: error, agentId }, "Failed to record user message");
}
startAgentRun(agentManager, agentId, prompt, logger, {
replaceRunning: true,
runOptions,
});
}
interface SetupFinishNotificationParams {
agentManager: AgentManager;
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
logger: Logger;
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const { agentManager, childAgentId, callerAgentId, logger } = params;
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
let hasSeenRunning = false;
let fired = false;
let unsubscribe: (() => void) | null = null;
function notify(reason: "finished" | "errored" | "needs permission"): void {
async function notify(reason: "finished" | "errored" | "needs permission"): Promise<void> {
if (fired) {
return;
}
@@ -209,6 +303,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
}
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
const prompt = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;

View File

@@ -34,6 +34,7 @@ export const ProviderRuntimeSettingsSchema = z
.object({
command: ProviderCommandSchema.optional(),
env: z.record(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
})
.strict();
@@ -64,6 +65,7 @@ export const ProviderOverrideSchema = z
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
})

View File

@@ -473,6 +473,52 @@ describe("buildProviderRegistry", () => {
expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude");
});
test("disallowedTools flows through to runtime settings", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
disallowedTools: ["WebSearch", "WebFetch"],
},
},
});
expect(mockState.constructorArgs.claude[0]).toEqual({
runtimeSettings: {
command: undefined,
env: undefined,
disallowedTools: ["WebSearch", "WebFetch"],
},
});
});
test("derived provider inherits and merges disallowedTools from base", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
disallowedTools: ["WebSearch"],
},
zai: {
extends: "claude",
label: "ZAI",
disallowedTools: ["ComputerUse"],
},
},
});
const zaiArgs = mockState.constructorArgs.claude.find(
(entry) =>
Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) &&
(entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes(
"ComputerUse",
),
);
expect(zaiArgs).toBeDefined();
expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([
"WebSearch",
"ComputerUse",
]);
});
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
buildProviderRegistry(logger, {
providerOverrides: {

View File

@@ -90,7 +90,7 @@ function getProviderClientFactory(provider: string): ProviderClientFactory {
}
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
if (!override?.command && !override?.env) {
if (!override?.command && !override?.env && !override?.disallowedTools) {
return undefined;
}
@@ -102,6 +102,7 @@ function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings
}
: undefined,
env: override.env,
disallowedTools: override.disallowedTools,
};
}
@@ -122,6 +123,10 @@ function mergeRuntimeSettings(
...(override?.env ?? {}),
}
: undefined,
disallowedTools:
base?.disallowedTools || override?.disallowedTools
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
: undefined,
};
}

View File

@@ -45,6 +45,7 @@ const TEST_CAPABILITIES = {
} as const;
describe("ProviderSnapshotManager", () => {
const ttlMs = 5 * 60 * 1_000;
const projectCwd = resolve("/tmp/project");
const projectACwd = resolve("/tmp/project-a");
const projectBCwd = resolve("/tmp/project-b");
@@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => {
);
});
manager.refresh(projectCwd);
manager.refresh({ cwd: projectCwd });
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
@@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("refresh with providers only re-fetches matching providers", async () => {
const codexFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const claudeFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("claude", "sonnet-4")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: codexFetchModels,
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: claudeFetchModels,
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
"sonnet-4",
);
});
manager.refresh({ cwd: projectCwd, providers: ["codex"] });
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({
provider: "claude",
status: "ready",
models: [createModel("claude", "sonnet-4")],
});
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
expect(codexFetchModels).toHaveBeenCalledTimes(2);
expect(claudeFetchModels).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("refresh treats an empty providers list as a full refresh", async () => {
const codexFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const claudeFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("claude", "sonnet-4")])
.mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: codexFetchModels,
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: claudeFetchModels,
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
});
manager.refresh({ cwd: projectCwd, providers: [] });
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
status: "loading",
label: "codex",
description: "codex test provider",
defaultModeId: null,
},
{
provider: "claude",
status: "loading",
label: "claude",
description: "claude test provider",
defaultModeId: null,
},
]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
"sonnet-4.5",
);
});
expect(codexFetchModels).toHaveBeenCalledTimes(2);
expect(claudeFetchModels).toHaveBeenCalledTimes(2);
manager.destroy();
});
test("refresh ignores provider filters that are not in the registry", async () => {
const codexFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: codexFetchModels,
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
manager.refresh({ cwd: projectCwd, providers: ["zai"] });
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.1")],
});
expect(codexFetchModels).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("refresh during an in-flight refresh is a no-op", async () => {
const fetchModels = deferred<AgentModelDefinition[]>();
const fetchModes = deferred<AgentMode[]>();
@@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => {
const changes: ProviderSnapshotEntry[][] = [];
manager.on("change", (entries) => changes.push(entries));
manager.refresh(projectCwd);
manager.refresh({ cwd: projectCwd });
expect(manager.getSnapshot(projectCwd)).toEqual([
{
@@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => {
},
]);
manager.refresh(projectCwd);
manager.refresh(projectCwd);
manager.refresh(projectCwd);
manager.refresh({ cwd: projectCwd });
manager.refresh({ cwd: projectCwd });
manager.refresh({ cwd: projectCwd });
expect(changes).toHaveLength(1);
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
@@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
let now = 1_000;
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => fetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
now += ttlMs + 1;
const staleSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.1")],
modes: [createMode("auto")],
});
await vi.waitFor(() => {
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
manager.destroy();
});
test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => {
let now = 2_000;
const staleRefreshModels = deferred<AgentModelDefinition[]>();
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockImplementationOnce(async () => staleRefreshModels.promise);
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => fetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
now += ttlMs + 1;
const firstStaleSnapshot = manager.getSnapshot(projectCwd);
const secondStaleSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
await vi.waitFor(() => {
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
manager.destroy();
});
test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => {
let now = 3_000;
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => [createModel("codex", "gpt-5.1")],
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
now += ttlMs - 1;
const freshSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => {
let now = 4_000;
const unavailableFetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
const unavailableIsAvailable = vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const errorFetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockRejectedValueOnce(new Error("model lookup failed"))
.mockResolvedValueOnce([createModel("claude", "sonnet")]);
const { registry } = createRegistry([
createMockProvider({
provider: "codex",
isAvailable: unavailableIsAvailable,
fetchModels: async (cwd) => unavailableFetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
createMockProvider({
provider: "claude",
fetchModels: async (cwd) => errorFetchModels(cwd),
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe(
"unavailable",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error");
});
now += ttlMs + 1;
const staleSnapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable");
expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error");
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
});
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
"sonnet",
);
manager.destroy();
});
test("getSnapshot respects an injected TTL", async () => {
let now = 5_000;
const customTtlMs = 100;
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async (cwd) => fetchModels(cwd),
fetchModes: async () => [createMode("auto")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
ttlMs: customTtlMs,
now: () => now,
});
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
now += customTtlMs - 1;
manager.getSnapshot(projectCwd);
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
now += 2;
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
});
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
manager.destroy();
});
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
const codexModels = deferred<AgentModelDefinition[]>();
const { registry, handles } = createRegistry([

View File

@@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"
import type { ProviderDefinition } from "./provider-registry.js";
const DEFAULT_CWD_KEY = "__default__";
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
type ProviderSnapshotManagerOptions = {
ttlMs?: number;
now?: () => number;
};
type ProviderSnapshotRefreshOptions = {
cwd?: string;
providers?: AgentProvider[];
};
export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly lastCheckedAts = new Map<string, number>();
private readonly warmUps = new Map<string, Promise<void>>();
private readonly events = new EventEmitter();
private destroyed = false;
private readonly ttlMs: number;
private readonly now: () => number;
constructor(
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
private readonly logger: Logger,
) {}
options: ProviderSnapshotManagerOptions = {},
) {
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
this.now = options.now ?? Date.now;
}
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
const cwdKey = normalizeCwdKey(cwd);
@@ -29,17 +45,24 @@ export class ProviderSnapshotManager {
void this.warmUp(cwd);
return entriesToArray(loadingEntries);
}
if (this.shouldRevalidate(cwdKey)) {
void this.warmUp(cwd);
}
return entriesToArray(entries);
}
refresh(cwd?: string): void {
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
const { cwd } = options;
const cwdKey = normalizeCwdKey(cwd);
if (this.warmUps.has(cwdKey)) {
const inFlight = this.warmUps.get(cwdKey);
if (inFlight) {
await inFlight;
return;
}
this.resetSnapshotToLoading(cwdKey);
const providers = this.resolveRefreshProviders(options.providers);
this.resetSnapshotToLoading(cwdKey, providers);
this.emitChange(cwdKey);
void this.warmUp(cwd);
await this.warmUp(cwd, providers);
}
on(event: "change", listener: ProviderSnapshotChangeListener): this {
@@ -56,6 +79,7 @@ export class ProviderSnapshotManager {
this.destroyed = true;
this.events.removeAllListeners();
this.snapshots.clear();
this.lastCheckedAts.clear();
this.warmUps.clear();
}
@@ -74,16 +98,21 @@ export class ProviderSnapshotManager {
return entries;
}
private async warmUp(cwd?: string): Promise<void> {
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
const cwdKey = normalizeCwdKey(cwd);
const inFlight = this.warmUps.get(cwdKey);
if (inFlight) {
return inFlight;
}
const providersToRefresh = providers ?? this.getProviderIds();
const warmUpPromise = Promise.allSettled(
this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
).then(() => undefined);
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
).then(() => {
if (!providers) {
this.lastCheckedAts.set(cwdKey, this.now());
}
});
this.warmUps.set(cwdKey, warmUpPromise);
@@ -107,13 +136,6 @@ export class ProviderSnapshotManager {
}
const snapshot = this.getOrCreateSnapshot(cwdKey);
snapshot.set(provider, {
provider,
status: "loading",
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
});
try {
const client = definition.createClient(this.logger);
@@ -174,6 +196,17 @@ export class ProviderSnapshotManager {
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
}
private shouldRevalidate(cwdKey: string): boolean {
if (this.warmUps.has(cwdKey)) {
return false;
}
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
if (lastCheckedAt === undefined) {
return false;
}
return this.now() - lastCheckedAt > this.ttlMs;
}
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
const existing = this.snapshots.get(cwdKey);
if (existing) {
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
return created;
}
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
private resetSnapshotToLoading(
cwdKey: string,
providers?: AgentProvider[],
): Map<AgentProvider, ProviderSnapshotEntry> {
const snapshot = this.getOrCreateSnapshot(cwdKey);
snapshot.clear();
for (const [provider, entry] of this.createLoadingEntries()) {
snapshot.set(provider, entry);
const loadingEntries = this.createLoadingEntries();
if (!providers) {
snapshot.clear();
for (const [provider, entry] of loadingEntries) {
snapshot.set(provider, entry);
}
return snapshot;
}
for (const provider of providers) {
const loadingEntry = loadingEntries.get(provider);
if (!loadingEntry) continue;
const existing = snapshot.get(provider);
snapshot.set(provider, {
...loadingEntry,
models: existing?.models,
modes: existing?.modes,
fetchedAt: existing?.fetchedAt,
});
}
return snapshot;
}
@@ -197,6 +250,15 @@ export class ProviderSnapshotManager {
private getProviderIds(): AgentProvider[] {
return Object.keys(this.providerRegistry) as AgentProvider[];
}
private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined {
if (!providers || providers.length === 0) {
return undefined;
}
const providerIds = new Set(this.getProviderIds());
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
}
}
function normalizeCwdKey(cwd?: string): string {

View File

@@ -0,0 +1,103 @@
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import {
query,
type Options,
type Query,
type SpawnOptions as ClaudeSpawnOptions,
} from "@anthropic-ai/claude-agent-sdk";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import * as spawnUtils from "../../../utils/spawn.js";
import { ClaudeAgentClient } from "./claude-agent.js";
function createQueryMock(events: unknown[]): Query {
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;
},
} as Query;
}
function createChildProcessStub(): ChildProcess {
return {
stderr: new EventEmitter(),
} as ChildProcess;
}
describe("Claude spawn override", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("bypasses the shell when spawning Claude Code", async () => {
let capturedOptions: Options | undefined;
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
capturedOptions = options;
return createQueryMock([
{
type: "system",
subtype: "init",
session_id: "claude-spawn-shell-regression-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 spawnSpy = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub());
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
await session.run("spawn shell regression");
capturedOptions?.spawnClaudeCodeProcess?.({
command: "node",
args: ["claude.js", "--mcp-config", '{"mcpServers":{"paseo":{"type":"http"}}}'],
cwd: process.cwd(),
env: {},
signal: new AbortController().signal,
} satisfies ClaudeSpawnOptions);
} finally {
await session.close();
}
expect(spawnSpy).toHaveBeenCalledTimes(1);
const spawnOptions = spawnSpy.mock.calls[0]?.[2];
expect(spawnOptions?.shell).toBe(false);
});
});

View File

@@ -340,6 +340,8 @@ describe("ClaudeAgentClient.listModels", () => {
const models = await client.listModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6",

View File

@@ -171,7 +171,7 @@ type ClaudeAgentSessionOptions = {
queryFactory?: typeof query;
};
type ClaudeThinkingEffort = "low" | "medium" | "high" | "max";
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
function resolveClaudeSpawnCommand(
spawnOptions: SpawnOptions,
@@ -238,7 +238,13 @@ function applyRuntimeSettingsToClaudeOptions(
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return value === "low" || value === "medium" || value === "high" || value === "max";
return (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh" ||
value === "max"
);
}
function sanitizeClaudeProjectPath(cwd: string): string {
@@ -1134,6 +1140,8 @@ export class ClaudeAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
// via process.execPath. No external `claude` binary is required.
return true;
}
@@ -1142,6 +1150,7 @@ export class ClaudeAgentClient implements AgentClient {
const resolvedBinary = (await findExecutable("claude")) ?? "not found";
const available = await this.isAvailable();
const version = await resolveClaudeVersion(this.runtimeSettings);
const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null;
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
@@ -1162,6 +1171,7 @@ export class ClaudeAgentClient implements AgentClient {
diagnostic: formatProviderDiagnostic("Claude Code", [
{ label: "Binary", value: resolvedBinary },
...(version ? [{ label: "Version", value: version }] : []),
...(auth ? [{ label: "Auth", value: auth }] : []),
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
@@ -1208,6 +1218,48 @@ async function resolveClaudeVersion(
}
}
async function resolveClaudeAuth(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> {
const command = runtimeSettings?.command;
const run = async (
executable: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> => {
try {
return await execCommand(executable, args, { timeout: 5_000 });
} catch (error) {
const err = error as { stdout?: string; stderr?: string; message?: string };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? err.message ?? "",
};
}
};
try {
let result: { stdout: string; stderr: string };
if (command?.mode === "replace") {
result = await run(command.argv[0]!, [...command.argv.slice(1), "auth", "status"]);
} else {
const executable = await findExecutable("claude");
if (!executable) {
return null;
}
result = await run(executable, ["auth", "status"]);
}
const combined = [result.stdout, result.stderr]
.map((s) => s.trim())
.filter((s) => s.length > 0)
.join("\n");
return combined || null;
} catch {
return null;
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined {
if (!modelUsage || typeof modelUsage !== "object") {
return undefined;
@@ -2068,7 +2120,9 @@ class ClaudeAgentSession implements AgentSession {
let effort: ClaudeOptions["effort"];
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
thinking = { type: "adaptive" };
effort = thinkingOptionId;
// SDK 0.2.71 types `effort` as 'low' | 'medium' | 'high' | 'max'; Opus 4.7
// adds 'xhigh' which the binary accepts but the typings don't yet expose.
effort = thinkingOptionId as ClaudeOptions["effort"];
}
const appendedSystemPrompt = [
@@ -2145,6 +2199,12 @@ class ClaudeAgentSession implements AgentSession {
if (this.claudeSessionId) {
base.resume = this.claudeSessionId;
}
if (this.runtimeSettings?.disallowedTools?.length) {
base.disallowedTools = [
...(base.disallowedTools ?? []),
...this.runtimeSettings.disallowedTools,
];
}
return this.applyRuntimeSettings(base);
}

View File

@@ -6,6 +6,8 @@ describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6",

View File

@@ -7,7 +7,29 @@ const CLAUDE_THINKING_OPTIONS = [
{ id: "max", label: "Max" },
] as const;
const CLAUDE_OPUS_4_7_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
] as const;
const CLAUDE_MODELS: AgentModelDefinition[] = [
{
provider: "claude",
id: "claude-opus-4-7[1m]",
label: "Opus 4.7 1M",
description: "Opus 4.7 with 1M context window",
thinkingOptions: [...CLAUDE_OPUS_4_7_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7",
label: "Opus 4.7",
description: "Opus 4.7 · Latest release",
thinkingOptions: [...CLAUDE_OPUS_4_7_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-6[1m]",

View File

@@ -4205,7 +4205,7 @@ export class CodexAppServerAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
return true;
return await isCommandAvailable("codex");
}
async getDiagnostic(): Promise<{ diagnostic: string }> {

View File

@@ -0,0 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const mockState = vi.hoisted(() => ({
superConstructorOptions: [] as unknown[],
}));
vi.mock("./acp-agent.js", () => ({
ACPAgentClient: class ACPAgentClient {
readonly provider: string;
constructor(options: unknown) {
this.provider = "acp";
mockState.superConstructorOptions.push(options);
}
},
}));
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient", () => {
test("passes the custom command only as defaultCommand", () => {
new GenericACPAgentClient({
logger: createTestLogger(),
command: ["hermes", "acp"],
env: {
HERMES_LOG: "info",
},
});
expect(mockState.superConstructorOptions).toEqual([
{
provider: "acp",
logger: expect.any(Object),
runtimeSettings: {
env: {
HERMES_LOG: "info",
},
},
defaultCommand: ["hermes", "acp"],
},
]);
});
});

View File

@@ -21,10 +21,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
provider: "acp",
logger: options.logger,
runtimeSettings: {
command: {
mode: "replace",
argv: options.command,
},
env: options.env,
},
defaultCommand: options.command as [string, ...string[]],

View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("OpenCodeAgentSession slash command timeout handling", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("waits for SSE completion when slash commands hit a header timeout", async () => {
const idleEventGate = createDeferred<void>();
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
event: {
subscribe: vi.fn().mockResolvedValue({
stream: (async function* () {
await idleEventGate.promise;
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})(),
}),
},
command: {
list: vi.fn().mockResolvedValue({
data: [{ name: "help", description: "Show help", hints: [] }],
}),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");
await Promise.resolve();
idleEventGate.resolve();
await expect(runPromise).resolves.toMatchObject({
sessionId: "session-1",
finalText: "",
timeline: [],
usage: undefined,
});
});
});

View File

@@ -315,6 +315,65 @@ const hasOpenCode = isBinaryInstalled("opencode");
});
describe("OpenCode adapter context-window normalization", () => {
test("close reconciliation aborts then archives upstream session", async () => {
const abort = vi.fn().mockResolvedValue({ data: true, error: undefined });
const update = vi.fn().mockResolvedValue({
data: { id: "session-1", time: { archived: Date.now() } },
error: undefined,
});
await __openCodeInternals.reconcileOpenCodeSessionClose({
client: {
session: {
abort,
update,
},
} as never,
sessionId: "session-1",
directory: "/tmp/project",
logger: createTestLogger(),
});
expect(abort).toHaveBeenCalledWith({
sessionID: "session-1",
directory: "/tmp/project",
});
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith({
sessionID: "session-1",
directory: "/tmp/project",
time: {
archived: expect.any(Number),
},
});
});
test("close reconciliation still archives when abort returns an error", async () => {
const abort = vi.fn().mockResolvedValue({
data: undefined,
error: { data: {}, errors: [], success: false },
});
const update = vi.fn().mockResolvedValue({
data: { id: "session-1", time: { archived: Date.now() } },
error: undefined,
});
await __openCodeInternals.reconcileOpenCodeSessionClose({
client: {
session: {
abort,
update,
},
} as never,
sessionId: "session-1",
directory: "/tmp/project",
logger: createTestLogger(),
});
expect(abort).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(1);
});
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([

View File

@@ -36,6 +36,7 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
@@ -107,6 +108,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
"does not exist",
"unsupported model",
] as const;
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
"headers timeout",
"headers timeout error",
"headers_timeout",
"und_err_headers_timeout",
] as const;
const OpencodeToolStateSchema = z
.object({
@@ -231,6 +238,73 @@ function normalizeTurnFailureError(error: unknown): string {
return normalized.length > 0 ? normalized : "Unknown error";
}
function isOpenCodeNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
(error as { name?: unknown }).name === "NotFoundError"
);
}
async function reconcileOpenCodeSessionClose(params: {
client: Pick<OpencodeClient, "session">;
sessionId: string;
directory: string;
logger: Logger;
}): Promise<void> {
const { client, sessionId, directory, logger } = params;
try {
const response = await client.session.abort({
sessionID: sessionId,
directory,
});
if (response.error && !isOpenCodeNotFoundError(response.error)) {
logger.warn(
{
sessionId,
error: normalizeTurnFailureError(response.error),
},
"Failed to abort OpenCode session during close",
);
}
} catch (error) {
logger.warn(
{
sessionId,
error: normalizeTurnFailureError(error),
},
"Failed to abort OpenCode session during close",
);
}
try {
const response = await client.session.update({
sessionID: sessionId,
directory,
time: { archived: Date.now() },
});
if (response.error && !isOpenCodeNotFoundError(response.error)) {
logger.warn(
{
sessionId,
error: normalizeTurnFailureError(response.error),
},
"Failed to archive OpenCode session during close",
);
}
} catch (error) {
logger.warn(
{
sessionId,
error: normalizeTurnFailureError(error),
},
"Failed to archive OpenCode session during close",
);
}
}
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
if (!normalized) {
@@ -239,6 +313,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
}
function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
const diagnostics = new Set<string>();
const queue: unknown[] = [error];
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
const normalized = stringifyUnknownError(current).trim().toLowerCase();
if (normalized) {
diagnostics.add(normalized);
}
if (typeof current === "object") {
const record = current as {
message?: unknown;
code?: unknown;
name?: unknown;
cause?: unknown;
};
for (const value of [record.message, record.code, record.name]) {
if (typeof value === "string") {
const diagnostic = value.trim().toLowerCase();
if (diagnostic) {
diagnostics.add(diagnostic);
}
}
}
if (record.cause) {
queue.push(record.cause);
}
}
}
return [...diagnostics].some((diagnostic) =>
OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)),
);
}
function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -570,6 +687,7 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
reconcileOpenCodeSessionClose,
resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow,
};
@@ -918,7 +1036,7 @@ export class OpenCodeAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
return true;
return await isCommandAvailable("opencode");
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -1042,6 +1160,150 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function mapOpenCodeTodosToTimelineItems(
todos: Array<{ content?: string | null; status?: string | null }>,
): Extract<AgentTimelineItem, { type: "todo" }> {
return {
type: "todo",
items: todos.flatMap((todo) => {
const text = readNonEmptyString(todo.content);
if (!text) {
return [];
}
return [
{
text,
completed: todo.status === "completed",
},
];
}),
};
}
function createCompactionTimelineItem(
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
): Extract<AgentTimelineItem, { type: "compaction" }> {
return {
type: "compaction",
status,
...(trigger ? { trigger } : {}),
};
}
const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const;
const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const;
const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const;
const PERMISSION_TITLE_BY_NAME: Record<string, string> = {
external_directory: "Access external directory",
bash: "Run shell command",
read: "Read files",
read_file: "Read files",
write: "Write files",
write_file: "Write files",
create_file: "Write files",
edit: "Edit files",
apply_patch: "Edit files",
apply_diff: "Edit files",
};
function toHumanReadablePermissionTitle(permission: string): string {
const mapped = PERMISSION_TITLE_BY_NAME[permission];
if (mapped) {
return mapped;
}
const normalized = permission
.split(/[\s_-]+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join(" ");
return normalized.length > 0 ? normalized : "Permission request";
}
function readFirstStringFromRecord(
record: Record<string, unknown> | null,
keys: readonly string[],
): string | null {
if (!record) {
return null;
}
for (const key of keys) {
const value = readNonEmptyString(record[key]);
if (value) {
return value;
}
}
return null;
}
function readPermissionField(
metadata: Record<string, unknown> | null,
keys: readonly string[],
): string | null {
const direct = readFirstStringFromRecord(metadata, keys);
if (direct) {
return direct;
}
const nestedInput = readOpenCodeRecord(metadata?.input);
return readFirstStringFromRecord(nestedInput, keys);
}
function buildOpenCodePermissionInput(params: {
patterns: string[];
metadata: Record<string, unknown> | null;
tool: Record<string, unknown> | null;
command: string | null;
}): Record<string, unknown> {
return {
...(params.patterns.length > 0 ? { patterns: params.patterns } : {}),
...(params.metadata ? { metadata: params.metadata } : {}),
...(params.tool ? { tool: params.tool } : {}),
...(params.command ? { command: params.command } : {}),
};
}
function buildOpenCodePermissionDetail(params: {
permission: string;
input: Record<string, unknown>;
command: string | null;
cwd: string | null;
}): ToolCallDetail {
if (params.command) {
return {
type: "shell",
command: params.command,
...(params.cwd ? { cwd: params.cwd } : {}),
};
}
return {
type: "unknown",
input: {
permission: params.permission,
...params.input,
},
output: null,
};
}
function buildOpenCodePermissionDescription(params: {
reason: string | null;
patterns: string[];
}): string | undefined {
const parts: string[] = [];
if (params.reason) {
parts.push(params.reason);
}
if (params.patterns.length > 0) {
parts.push(`Scope: ${params.patterns.join(", ")}`);
}
return parts.length > 0 ? parts.join(" - ") : undefined;
}
export function translateOpenCodeEvent(
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
@@ -1141,6 +1403,12 @@ export function translateOpenCodeEvent(
item: parsedToolPart.data,
});
}
} else if (part.type === "compaction") {
events.push({
type: "timeline",
provider: "opencode",
item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"),
});
} else if (part.type === "step-finish") {
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
@@ -1198,6 +1466,31 @@ export function translateOpenCodeEvent(
break;
}
const metadata = readOpenCodeRecord(event.properties.metadata);
const tool = readOpenCodeRecord(event.properties.tool);
const patterns = Array.isArray(event.properties.patterns)
? event.properties.patterns.filter((value): value is string => typeof value === "string")
: [];
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
const input = buildOpenCodePermissionInput({
patterns,
metadata,
tool,
command,
});
const detail = buildOpenCodePermissionDetail({
permission: event.properties.permission,
input,
command,
cwd,
});
const description = buildOpenCodePermissionDescription({
reason,
patterns,
});
events.push({
type: "permission_requested",
provider: "opencode",
@@ -1206,9 +1499,10 @@ export function translateOpenCodeEvent(
provider: "opencode",
name: event.properties.permission,
kind: "tool",
title: event.properties.permission,
description: event.properties.patterns?.join(", "),
input: event.properties.metadata,
title: toHumanReadablePermissionTitle(event.properties.permission),
...(description ? { description } : {}),
input,
detail,
},
});
break;
@@ -1261,6 +1555,32 @@ export function translateOpenCodeEvent(
break;
}
case "todo.updated": {
if (event.properties.sessionID !== state.sessionId) {
break;
}
events.push({
type: "timeline",
provider: "opencode",
item: mapOpenCodeTodosToTimelineItems(event.properties.todos),
});
break;
}
case "session.compacted": {
if (event.properties.sessionID !== state.sessionId) {
break;
}
events.push({
type: "timeline",
provider: "opencode",
item: createCompactionTimelineItem("completed"),
});
break;
}
case "session.idle": {
if (event.properties.sessionID === state.sessionId) {
state.streamedPartKeys.clear();
@@ -1530,6 +1850,17 @@ class OpenCodeAgentSession implements AgentSession {
})
.then((response) => {
if (response.error) {
if (isOpenCodeHeadersTimeoutFailure(response.error)) {
this.logger.warn(
{
err: response.error,
commandName: slashCommand.commandName,
turnId,
},
"OpenCode slash command hit a header timeout; waiting for SSE terminal event",
);
return;
}
const errorMsg = normalizeTurnFailureError(response.error);
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
@@ -1543,6 +1874,17 @@ class OpenCodeAgentSession implements AgentSession {
}
})
.catch((err) => {
if (isOpenCodeHeadersTimeoutFailure(err)) {
this.logger.warn(
{
err,
commandName: slashCommand.commandName,
turnId,
},
"OpenCode slash command hit a header timeout; waiting for SSE terminal event",
);
return;
}
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
@@ -1932,6 +2274,12 @@ class OpenCodeAgentSession implements AgentSession {
async close(): Promise<void> {
this.abortController?.abort();
await reconcileOpenCodeSessionClose({
client: this.client,
sessionId: this.sessionId,
directory: this.config.cwd,
logger: this.logger,
});
this.subscribers.clear();
this.activeForegroundTurnId = null;
}

View File

@@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("humanizes permission requests and includes shell detail when command metadata exists", () => {
const state = createState();
const result = translateOpenCodeEvent(
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "session-1",
permission: "external_directory",
patterns: ["/home/user/secrets/*"],
metadata: {
command: "ls /home/user/secrets",
reason: "Need to inspect generated files",
},
tool: {
messageID: "message-1",
callID: "call-1",
},
},
},
state,
);
expect(result).toEqual([
{
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-1",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
description: "Need to inspect generated files - Scope: /home/user/secrets/*",
input: {
patterns: ["/home/user/secrets/*"],
metadata: {
command: "ls /home/user/secrets",
reason: "Need to inspect generated files",
},
tool: {
messageID: "message-1",
callID: "call-1",
},
command: "ls /home/user/secrets",
},
detail: {
type: "shell",
command: "ls /home/user/secrets",
},
},
},
]);
});
it("falls back to unknown permission detail when command metadata is absent", () => {
const state = createState();
const result = translateOpenCodeEvent(
{
type: "permission.asked",
properties: {
id: "perm-2",
sessionID: "session-1",
permission: "external_directory",
patterns: ["/tmp/outside/*"],
metadata: {
reason: "Need to access temporary checkout",
},
},
},
state,
);
expect(result).toEqual([
{
type: "permission_requested",
provider: "opencode",
request: {
id: "perm-2",
provider: "opencode",
name: "external_directory",
kind: "tool",
title: "Access external directory",
description: "Need to access temporary checkout - Scope: /tmp/outside/*",
input: {
patterns: ["/tmp/outside/*"],
metadata: {
reason: "Need to access temporary checkout",
},
},
detail: {
type: "unknown",
input: {
permission: "external_directory",
patterns: ["/tmp/outside/*"],
metadata: {
reason: "Need to access temporary checkout",
},
},
output: null,
},
},
},
]);
});
it("emits usage_updated after step-finish parts", () => {
const state = createState();
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
@@ -310,6 +418,96 @@ describe("translateOpenCodeEvent", () => {
});
});
it("emits normalized todo timeline items from todo.updated", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "todo.updated",
properties: {
sessionID: "session-1",
todos: [
{ content: "Outline", status: "pending", priority: "high" },
{ content: "Ship", status: "completed", priority: "medium" },
{ content: " ", status: "completed", priority: "low" },
],
},
},
state,
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "todo",
items: [
{ text: "Outline", completed: false },
{ text: "Ship", completed: true },
],
},
},
]);
});
it("emits compaction loading timeline items from compaction parts", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "compaction-part-1",
sessionID: "session-1",
messageID: "message-compaction-1",
type: "compaction",
auto: true,
},
},
},
state,
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
},
]);
});
it("emits compaction completed timeline items from session.compacted", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "session.compacted",
properties: {
sessionID: "session-1",
},
},
state,
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
it("emits reasoning from message.part.delta events", () => {
const state = createState();
@@ -510,6 +708,99 @@ describe("translateOpenCodeEvent", () => {
expect(result).toEqual([]);
});
it("emits turn_completed from session.status idle", () => {
const state = createState();
state.streamedPartKeys.add("text:part-1");
state.partTypes.set("part-1", "text");
const result = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: { type: "idle" },
},
},
state,
);
expect(result).toEqual([
{
type: "turn_completed",
provider: "opencode",
usage: undefined,
},
]);
expect(state.streamedPartKeys.size).toBe(0);
expect(state.partTypes.size).toBe(0);
});
it("emits turn_failed from fatal session.status retry", () => {
const state = createState();
state.streamedPartKeys.add("text:part-1");
state.partTypes.set("part-1", "text");
const result = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: {
type: "retry",
attempt: 2,
message: "Invalid API key",
next: Date.now() + 1000,
},
},
},
state,
);
expect(result).toEqual([
{
type: "turn_failed",
provider: "opencode",
error: "Invalid API key",
},
]);
expect(state.streamedPartKeys.size).toBe(0);
expect(state.partTypes.size).toBe(0);
});
it("ignores transient session.status updates", () => {
const state = createState();
const busy = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: { type: "busy" },
},
},
state,
);
const retry = translateOpenCodeEvent(
{
type: "session.status",
properties: {
sessionID: "session-1",
status: {
type: "retry",
attempt: 1,
message: "rate limited",
next: Date.now() + 1000,
},
},
},
state,
);
expect(busy).toEqual([]);
expect(retry).toEqual([]);
});
it("emits structured assistant output when schema mode completes without text parts", () => {
const state = createState();

View File

@@ -0,0 +1,147 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentProvider } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function isolatePathTo(dir: string): void {
process.env.PATH = dir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
}
function writeProviderShim(dir: string, command: string): string {
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
const content =
process.platform === "win32"
? `@echo off\r\necho ${command} 1.0\r\n`
: `#!/bin/sh\necho ${command} 1.0\n`;
writeFileSync(filePath, content);
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("default provider availability", () => {
test("Codex reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
const binDir = makeTempDir("provider-availability-claude-");
isolatePathTo(binDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Codex reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
writeProviderShim(binDir, "codex");
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
writeProviderShim(binDir, "opencode");
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("AgentManager reports Codex unavailable without throwing", async () => {
const binDir = makeTempDir("provider-availability-manager-bin-");
isolatePathTo(binDir);
const workdir = makeTempDir("provider-availability-manager-work-");
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
const manager = new AgentManager({
clients: {
codex: new CodexAppServerAgentClient(createTestLogger()),
},
registry: storage,
logger: createTestLogger(),
});
await expect(manager.listProviderAvailability()).resolves.toEqual([
{
provider: "codex",
available: false,
error: null,
},
]);
});
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
const binDir = makeTempDir("provider-availability-resume-bin-");
isolatePathTo(binDir);
const workdir = makeTempDir("provider-availability-resume-work-");
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
const manager = new AgentManager({
clients: {
codex: new CodexAppServerAgentClient(createTestLogger()),
},
registry: storage,
logger: createTestLogger(),
});
await expect(
manager.resumeAgentFromPersistence(
{
provider: "codex" as AgentProvider,
sessionId: "missing-codex-session",
metadata: {
provider: "codex",
cwd: workdir,
},
},
{ cwd: workdir },
),
).rejects.toThrow("Provider 'codex' is not available");
});
});

View File

@@ -0,0 +1,217 @@
import type { ChildProcess } from "node:child_process";
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
type SpawnResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
error: Error | null;
};
type ProviderLaunchCase = {
provider: "claude" | "codex" | "opencode" | "generic-acp";
binaryName: string;
args: string[];
shell?: boolean;
};
const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}';
const tempDirs: string[] = [];
function makeFixture(binaryName: string, expectedArgs: string[]) {
const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `));
tempDirs.push(root);
const fakeDaemonNode = path.join(root, "Fake Paseo.exe");
copyFileSync(process.execPath, fakeDaemonNode);
const assertScript = path.join(root, "assert-argv.js");
writeFileSync(
assertScript,
`
if (process.argv.includes("--version")) {
console.log("fake-provider 1.0.0");
process.exit(0);
}
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
const actual = process.argv.slice(2);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.error("ARGV_MISMATCH");
console.error(JSON.stringify({ expected, actual }));
process.exit(42);
}
console.log("ARGV_OK");
`,
);
const shim = path.join(root, `${binaryName}.cmd`);
writeFileSync(
shim,
["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"),
);
return { root, shim, expectedArgs };
}
function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise<SpawnResult> {
return new Promise((resolve) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let error: Error | null = null;
let settled = false;
const settle = (result: Pick<SpawnResult, "code" | "signal">) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve({
...result,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
error,
});
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
settle({ code: null, signal: "SIGKILL" });
}, timeoutMs);
timer.unref?.();
child.stdout?.on("data", (chunk: Buffer | string) => {
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.once("error", (err) => {
error = err;
settle({ code: null, signal: null });
});
child.once("exit", (code, signal) => {
settle({ code, signal });
});
});
}
async function runProviderFixture(params: {
command: string;
args: string[];
expectedArgs: string[];
shell?: boolean;
}): Promise<SpawnResult> {
const child = spawnProcess(params.command, params.args, {
env: {
...process.env,
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(params.expectedArgs),
},
stdio: ["ignore", "pipe", "pipe"],
...(params.shell === undefined ? {} : { shell: params.shell }),
});
return collectChild(child);
}
function withPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
const pathKey =
process.platform === "win32"
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
: "PATH";
const previousPath = process.env[pathKey];
process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir;
return run().finally(() => {
if (previousPath === undefined) {
delete process.env[pathKey];
} else {
process.env[pathKey] = previousPath;
}
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
const providerLaunchCases: ProviderLaunchCase[] = [
{
provider: "claude",
binaryName: "claude",
args: ["--mcp-config", JSON_ARG],
shell: false,
},
{
provider: "codex",
binaryName: "codex",
args: ["app-server", "--config", JSON_ARG],
},
{
provider: "opencode",
binaryName: "opencode",
args: ["serve", "--port", "49271", "--config", JSON_ARG],
},
{
provider: "generic-acp",
binaryName: "generic-acp",
args: ["--mcp-config", JSON_ARG],
},
];
describe.runIf(process.platform === "win32")("Windows provider launch parity", () => {
test("detected claude.cmd can be launched with Claude's JSON-safe spawn shape", async () => {
const args = ["--mcp-config", JSON_ARG];
const fixture = makeFixture("claude", args);
await withPathEntry(fixture.root, async () => {
const detected = await findExecutable("claude");
expect(detected?.toLowerCase()).toBe(fixture.shim.toLowerCase());
const result = await runProviderFixture({
command: detected!,
args,
expectedArgs: args,
shell: false,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
});
test.each(
providerLaunchCases,
)("$provider launches a cmd shim from a path with spaces through spawnProcess", async ({
binaryName,
args,
shell,
}) => {
const fixture = makeFixture(binaryName, args);
const result = await runProviderFixture({
command: fixture.shim,
args: fixture.expectedArgs,
expectedArgs: fixture.expectedArgs,
shell,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
});

View File

@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js";
describe("allowed hosts (vite-style)", () => {
it("allows localhost by default", () => {
expect(isHostAllowed("localhost:6767", undefined)).toBe(true);
});
it("allows subdomains of .localhost by default", () => {
expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true);
});
it("allows IP addresses by default", () => {
expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true);
expect(isHostAllowed("[::1]:6767", undefined)).toBe(true);
});
it("rejects non-default hosts when no allowlist is provided", () => {
expect(isHostAllowed("evil.com:6767", undefined)).toBe(false);
});
it("allows any host when set to true", () => {
expect(isHostAllowed("evil.com:6767", true)).toBe(true);
});
it("supports leading-dot patterns", () => {
const allowed = [".example.com"];
expect(isHostAllowed("example.com:6767", allowed)).toBe(true);
expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true);
expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true);
expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false);
});
it("merges arrays (append + de-dupe) and short-circuits on true", () => {
expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]);
expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true);
});
it("parses env var values", () => {
expect(parseAllowedHostsEnv(undefined)).toBeUndefined();
expect(parseAllowedHostsEnv("")).toBeUndefined();
expect(parseAllowedHostsEnv("true")).toBe(true);
expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
});
});

View File

@@ -0,0 +1,116 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
import { ensureAgentLoaded } from "./agent/agent-loading.js";
import type { PaseoDaemonConfig } from "./bootstrap.js";
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
describe("bootstrap provider availability", () => {
const tempRoots: string[] = [];
afterEach(async () => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const root of tempRoots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
test("loads a persisted Codex record without spawning a missing Codex binary", async () => {
const { createPaseoDaemon } = await import("./bootstrap.js");
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
tempRoots.push(root);
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
tempRoots.push(binDir);
process.env.PATH = binDir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
const paseoHome = path.join(root, ".paseo");
const staticDir = path.join(root, "static");
const agentStoragePath = path.join(paseoHome, "agents");
const now = new Date("2026-04-16T00:00:00.000Z").toISOString();
const agentId = "11111111-1111-4111-8111-111111111111";
await mkdir(agentStoragePath, { recursive: true });
await mkdir(staticDir, { recursive: true });
await writeFile(
path.join(agentStoragePath, `${agentId}.json`),
JSON.stringify({
id: agentId,
provider: "codex",
cwd: root,
createdAt: now,
updatedAt: now,
lastStatus: "idle",
lastModeId: "auto",
config: {
modeId: "auto",
model: "gpt-5.4",
},
persistence: {
provider: "codex",
sessionId: "codex-session-1",
metadata: {
provider: "codex",
cwd: root,
},
},
}),
);
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: {},
agentStoragePath,
relayEnabled: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const processFailures: Error[] = [];
const onUnhandledRejection = (reason: unknown) => {
processFailures.push(reason instanceof Error ? reason : new Error(String(reason)));
};
const onUncaughtException = (error: Error) => {
processFailures.push(error);
};
process.on("unhandledRejection", onUnhandledRejection);
process.on("uncaughtException", onUncaughtException);
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.agentStorage.list()).resolves.toHaveLength(1);
await expect(daemon.agentManager.listProviderAvailability()).resolves.toContainEqual({
provider: "codex",
available: false,
error: null,
});
await expect(
ensureAgentLoaded(agentId, {
agentManager: daemon.agentManager,
agentStorage: daemon.agentStorage,
logger: pino({ level: "silent" }),
}),
).rejects.toThrow("Provider 'codex' is not available");
await new Promise((resolve) => setImmediate(resolve));
expect(processFailures).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
process.off("uncaughtException", onUncaughtException);
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
}
});
});

View File

@@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
@@ -163,7 +163,7 @@ describe("paseo daemon bootstrap", () => {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,

View File

@@ -119,7 +119,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
@@ -162,7 +162,7 @@ export type PaseoDaemonConfig = {
listen: string;
paseoHome: string;
corsAllowedOrigins: string[];
allowedHosts?: AllowedHostsConfig;
hostnames?: HostnamesConfig;
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
staticDir: string;
@@ -231,7 +231,7 @@ export async function createPaseoDaemon(
if (listenTarget.type === "tcp") {
app.use((req, res, next) => {
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
if (!isHostAllowed(hostHeader, config.allowedHosts)) {
if (!isHostnameAllowed(hostHeader, config.hostnames)) {
res.status(403).json({ error: "Invalid Host header" });
return;
}
@@ -604,7 +604,7 @@ export async function createPaseoDaemon(
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, allowedHosts: config.allowedHosts },
{ allowedOrigins, hostnames: config.hostnames },
speechService,
terminalManager,
{

View File

@@ -11,11 +11,7 @@ import type {
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import {
mergeAllowedHosts,
parseAllowedHostsEnv,
type AllowedHostsConfig,
} from "./allowed-hosts.js";
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
@@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{
relayEnabled: boolean;
mcpEnabled: boolean;
mcpInjectIntoAgents: boolean;
allowedHosts: AllowedHostsConfig;
hostnames: HostnamesConfig;
}>;
const OptionalVoiceLlmProviderSchema = z
@@ -133,10 +129,10 @@ export function loadConfig(
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
const allowedHosts = mergeAllowedHosts([
persisted.daemon?.allowedHosts,
parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS),
options?.cli?.allowedHosts,
const hostnames = mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
options?.cli?.hostnames,
]);
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
@@ -181,7 +177,7 @@ export function loadConfig(
corsAllowedOrigins: Array.from(
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
),
allowedHosts,
hostnames,
mcpEnabled,
mcpInjectIntoAgents,
mcpDebug: env.MCP_DEBUG === "1",

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { PersistedConfigSchema } from "./persisted-config.js";
import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js";
describe("hostnames (vite-style)", () => {
it("allows localhost by default", () => {
expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true);
});
it("allows subdomains of .localhost by default", () => {
expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true);
});
it("allows IP addresses by default", () => {
expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true);
expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true);
});
it("rejects non-default hosts when no allowlist is provided", () => {
expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false);
});
it("allows any host when set to true", () => {
expect(isHostnameAllowed("evil.com:6767", true)).toBe(true);
});
it("supports leading-dot patterns", () => {
const hostnames = [".example.com"];
expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true);
expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true);
expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true);
expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false);
});
it("merges arrays (append + de-dupe) and short-circuits on true", () => {
expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]);
expect(mergeHostnames([["a"], true, ["b"]])).toBe(true);
});
it("parses env var values", () => {
expect(parseHostnamesEnv(undefined)).toBeUndefined();
expect(parseHostnamesEnv("")).toBeUndefined();
expect(parseHostnamesEnv("true")).toBe(true);
expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
});
it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
allowedHosts: [".example.com"],
},
});
expect(parsed.daemon?.hostnames).toEqual([".example.com"]);
});
});

View File

@@ -1,6 +1,6 @@
import net from "node:net";
export type AllowedHostsConfig = true | string[] | undefined;
export type HostnamesConfig = true | string[] | undefined;
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase();
@@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null {
return normalizeHostname(trimmed.slice(0, colonIndex));
}
function matchesAllowedHostPattern(hostname: string, pattern: string): boolean {
function matchesHostnamePattern(hostname: string, pattern: string): boolean {
const normalizedPattern = normalizeHostname(pattern);
if (!normalizedPattern) return false;
@@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean {
}
/**
* Vite-style allowed hosts check, adapted to raw Host headers.
* Vite-style hostname allowlist check, adapted to raw Host headers.
*
* Semantics:
* - `allowedHosts === true` => allow any host.
* - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs.
* - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
* - `hostnames === true` => allow any host.
* - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs.
* - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
*/
export function isHostAllowed(
export function isHostnameAllowed(
hostHeader: string | undefined,
allowedHosts: AllowedHostsConfig,
hostnames: HostnamesConfig,
): boolean {
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
if (!hostname) return false;
if (allowedHosts === true) return true;
if (hostnames === true) return true;
// Defaults are always allowed.
if (isDefaultAllowedHostname(hostname)) return true;
const patterns = allowedHosts ?? [];
const patterns = hostnames ?? [];
for (const pattern of patterns) {
if (matchesAllowedHostPattern(hostname, pattern)) return true;
if (matchesHostnamePattern(hostname, pattern)) return true;
}
return false;
}
export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHostsConfig {
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHos
return deduped;
}
export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;

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