Compare commits

...

26 Commits

Author SHA1 Message Date
Mohamed Boudra
51d9563352 chore(release): cut 0.1.75 2026-05-12 17:35:22 +07:00
Mohamed Boudra
045b373168 docs(changelog): draft 0.1.75 entry 2026-05-12 17:34:18 +07:00
Mohamed Boudra
985ad52cce fix(copilot): preserve legacy autopilot mode alias 2026-05-12 17:34:09 +07:00
Mohamed Boudra
d198c68b9e Fail Codex resume requests explicitly (#947) 2026-05-12 17:50:31 +08:00
Mohamed Boudra
751a07124f Fix scheduled agent cleanup (#945) 2026-05-12 17:05:40 +08:00
Mohamed Boudra
defb4f82f7 ci(nix): smoke test daemon boot (#939) 2026-05-12 16:05:15 +08:00
Bolun Zhang
4570e65ce8 fix(app): respect iPad safe area in settings sidebar (#922)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 07:27:49 +00:00
ezra
db0d63dd90 Handle Windows shell wrappers in Codex command summaries (#931) 2026-05-12 15:07:37 +08:00
Mohamed Boudra
1a8fdcd388 Configure STT language from settings (#941)
* Configure STT language from settings

* Update websocket speech mock for language config
2026-05-12 15:03:53 +08:00
Biao Ma
417abed6a5 Fix desktop daemon stale PID startup (#913) 2026-05-12 14:33:18 +08:00
Bolun Zhang
6afdeef84a Fix iPad sidebar safe area background (#937)
Co-authored-by: zbl <zbl@zbl-M4Pro.local>
2026-05-12 14:24:20 +08:00
Mohamed Boudra
77c82dfdbd ci: split nix into two workflows
nix.yml: PR-only build check.
nix-update-hash.yml: push-to-main hash update via paseo-ai App token.
2026-05-12 12:57:38 +07:00
Mohamed Boudra
29277c900d ci: split nix workflow into build (anywhere) + update-hash (main only)
build runs on push to main and every PR including forks. Same model as
the test/lint/typecheck workflows: read-only token, no secrets injected,
runs scripts/update-nix.sh + nix build to validate. No commit, no push.

update-hash runs only on push to main. Mints the paseo-ai App token,
re-runs the update + build, and commits the refreshed hash via the
ruleset bypass. The default GITHUB_TOKEN no longer pushes anywhere.

Rename file to nix.yml since it's no longer just a build job.
2026-05-12 12:54:22 +07:00
Mohamed Boudra
ed1943058a ci: gate nix-build on same-repo PRs only
scripts/update-nix.sh and scripts/fix-lockfile.mjs are executed from
the PR's checked-out tree. A fork PR can modify those scripts to run
arbitrary code on the runner. Same-repo PRs and push-to-main still
run; fork PRs skip the job entirely.

Closes #365.
2026-05-12 12:37:01 +07:00
Mohamed Boudra
e0361ddd22 ci: drop unused contents: write override in nix-build
GITHUB_TOKEN no longer pushes anything in this workflow. PR runs are
read-only, and main pushes go through the paseo-ai App token. The
workflow-level 'contents: read' is sufficient.

Addresses #365.
2026-05-12 12:35:05 +07:00
Mohamed Boudra
ce9474055e ci: skip CI on nix-build auto hash commits
The hash-update commit only changes package-lock.json and
nix/npm-deps.hash. The parent commit already passed all 13 required
checks, and the file diff is content-only, so re-running the full
suite on every hash bump is wasted CI minutes.
2026-05-12 12:28:34 +07:00
Mohamed Boudra
8f9b4c8828 ci: skip App token on PRs in nix-build
Fork PRs cannot read repo secrets, so the App-token step failed with
'Input required and not supplied: app-id'. Gate the App-token step on
push-to-main and fall back to github.token for the checkout on PRs,
since PRs only need to validate the build.
2026-05-12 12:26:22 +07:00
paseo-ai[bot]
af4e0de9ab fix: update lockfile signatures and Nix hash 2026-05-12 05:21:32 +00:00
Mohamed Boudra
3acc71b8ad Normalize HEIC image attachments on the client (#934)
* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks
2026-05-12 13:14:25 +08:00
Matan Bendix Shenhav
0759932dad nix: declarative config, typed relay options, desktop packaging (#923)
* nix: expose npmDepsHash as a callPackage arg

Downstream flakes that follow a different nixpkgs revision can hit a
hash mismatch on the npm-deps FOD even though package-lock.json is
unchanged, because fetchNpmDeps output is sensitive to nixpkgs version.
The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not
work for buildNpmPackage: npmDepsHash is destructured from args, so the
default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already
bound by the time overrideAttrs runs.

Promote npmDepsHash to a callPackage arg with the current value as the
default. Consumers can now `.override { npmDepsHash = "sha256-..."; }`
and have it propagate to the npmDeps fetcher. Upstream CI behavior is
unchanged — update-nix.sh is adjusted to match the new
`npmDepsHash ? "..."` pattern.

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

* nix: move npmDepsHash default to a sidecar file

Read the default `npmDepsHash` from `nix/npm-deps.hash` via
`lib.fileContents` instead of inlining it as a string literal in
`nix/package.nix`. The CI auto-updater becomes a one-line file write
instead of a regex against a .nix source — decoupling lockfile bumps
from the formatting of the package definition.

No behavior change: same hash, same default, same `.override` surface.
Lockfile diffs become smaller and the update path stops being load-
bearing on a sed pattern.

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

* nix: declarative config via services.paseo.settings

Today only a handful of `config.json` fields are surfaced as module
options (listen, port, hostnames, relay.enable). Anything richer —
custom agent providers, MCP injection, log config, voice features —
requires hand-editing `$PASEO_HOME/config.json`.

Add `services.paseo.settings` as a freeform attrset rendered to JSON
via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json`
on each service start. Standard NixOS idiom.

`install` on `preStart` rather than a `tmpfiles` symlink because the
daemon writes to `config.json` at runtime via `DaemonConfigStore.patch`
(MCP / provider toggles). A read-only symlink would break those writes;
a copy-on-start lets the daemon mutate freely within a session while
the Nix-managed file remains the source of truth at boot.

The full schema is `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`. Documented in the
option description that runtime mutations don't survive restarts when
`settings` is non-empty.

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

* nix: typed services.paseo.relay options with auto-wired endpoint

Addresses #224 (option surface only).

Today `services.paseo.relay.enable` is a bool that just toggles
`--no-relay`. Pointing the daemon at a self-hosted relay requires
hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via
the freeform `environment` option.

Add a typed relay subtree:

- `relay.mode = "hosted" | "remote"` selects how the daemon reaches
  the relay when enabled. Default is `"hosted"` (current behavior).
- `relay.{host,port,useTls}` configure the `"remote"` case.
- The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS`
  when `mode = "remote"`.
- Assertion fires at eval time when `mode = "remote"` but `host` is empty.
- `relay.enable` keeps its current semantics — bool answers "is it on?",
  the new options answer "how is it configured?".

The `"local"` mode from #224 (running a relay on the same host as a
systemd unit) is deliberately not added here: `packages/relay` ships
only a Cloudflare Workers adapter, so there's no Node.js runtime to
package as a binary. Adding a Node adapter is a TS-side feature change
worth its own design discussion; tracked as a follow-up.

No breaking changes — existing `relay.enable = true|false` configs
evaluate unchanged with the new `mode = "hosted"` default.

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

* nix: package paseo desktop app for Linux

NixOS users have no easy way to run the desktop app today —
electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's
model, so `nix run github:getpaseo/paseo#desktop` doesn't exist.

Add `packages.<linux>.desktop` following the standard nixpkgs Electron
pattern (see e.g. signal-desktop, vscode): skip electron-builder
entirely, build the desktop main process with `tsc`, bundle the Expo
web export and built daemon workspaces, and wrap `pkgs.electron` with
`makeWrapper`. Output is a runnable derivation usable via `nix run` or
`environment.systemPackages`.

The install layout preserves the monorepo source tree
(`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules`
at the workspace root) so `main.ts`'s dev-mode path resolution
(`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
without any source patches. When Electron is invoked unpackaged via
`electron path/to/main.js`, `app.isPackaged` is false and these
relative paths are used.

`--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't
live in `/nix/store` (immutable, no setuid). A follow-up can wire
`security.wrappers` from a NixOS module for users who want the
renderer sandbox.

No CI changes — `desktop-release.yml` continues to produce
.deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely
additive for NixOS users.

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

* nix: copy full packages/ tree in desktop derivation

The previous installPhase selectively copied built artifacts (dist/
under server, cli, relay, highlight, expo-two-way-audio), which left
two workspace symlinks dangling and failed noBrokenSymlinks:

- node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio
  (the Expo native module ships source + native projects, no built dist/)
- node_modules/.bin/paseo → @getpaseo/cli/bin/paseo
  (the CLI launcher script lives under bin/, not dist/)

npm workspace symlinks expect every workspace package to exist at its
source path. Copy the whole packages/ tree instead. The cleanSourceWith
filter already excludes the heavy platform-specific paths (android/ios
under packages/app, website, tests), and the remaining ~16MB of src is
acceptable for an Electron app derivation.

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

* nix: route desktop renderer through paseo:// protocol handler

When `paseo-desktop` is launched via `electron path/to/main.js` (our
unpackaged Nix layout), `app.isPackaged` is false and main.ts loads
`DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo
dev server). That URL has nothing listening in a Nix-installed run,
so the renderer fails with ERR_CONNECTION_REFUSED.

main.ts already supports overriding this via the `EXPO_DEV_URL` env
var. Set it to `paseo://app/` so the request goes through the
`paseo://` protocol handler that main.ts registers unconditionally.
The handler resolves files via `getAppDistDir()`, which in the
unpackaged branch returns `__dirname/../../app/dist` — exactly where
our install layout places the Expo web export.

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

* ci: track nix/npm-deps.hash in nix-build commit step

The commit step still referenced nix/package.nix in its diff check
and git add. After moving the hash to nix/npm-deps.hash, the
auto-updated hash would never be staged and the new value would
sit unstaged in the working tree forever.

* ci: push nix-build hash commits via paseo-ai[bot] App token

The default GITHUB_TOKEN cannot bypass main's required status checks,
so the auto-commit of stale Nix hash updates has been silently failing.
Mint an installation token for the paseo-ai App (which is in the
ruleset bypass list) and use it for checkout and push.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-12 13:11:42 +08:00
Mohamed Boudra
95f45e4e2b Add trace logging and tighten daemon log defaults (#933)
* Add trace logging and tighten daemon log defaults

* Clean up daemon trace logging shape

* Clean up daemon trace logging

* Route provider turn-id checks through shared helper

* Fix supervisor log config test paths on Windows

* Expect resolved supervisor log path on Windows
2026-05-12 12:58:46 +08:00
Mohamed Boudra
a5c2b97e1d Wire Copilot Allow All mode to ACP permissions (#935) 2026-05-12 12:00:20 +08:00
Mohamed Boudra
d32462e9ee Fix custom Codex provider base URL routing (#915) 2026-05-12 11:54:15 +08:00
Mohamed Boudra
33262843a5 chore(release): cut 0.1.74 2026-05-11 18:17:50 +07:00
Mohamed Boudra
1cd02a0e1a chore: changelog for 0.1.74 2026-05-11 18:16:46 +07:00
Mohamed Boudra
40ab9e3f20 Use OpenCode global event stream (#916)
* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes
2026-05-11 09:17:00 +00:00
82 changed files with 3866 additions and 2843 deletions

View File

@@ -1,72 +0,0 @@
name: Nix Build
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-build.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates (main push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git diff --quiet package-lock.json nix/package.nix && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package-lock.json nix/package.nix
git commit -m "fix: update lockfile signatures and Nix hash"
git push

60
.github/workflows/nix-update-hash.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Nix Update Hash
on:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix-update-hash.yml"
permissions:
contents: read
jobs:
update-hash:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Commit hash/lockfile updates
run: |
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
git config user.name "paseo-ai[bot]"
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
git add package-lock.json nix/npm-deps.hash
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
git push

99
.github/workflows/nix.yml vendored Normal file
View File

@@ -0,0 +1,99 @@
name: Nix
on:
pull_request:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Update lockfile + Nix hash if stale
run: ./scripts/update-nix.sh
- name: Build Nix package
run: nix build .#default -o result
- name: Smoke Nix daemon
run: |
set -euo pipefail
export PASEO_HOME
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
kill "$DAEMON_PID"
wait "$DAEMON_PID" || true
fi
rm -rf "$PASEO_HOME"
}
trap cleanup EXIT
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
echo "$STATUS_JSON"
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Nix daemon exited before becoming reachable."
break
fi
sleep 1
done
echo "::group::daemon.log"
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon status"
./result/bin/paseo daemon status || true
echo "::endgroup::"
exit 1
- name: Build Nix desktop package
run: nix build .#desktop -o result-desktop

View File

@@ -1,5 +1,30 @@
# Changelog
## 0.1.75 - 2026-05-12
### Added
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
### Fixed
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed

View File

@@ -59,6 +59,30 @@ Required fields for custom providers:
- `extends` — which built-in provider to inherit from (or `"acp"`)
- `label` — display name in the UI
### Codex with an OpenAI-compatible endpoint
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
```json
{
"agents": {
"providers": {
"my-codex": {
"extends": "codex",
"label": "My Codex",
"env": {
"OPENAI_API_KEY": "sk-...",
"OPENAI_BASE_URL": "https://custom-relay.example.com"
},
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
}
}
}
}
```
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
---
## Z.AI (Zhipu) coding plan

View File

@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
providers: Record<providerId, ProviderOverride>
},
features: {
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
},
log: {
level, format,

View File

@@ -39,7 +39,14 @@ In any worktree-style or portless setup, never assume default ports.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for trace-level logs.
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
session, and agent-manager traces for stuck-state debugging.
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
## paseo.json service scripts

View File

@@ -0,0 +1,49 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`

View File

@@ -26,11 +26,18 @@
let
pkgs = pkgsFor system;
paseo = pkgs.callPackage ./nix/package.nix { };
isLinux = nixpkgs.lib.elem system [
"x86_64-linux"
"aarch64-linux"
];
in
{
default = paseo;
paseo = paseo;
}
// nixpkgs.lib.optionalAttrs isLinux {
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
}
);
nixosModules.default = self.nixosModules.paseo;

157
nix/desktop-package.nix Normal file
View File

@@ -0,0 +1,157 @@
{
lib,
stdenv,
buildNpmPackage,
nodejs_22,
python3,
makeWrapper,
copyDesktopItems,
makeDesktopItem,
electron,
libuv,
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
# different value.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
pname = "paseo-desktop";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = lib.cleanSourceWith {
src = ./..;
filter = path: type:
let
baseName = builtins.baseNameOf path;
relPath = lib.removePrefix (toString ./..) path;
in
# Exclude mobile-only platform code (we only need the web/electron build)
!(lib.hasPrefix "/packages/app/android" relPath)
&& !(lib.hasPrefix "/packages/app/ios" relPath)
# Website is unrelated to the desktop app
&& !(lib.hasPrefix "/packages/website" relPath)
# Test fixtures and build artifacts
&& !(lib.hasSuffix ".test.ts" baseName)
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
&& baseName != "node_modules"
&& baseName != ".git"
&& baseName != ".paseo"
&& baseName != ".DS_Store"
&& baseName != "release";
};
nodejs = nodejs_22;
inherit npmDepsHash;
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild. We manually rebuild only node-pty in buildPhase.
npmRebuildFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [
python3 # for node-gyp (node-pty)
makeWrapper
copyDesktopItems
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
dontNpmBuild = true;
env = {
EXPO_NO_TELEMETRY = "1";
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
};
buildPhase = ''
runHook preBuild
# Native deps (terminal emulation; libuv-linked on Linux)
npm rebuild node-pty
# Daemon workspaces (highlight + relay + server + cli)
npm run build:daemon
# App workspace deps not covered by build:daemon
npm run build --workspace=@getpaseo/expo-two-way-audio
# Expo web export for the Electron renderer
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
# Desktop main process (tsc only NOT electron-builder)
npm run build:main --workspace=@getpaseo/desktop
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/paseo-desktop $out/bin
# Preserve the monorepo layout so main.js's dev-mode path resolution
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
# without patching: invoked unpackaged via `electron path/to/main.js`,
# `app.isPackaged` is false, so these relative paths are used.
#
# Copy the entire packages/ tree (not just built artifacts) because npm
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
# Missing any workspace package leaves dangling symlinks and fails the
# noBrokenSymlinks output check. The cleanSourceWith filter above already
# drops the big platform-specific things (android/ios, website, tests).
cp package.json $out/share/paseo-desktop/
cp -a packages $out/share/paseo-desktop/
cp -a node_modules $out/share/paseo-desktop/
# Skills directory referenced at runtime by some agents
if [ -d skills ]; then
cp -a skills $out/share/paseo-desktop/
fi
# Hicolor icon for desktop environments
install -Dm644 packages/desktop/assets/icon.png \
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
# Launcher wraps nixpkgs electron.
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
# `security.wrappers` via a NixOS module for users who want the sandbox.
#
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
# (defaults to http://localhost:8081 the Expo dev server, which doesn't
# exist here). Point it at the `paseo://` protocol handler instead, which
# serves from `__dirname/../../app/dist` (our install layout matches).
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
--add-flags "--no-sandbox" \
--set EXPO_DEV_URL "paseo://app/"
copyDesktopItems
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "paseo-desktop";
desktopName = "Paseo";
genericName = "AI Coding Agents";
comment = "Self-hosted daemon for AI coding agents";
exec = "paseo-desktop";
icon = "paseo-desktop";
categories = [ "Development" ];
startupWMClass = "Paseo";
})
];
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux;
};
}

View File

@@ -81,7 +81,48 @@ in
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
description = ''
Whether to enable relay-based remote access. When false, the daemon
runs with `--no-relay` and only accepts direct (LAN/loopback)
connections.
'';
};
mode = lib.mkOption {
type = lib.types.enum [ "hosted" "remote" ];
default = "hosted";
description = ''
How the daemon reaches the relay when `relay.enable = true`:
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
Preserves the current behavior; no extra options needed.
- `"remote"`: connect to a self-hosted relay at
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
`PASEO_RELAY_USE_TLS` for the daemon.
A `"local"` mode (running a relay on the same host as a systemd
unit) is not yet implemented the relay package currently only
ships a Cloudflare Workers adapter. Tracked separately.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "";
example = "relay.example.com";
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Relay port. Used when `relay.mode = \"remote\"`.";
};
useTls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
};
@@ -111,9 +152,50 @@ in
'';
description = "Extra environment variables for the Paseo daemon.";
};
settings = lib.mkOption {
type = (pkgs.formats.json { }).type;
default = { };
example = lib.literalExpression ''
{
daemon.mcp = { enabled = true; injectIntoAgents = false; };
agents.providers.myAcp = {
extends = "acp";
label = "My Agent";
command = { path = "/run/current-system/sw/bin/my-acp"; };
};
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
}
'';
description = ''
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
and installed on every service start.
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
or the mobile app toggling MCP injection / provider overrides) are
overwritten on the next restart. Pick one: manage via this option, or
manage via the CLI not both.
The full schema is defined by `PersistedConfigSchema` in
`packages/server/src/server/persisted-config.ts`.
'';
};
};
config = lib.mkIf cfg.enable {
config = lib.mkIf cfg.enable (
let
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
in
{
assertions = [
{
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
message = ''
services.paseo.relay.host must be set when relay.mode = "remote".
'';
}
];
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
isSystemUser = true;
group = cfg.group;
@@ -131,6 +213,10 @@ in
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = lib.mkIf (cfg.settings != { }) ''
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
'';
environment = {
NODE_ENV = "production";
PASEO_HOME = cfg.dataDir;
@@ -149,6 +235,9 @@ in
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // cfg.environment;
serviceConfig = {
@@ -172,5 +261,6 @@ in
environment.systemPackages = [ cfg.package ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
);
}

1
nix/npm-deps.hash Normal file
View File

@@ -0,0 +1 @@
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=

View File

@@ -7,6 +7,15 @@
makeWrapper,
# node-pty needs libuv headers on Linux
libuv,
# Exposed so downstream flakes that follow a different nixpkgs revision
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
# can override via `.override { npmDepsHash = "sha256-..."; }` without
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
#
# The default is read from a sidecar file so the CI auto-updater can replace
# the hash with a single file write instead of a sed against this source.
npmDepsHash ? lib.fileContents ./npm-deps.hash,
}:
buildNpmPackage rec {
@@ -40,9 +49,9 @@ buildNpmPackage rec {
nodejs = nodejs_22;
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
# Default hash lives in nix/npm-deps.hash (see arg default above).
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
inherit npmDepsHash;
# 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).

39
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.73",
"version": "0.1.75",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.73",
"version": "0.1.75",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -21769,6 +21769,18 @@
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
@@ -38848,7 +38860,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.73",
"version": "0.1.75",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38886,6 +38898,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
@@ -38974,10 +38987,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.73",
"version": "0.1.75",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.73",
"@getpaseo/server": "0.1.75",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39020,7 +39033,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.73",
"version": "0.1.75",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -39069,7 +39082,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.73",
"version": "0.1.75",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39105,7 +39118,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.73",
"version": "0.1.75",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -39131,7 +39144,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.73",
"version": "0.1.75",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39146,12 +39159,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.73",
"version": "0.1.75",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.73",
"@getpaseo/relay": "0.1.73",
"@getpaseo/highlight": "0.1.75",
"@getpaseo/relay": "0.1.75",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -39694,7 +39707,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.73",
"version": "0.1.75",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.73",
"version": "0.1.75",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.73",
"version": "0.1.75",
"private": true,
"main": "index.ts",
"scripts": {
@@ -63,6 +63,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",

View File

@@ -791,10 +791,13 @@ function DesktopSidebar({
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle, insetsTop],
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
[resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
);
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[],

View File

@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-image-manipulator", () => ({
SaveFormat: { PNG: "png" },
ImageManipulator: {
manipulate: (_source: string) => ({
async renderAsync() {
return {
release() {},
async saveAsync(options: { format?: string }) {
return {
uri:
options.format === "png"
? "file:///cache/ImageManipulator/safe-picked.png"
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
width: 100,
height: 100,
};
},
};
},
release() {},
}),
},
}));
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
describe("native image attachment picker", () => {
it("preserves native picked JPEG and PNG attachment inputs", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.JPG",
mimeType: "image/jpeg",
fileName: "picked.jpeg",
},
{
uri: "file:///photos/screenshot.png",
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
mimeType: "image/jpeg",
fileName: "picked.jpg",
},
{
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
});
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.HEIC",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
});

View File

@@ -0,0 +1,129 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
interface SupportedPickedImageFormat {
mimeType: "image/jpeg" | "image/png";
extension: "jpg" | "png";
}
const JPEG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/jpeg",
extension: "jpg",
};
const PNG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/png",
extension: "png",
};
function extensionFromPath(path: string | null | undefined): string | null {
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
return match?.[1]?.toLowerCase() ?? null;
}
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
if (extension === "jpg" || extension === "jpeg") {
return JPEG_FORMAT;
}
if (extension === "png") {
return PNG_FORMAT;
}
return null;
}
function supportedFormatForMimeType(
mimeType: string | null | undefined,
): SupportedPickedImageFormat | null {
const normalizedMimeType = mimeType?.toLowerCase();
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
return JPEG_FORMAT;
}
if (normalizedMimeType === "image/png") {
return PNG_FORMAT;
}
return null;
}
function pickedAssetSupportedFormat(
asset: ExpoImagePickerAssetLike,
): SupportedPickedImageFormat | null {
const uriExtension = extensionFromPath(asset.uri);
if (uriExtension) {
return supportedFormatForExtension(uriExtension);
}
return (
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
supportedFormatForMimeType(asset.mimeType)
);
}
function replaceFileExtension(
fileName: string | null | undefined,
extension: SupportedPickedImageFormat["extension"],
): string | null {
if (!fileName) {
return null;
}
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
}
async function exportPickedImageAsPng(uri: string): Promise<string> {
const context = ImageManipulator.manipulate(uri);
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
try {
image = await context.renderAsync();
const result = await image.saveAsync({
format: SaveFormat.PNG,
});
return result.uri;
} finally {
image?.release();
context.release();
}
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
const supportedFormat = pickedAssetSupportedFormat(asset);
if (supportedFormat) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: supportedFormat.mimeType,
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
};
}
const convertedUri = await exportPickedImageAsPng(asset.uri);
return {
source: { kind: "file_uri", uri: convertedUri },
mimeType: PNG_FORMAT.mimeType,
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
};
}),
);
}
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
throw new Error("Desktop dialog API is not available on native.");
}

View File

@@ -720,9 +720,16 @@ function SettingsSidebar({
}, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
const padding = useWindowControlsPadding("sidebar");
const isDesktop = layout === "desktop";
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const containerStyle = useMemo(
() => [
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
isDesktop ? { paddingTop: insets.top } : null,
],
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.73",
"version": "0.1.75",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.73",
"@getpaseo/server": "0.1.75",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.73",
"version": "0.1.75",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
@@ -70,6 +71,34 @@ function desktopSettingsWithManagement(enabled: boolean) {
};
}
type MockChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
pid: number;
spawnfile: string;
spawnargs: string[];
unref: ReturnType<typeof vi.fn>;
};
function createMockChildProcess(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 1234;
child.spawnfile = "node";
child.spawnargs = ["node", "daemon.js"];
child.unref = vi.fn();
return child;
}
function scheduleFailedStartupOutput(child: MockChildProcess): void {
setImmediate(() => {
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
child.emit("exit", 1, null);
});
}
describe("daemon-manager commands", () => {
beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
@@ -165,4 +194,61 @@ describe("daemon-manager commands", () => {
"--json",
]);
});
it("uses a reachable daemon when the PID file is stale", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.2",
desktopManaged: true,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: "dev-host",
pid: null,
home: "/tmp/paseo-home",
version: "1.2.2",
desktopManaged: false,
error: null,
});
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("bounds captured daemon startup output", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
});
mocks.spawnProcess.mockImplementation(() => {
const child = createMockChildProcess();
scheduleFailedStartupOutput(child);
return child;
});
const handlers = createDaemonCommandHandlers();
let thrown: Error | null = null;
try {
await handlers.start_desktop_daemon();
} catch (error) {
thrown = error instanceof Error ? error : new Error(String(error));
}
expect(thrown).toBeInstanceOf(Error);
const message = thrown?.message ?? "";
expect(message).toContain("Daemon failed to start: exit code 1");
expect(message).toContain("output truncated to the last 65536 chars");
expect(message).toContain("stdout-tail");
expect(message).toContain("stderr-tail");
expect(message.length).toBeLessThan(150_000);
});
});

View File

@@ -43,6 +43,7 @@ const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150;
const DETACHED_STARTUP_GRACE_MS = 1200;
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -69,6 +70,11 @@ interface DesktopPairingOffer {
qr: string | null;
}
interface StartupOutputCapture {
text: string;
truncated: boolean;
}
function parseReleaseChannel(
args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined {
@@ -145,6 +151,30 @@ function tailFile(filePath: string, lines = 50): string {
}
}
function createStartupOutputCapture(): StartupOutputCapture {
return { text: "", truncated: false };
}
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
const nextText = capture.text + chunk.toString();
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
return { text: nextText, truncated: capture.truncated };
}
return {
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
truncated: true,
};
}
function formatStartupOutput(capture: StartupOutputCapture): string {
if (!capture.truncated) {
return capture.text;
}
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
}
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
log.info("[desktop daemon]", message, {
pid: process.pid,
@@ -197,17 +227,28 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
unknown
>;
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const running = localDaemon === "running";
const connectedDaemon =
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
status = "running";
} else if (localDaemon === "unresponsive") {
status = "errored";
}
return {
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
status: running ? "running" : "stopped",
status,
listen: typeof payload.listen === "string" ? payload.listen : null,
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
pid: running && typeof payload.pid === "number" ? payload.pid : null,
hostname:
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: payload.desktopManaged === true,
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
error: null,
};
} catch (error) {
@@ -248,15 +289,17 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error },
stdout: string,
stderr: string,
stdout: StartupOutputCapture,
stderr: StartupOutputCapture,
): Error {
const reason = result.error
? result.error.message
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
const parts = [`Daemon failed to start: ${reason}`];
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`);
const formattedStderr = formatStartupOutput(stderr).trim();
const formattedStdout = formatStartupOutput(stdout).trim();
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
const logs = tailFile(logFilePath(), 15);
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
return new Error(parts.join("\n\n"));
@@ -337,13 +380,13 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdout = createStartupOutputCapture();
let stderr = createStartupOutputCapture();
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
stdout = appendStartupOutput(stdout, data);
});
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
stderr = appendStartupOutput(stderr, data);
});
logDesktopDaemonLifecycle("detached spawn returned", {
@@ -381,8 +424,8 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly: result.exitedEarly,
stdout: stdout.slice(0, 2000),
stderr: stderr.slice(0, 2000),
stdout: formatStartupOutput(stdout).slice(0, 2000),
stderr: formatStartupOutput(stderr).slice(0, 2000),
...(result.exitedEarly
? {
exitCode: result.code,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.73",
"version": "0.1.75",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.73",
"version": "0.1.75",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.73",
"version": "0.1.75",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.73",
"version": "0.1.75",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,8 +58,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.73",
"@getpaseo/relay": "0.1.73",
"@getpaseo/highlight": "0.1.75",
"@getpaseo/relay": "0.1.75",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",

View File

@@ -10,12 +10,9 @@ import {
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig {
devMode: boolean;
workerArgs: string[];
@@ -77,28 +74,6 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null;
}
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
@@ -113,7 +88,7 @@ async function main(): Promise<void> {
const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv);
try {
await acquirePidLock(paseoHome, null, {

View File

@@ -0,0 +1,42 @@
import path from "node:path";
import type { loadPersistedConfig } from "../src/server/persisted-config.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 3;
export function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
env: NodeJS.ProcessEnv = process.env,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
const envRotateSize = env.PASEO_LOG_ROTATE_SIZE?.trim();
const envRotateMaxFiles = parseOptionalPositiveInteger(env.PASEO_LOG_ROTATE_COUNT);
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? envRotateSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles:
configuredFile?.rotate?.maxFiles ?? envRotateMaxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
function parseOptionalPositiveInteger(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}

View File

@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -87,6 +88,57 @@ async function runSupervisorFixture(options: {
}
describe("supervisor durable logging", () => {
test("resolves rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(paseoHome, {}, {});
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "10m", maxFiles: 3 },
});
});
test("lets persisted rotation override env rotation defaults", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{
log: {
file: {
path: "logs/daemon.log",
rotate: { maxSize: "25m", maxFiles: 4 },
},
},
},
{
PASEO_LOG_ROTATE_SIZE: "200m",
PASEO_LOG_ROTATE_COUNT: "12",
},
);
expect(logFile).toEqual({
path: path.resolve(paseoHome, "logs", "daemon.log"),
rotate: { maxSize: "25m", maxFiles: 4 },
});
});
test("uses env rotation when persisted rotation is absent", () => {
const paseoHome = path.join(path.sep, "tmp", "paseo-home");
const logFile = resolveSupervisorLogFile(
paseoHome,
{},
{
PASEO_LOG_ROTATE_SIZE: "50m",
PASEO_LOG_ROTATE_COUNT: "8",
},
);
expect(logFile).toEqual({
path: path.join(paseoHome, "daemon.log"),
rotate: { maxSize: "50m", maxFiles: 8 },
});
});
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `

View File

@@ -716,6 +716,7 @@ test("createAgent passes daemon launch env through the provider launch context",
modeId: "auto",
});
expect(client.lastLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
@@ -1179,6 +1180,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
},
});
expect(client.lastResumeLaunchContext).toEqual({
agentId: resumed.id,
env: {
PASEO_AGENT_ID: resumed.id,
},
@@ -1283,6 +1285,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
});
expect(client.lastCreateLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},
@@ -1293,6 +1296,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
});
expect(client.lastResumeLaunchContext).toEqual({
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
},

View File

@@ -10,30 +10,31 @@ import type { Logger } from "pino";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentSlashCommand,
AgentMode,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPersistenceHandle,
AgentPromptInput,
AgentProvider,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
@@ -1005,18 +1006,28 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
},
"closeAgent: start",
"agent.manager.close.start",
);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close();
this.timelineStore.delete(agentId);
await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace({ agentId }, "closeAgent: completed");
this.logger.trace(
{
agentId,
provider: closedAgent.provider,
sessionId: closedAgent.persistence?.sessionId ?? undefined,
},
"agent.manager.close.complete",
);
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
@@ -1470,22 +1481,28 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options),
},
"streamAgent: requested",
"agent.manager.stream.request",
);
if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
},
"streamAgent: rejected because a foreground run is already in flight",
"agent.manager.stream.reject",
);
throw new Error(`Agent ${agentId} already has an active run`);
}
@@ -1522,10 +1539,13 @@ export class AgentManager {
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
},
"streamAgent: started",
"agent.manager.stream.start",
);
turnStream = this.foregroundRuns.createTurnStream(turnId);
@@ -1577,11 +1597,14 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: mutableAgent.lifecycle,
terminalError,
pendingReplacement: mutableAgent.pendingReplacement,
},
"finalizeForegroundTurn: applying terminal state",
"agent.manager.finalize",
);
if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent);
@@ -2371,6 +2394,16 @@ export class AgentManager {
}
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.enqueue",
);
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
@@ -2382,6 +2415,16 @@ export class AgentManager {
if (current.session == null) {
return;
}
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: current.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.dequeue",
);
await this.dispatchSessionEvent(current, event);
return;
})
@@ -2405,8 +2448,19 @@ export class AgentManager {
agent: ActiveManagedAgent,
event: AgentStreamEvent,
): Promise<void> {
const turnId = (event as { turnId?: string }).turnId;
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
matchingWaiterCount: matchingWaiters.length,
event,
},
"agent.manager.dispatch_session_event",
);
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
@@ -2417,6 +2471,18 @@ export class AgentManager {
this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
terminal: isTurnTerminalEvent(event),
});
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
notifiedWaiterCount: matchingWaiters.length,
terminal: isTurnTerminalEvent(event),
event,
},
"agent.manager.notify_waiters",
);
}
private async resolveInitialPersistedTitle(
@@ -2529,7 +2595,7 @@ export class AgentManager {
}
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
const turnId = (event as { turnId?: string }).turnId;
const turnId = getAgentStreamEventTurnId(event);
if (turnId == null) {
return;
}
@@ -2540,6 +2606,16 @@ export class AgentManager {
}
this.foregroundRuns.notifyAgentWaiters(agent, event);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.notify_waiters.coalesced",
);
}
private async handleStreamEvent(
@@ -2547,8 +2623,9 @@ export class AgentManager {
event: AgentStreamEvent,
options?: HandleStreamEventOptions,
): Promise<boolean> {
const eventTurnId = (event as { turnId?: string }).turnId;
const eventTurnId = getAgentStreamEventTurnId(event);
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
if (
eventTurnId &&
isTurnTerminalEvent(event) &&
@@ -2561,6 +2638,7 @@ export class AgentManager {
if (!options?.fromHistory) {
this.touchUpdatedAt(agent);
if (this.agentStreamCoalescer.handle(agent.id, event)) {
this.traceCoalescerBuffered(agent, event, eventTurnId);
return false;
}
this.agentStreamCoalescer.flushFor(agent.id);
@@ -2588,9 +2666,71 @@ export class AgentManager {
this.dispatchStream(agent.id, event);
}
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
return flags.shouldNotifyWaiters;
}
private traceHandleStreamEventStart(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
isForegroundEvent: boolean,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
isForegroundEvent,
event,
},
"agent.manager.handle_stream_event.start",
);
}
private traceCoalescerBuffered(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.coalescer.buffer",
);
}
private traceHandleStreamEventEnd(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
flags: StreamEventFlags,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
shouldDispatchEvent: flags.shouldDispatchEvent,
shouldNotifyWaiters: flags.shouldNotifyWaiters,
event,
},
"agent.manager.handle_stream_event.end",
);
}
private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent;
event: AgentStreamEvent;
@@ -2747,11 +2887,13 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"handleStreamEvent: turn_completed",
"agent.manager.turn.completed",
);
agent.lastUsage = event.usage;
agent.lastError = undefined;
@@ -2778,6 +2920,9 @@ export class AgentManager {
this.logger.warn(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
@@ -2818,11 +2963,14 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"handleStreamEvent: turn_canceled",
"agent.manager.turn.canceled",
);
if (!isForegroundEvent && !agent.pendingReplacement) {
agent.lifecycle = "idle";
@@ -2843,11 +2991,13 @@ export class AgentManager {
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"handleStreamEvent: turn_started",
"agent.manager.turn.started",
);
if (!isForegroundEvent) {
agent.lifecycle = "running";
@@ -2993,6 +3143,20 @@ export class AgentManager {
this.syncFeaturesFromSession(agent);
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
persist: options?.persist !== false,
},
"agent.manager.emit_state",
);
this.dispatch({
type: "agent_state",
agent: { ...agent },
@@ -3120,6 +3284,18 @@ export class AgentManager {
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string },
): void {
const agent = this.agents.get(agentId);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
metadata,
event,
},
"agent.manager.dispatch_stream",
);
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
}
@@ -3215,6 +3391,7 @@ export class AgentManager {
private buildLaunchContext(agentId: string): AgentLaunchContext {
return {
agentId,
env: {
PASEO_AGENT_ID: agentId,
},

View File

@@ -17,6 +17,19 @@ export function startAgentRun(
logger: Logger,
options?: StartAgentRunOptions,
): { outOfBand: boolean } {
const snapshot = agentManager.getAgent(agentId);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
turnId: snapshot?.activeForegroundTurnId ?? undefined,
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options?.runOptions),
replaceRunning: Boolean(options?.replaceRunning),
},
"agent.session.start_stream.request",
);
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
@@ -28,12 +41,38 @@ export function startAgentRun(
const iterator = shouldReplace
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
: agentManager.streamAgent(agentId, prompt, runOptions);
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
shouldReplace,
},
"agent.session.start_stream.iterator_returned",
);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
},
"agent.session.iterator.drained",
);
} catch (error) {
logger.trace(
{
agentId,
provider: snapshot?.provider,
providerSessionId: snapshot?.persistence?.sessionId ?? undefined,
err: error,
},
"agent.session.iterator.error",
);
logger.error({ err: error, agentId }, "Agent stream failed");
}
})();

View File

@@ -358,6 +358,10 @@ export type AgentStreamEvent =
timestamp: string;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
return "turnId" in event ? event.turnId : undefined;
}
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata;
@@ -476,6 +480,7 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
agentId?: string;
env?: Record<string, string>;
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import { getAgentStreamEventTurnId, type AgentStreamEvent } from "./agent-sdk-types.js";
export interface ForegroundTurnWaiter {
turnId: string;
@@ -105,7 +105,7 @@ export class ForegroundRunState {
event: AgentStreamEvent,
options?: { terminal?: boolean },
): void {
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
const waiters = this.getMatchingWaiters(agent, getAgentStreamEventTurnId(event));
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
}
@@ -226,7 +226,3 @@ function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
pendingRun.settled = true;
pendingRun.resolveSettled();
}
function eventTurnId(event: AgentStreamEvent): string | undefined {
return (event as { turnId?: string }).turnId;
}

View File

@@ -11,6 +11,7 @@ export interface AgentModeVisuals {
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & {
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
isUnattended?: boolean;
};
@@ -96,9 +97,9 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
colorTier: "planning",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
id: "allow-all",
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,

View File

@@ -67,10 +67,21 @@ export interface BuildProviderRegistryOptions {
isDev?: boolean;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService"
> {
customProvider?: {
id: string;
label: string;
extends: string;
};
}
type ProviderClientFactory = (
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
options?: ProviderClientFactoryOptions,
) => AgentClient;
interface ResolvedProvider {
@@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
codex: (logger, runtimeSettings, options) =>
new CodexAppServerAgentClient(logger, runtimeSettings, {
workspaceGitService: options?.workspaceGitService,
customProvider: options?.customProvider,
}),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
@@ -510,10 +522,11 @@ function addDerivedProviders(
continue;
}
const baseProvider = resolvedProviders.get(override.extends);
const baseProviderId = override.extends;
const baseProvider = resolvedProviders.get(baseProviderId);
if (!baseProvider) {
throw new Error(
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
`Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`,
);
}
@@ -522,7 +535,7 @@ function addDerivedProviders(
toRuntimeSettings(override),
);
const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(override.extends);
const baseFactory = getProviderClientFactory(baseProviderId);
resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override),
@@ -530,8 +543,15 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: override.extends,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
derivedFromProviderId: baseProviderId,
createBaseClient: (logger) =>
baseFactory(logger, mergedRuntimeSettings, {
customProvider: {
id: providerId,
label: override.label ?? providerId,
extends: baseProviderId,
},
}),
});
}
}

View File

@@ -21,6 +21,15 @@ import {
resolveACPModeSelection,
resolveACPModelSelection,
} from "./acp-agent.js";
import {
COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES,
beforeCopilotModeWriter,
transformCopilotConfigOptions,
transformCopilotModeId,
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -140,6 +149,73 @@ function selectConfigOption(
};
}
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
return new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined,
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
}
function copilotModeConfigOption(currentValue: string): SessionConfigOption {
return {
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue,
options: [
{
value: "https://agentclientprotocol.com/protocol/session-modes#agent",
name: "Agent",
},
{
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
name: "Plan",
},
{
value: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
name: "Autopilot",
},
],
};
}
function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigOption {
return {
id: "allow_all",
name: "Allow All",
category: "permissions",
type: "select",
currentValue,
options: [
{ value: "on", name: "On" },
{ value: "off", name: "Off" },
],
};
}
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
if (category === "mode") {
return "Mode";
@@ -190,7 +266,7 @@ function prepareConfiguredOverrideSession(
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
const childLogger = { trace: vi.fn(), warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({
@@ -577,7 +653,7 @@ describe("ACPAgentSession Zed parity", () => {
expect(await validSession.getCurrentMode()).toBe("default");
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
const childLogger = { trace: vi.fn(), warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
const invalidSession = createSessionWithConfig(
{ modeId: "acceptEdits", model: "opus" },
@@ -796,12 +872,10 @@ describe("ACPAgentSession Zed parity", () => {
});
});
test("passes Copilot Autopilot ACP permission requests through to the user", async () => {
// Zed parity: Copilot Autopilot no longer auto-approves ACP permission requests,
// so the accepted UX regression is that every tool request reaches the user UI.
test("passes generic ACP permission requests through to the user", async () => {
const session = createSessionWithConfig({
provider: "copilot",
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
provider: "cursor-acp",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
const events: Array<{ type: string; request?: { id: string } }> = [];
const permissionOptions: PermissionOption[] = [
@@ -835,6 +909,126 @@ describe("ACPAgentSession Zed parity", () => {
outcome: { outcome: "selected", optionId: "allow-once" },
});
});
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
const { internals } = prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
await internals.applyConfiguredOverrides();
unsubscribe();
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
});
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig();
prepareConfiguredOverrideSession(session, {
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("off"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "on",
});
expect(setSessionMode).not.toHaveBeenCalled();
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
],
}));
const setSessionMode = vi.fn(async () => undefined);
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
prepareConfiguredOverrideSession(session, {
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: COPILOT_MODES,
configOptions: [
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
copilotAllowAllConfigOption("on"),
],
connection: { setSessionConfigOption, setSessionMode },
});
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "allow_all",
value: "off",
});
expect(setSessionMode).toHaveBeenCalledWith({
sessionId: "session-1",
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
});
});
test("trusts Copilot allow_all config updates as the current mode source", async () => {
const session = createCopilotSessionWithConfig();
const internals = asInternals<ACPSessionInternals>(session);
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
copilotAllowAllConfigOption("on"),
],
});
expect(events).toMatchObject([
{
type: "mode_changed",
provider: "copilot",
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
availableModes: expect.arrayContaining([
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
]),
},
]);
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
});
describe("deriveModelDefinitionsFromACP", () => {

View File

@@ -54,35 +54,36 @@ import {
} from "@agentclientprotocol/sdk";
import type { Logger } from "pino";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallDetail,
ToolCallTimelineItem,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModesOptions,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
createProviderEnvSpec,
@@ -226,7 +227,13 @@ interface ACPAgentClientOptions {
defaultModes?: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -245,7 +252,13 @@ interface ACPAgentSessionOptions {
defaultModes: AgentMode[];
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
beforeModeWriter?: (context: ACPProviderModeWriterContext) => Promise<ACPBeforeModeWriteResult>;
thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -253,6 +266,7 @@ interface ACPAgentSessionOptions {
) => Promise<void>;
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
@@ -337,6 +351,26 @@ interface ACPModelSelection {
hasAvailableModels: boolean;
}
export interface ACPProviderModeWriterContext {
connection: ClientSideConnection;
sessionId: string;
requestedModeId: string;
currentModeId: string | null;
selection: ACPModeSelection;
configOptions: SessionConfigOption[];
logger: Logger;
}
export interface ACPProviderModeWriteResult {
handled: boolean;
currentModeId?: string;
configOptions?: SessionConfigOption[];
}
export interface ACPBeforeModeWriteResult {
configOptions?: SessionConfigOption[];
}
export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined {
if (!usage) {
return undefined;
@@ -465,7 +499,17 @@ export class ACPAgentClient implements AgentClient {
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
@@ -477,13 +521,20 @@ export class ACPAgentClient implements AgentClient {
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
this.logger = options.logger.child({ module: "agent", provider: options.provider });
this.logger = options.logger.child({
module: "agent",
provider: options.provider,
});
this.runtimeSettings = options.runtimeSettings;
this.defaultCommand = options.defaultCommand;
this.defaultModes = options.defaultModes ?? [];
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
@@ -504,9 +555,14 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -545,10 +601,15 @@ export class ACPAgentClient implements AgentClient {
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
@@ -747,7 +808,16 @@ export class ACPAgentClient implements AgentClient {
}
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
const transformed = this.sessionResponseTransformer
? this.sessionResponseTransformer(response)
: response;
if (!this.configOptionsTransformer || !transformed.configOptions) {
return transformed;
}
return {
...transformed,
configOptions: this.configOptionsTransformer(transformed.configOptions),
};
}
}
@@ -763,12 +833,23 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly sessionResponseTransformer?: (
response: SessionStateResponse,
) => SessionStateResponse;
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPProviderModeWriteResult>;
private readonly beforeModeWriter?: (
context: ACPProviderModeWriterContext,
) => Promise<ACPBeforeModeWriteResult>;
private readonly thinkingOptionWriter?: (
connection: ClientSideConnection,
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
private readonly agentId?: string;
private readonly launchEnv?: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
@@ -814,9 +895,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.defaultModes = options.defaultModes;
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
this.beforeModeWriter = options.beforeModeWriter;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.availableModes = options.defaultModes;
this.agentId = options.agentId;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
this.config = { ...config, provider: options.provider };
@@ -1068,6 +1154,25 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
const context = this.createProviderModeWriterContext(modeId, selection);
const providerResult = this.providerModeWriter
? await this.providerModeWriter(context)
: { handled: false };
if (providerResult.handled) {
this.currentMode = providerResult.currentModeId ?? modeId;
if (providerResult.configOptions) {
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
}
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
currentModeId: this.currentMode,
availableModes: [...this.availableModes],
});
return;
}
if (selection.hasAvailableModes) {
if (!selection.availableMode) {
this.warnInvalidSelection(
@@ -1078,7 +1183,32 @@ export class ACPAgentSession implements AgentSession, ACPClient {
);
return;
}
} else {
const modeOption = selection.configOption;
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
}
if (this.beforeModeWriter) {
const beforeResult = await this.beforeModeWriter(context);
if (beforeResult?.configOptions) {
this.configOptions = this.transformConfigOptions(beforeResult.configOptions);
}
}
if (selection.hasAvailableModes) {
await this.connection.setSessionMode({ sessionId: this.sessionId, modeId });
this.currentMode = modeId;
this.pushEvent({
@@ -1094,17 +1224,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!modeOption) {
throw new Error(`${this.provider} does not expose ACP mode switching`);
}
if (!selection.configChoice) {
this.warnInvalidSelection(
modeId,
`is not valid ${this.provider} mode config option. Available options: ${flattenSelectOptions(
modeOption.options,
)
.map((option) => option.value)
.join(", ")}`,
);
return;
}
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
@@ -1127,6 +1246,24 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
}
private createProviderModeWriterContext(
requestedModeId: string,
selection: ACPModeSelection,
): ACPProviderModeWriterContext {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
return {
connection: this.connection,
sessionId: this.sessionId,
requestedModeId,
currentModeId: this.currentMode,
selection,
configOptions: this.configOptions,
logger: this.logger,
};
}
async setModel(modelId: string | null): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
@@ -1281,9 +1418,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: string;
label: string;
}): string {
this.configOptions = response.configOptions;
this.configOptions = this.transformConfigOptions(response.configOptions);
const responseOption = findSelectConfigOption({
configOptions: response.configOptions,
configOptions: this.configOptions,
category,
id: configId,
});
@@ -1409,7 +1546,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
// Match Zed acp.rs:3189-3220 — pure pass-through. Accepted UX regression: Copilot Autopilot will now prompt the user for every tool request.
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through.
const requestId = randomUUID();
let toolSnapshot =
this.toolCalls.get(params.toolCall.toolCallId) ??
@@ -1439,11 +1576,31 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async sessionUpdate(params: SessionNotification): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: params.sessionId,
rawEvent: params,
},
"provider.acp.raw_event",
);
if (params.sessionId !== this.sessionId) {
return;
}
const events = this.translateSessionUpdate(params.update);
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: this.activeForegroundTurnId ?? undefined,
rawEvent: params,
events,
},
"provider.acp.parsed_event",
);
if (this.replayingHistory) {
for (const event of events) {
if (event.type === "timeline") {
@@ -1622,7 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
? this.sessionResponseTransformer(response)
: response;
this.configOptions = transformed.configOptions ?? [];
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -1635,6 +1792,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
}
private transformConfigOptions(configOptions: SessionConfigOption[]): SessionConfigOption[] {
return this.configOptionsTransformer
? this.configOptionsTransformer(configOptions)
: configOptions;
}
private transformModeId(modeId: string): string | null {
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
}
private async applyConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
@@ -1772,11 +1939,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
this.currentMode = update.currentModeId;
this.currentMode = this.transformModeId(update.currentModeId);
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = update.configOptions;
this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
@@ -1864,6 +2031,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private pushEvent(event: AgentStreamEvent): void {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: this.sessionId,
turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined,
event,
},
"provider.acp.event_emit",
);
for (const subscriber of this.subscribers) {
subscriber(event);
}

View File

@@ -14,7 +14,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import {
getAgentStreamEventTurnId,
type AgentSession,
type AgentStreamEvent,
} from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js";
@@ -37,15 +41,14 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
);
}
// turnId is optional on AgentStreamEvent — this narrows to events where it's present.
type EventWithTurnId = AgentStreamEvent & { turnId: string };
function hasTurnId(event: AgentStreamEvent): event is EventWithTurnId {
return "turnId" in event && typeof (event as Record<string, unknown>).turnId === "string";
return getAgentStreamEventTurnId(event) !== undefined;
}
function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamEvent[] {
return events.filter((e) => hasTurnId(e) && e.turnId === turnId);
return events.filter((e) => getAgentStreamEventTurnId(e) === turnId);
}
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
@@ -157,7 +160,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// Invariant 2: Every turn_started has exactly one matching terminal
const turnStartedIds = events
.filter((e) => e.type === "turn_started" && hasTurnId(e))
.map((e) => (e as EventWithTurnId).turnId);
.map((e) => e.turnId);
for (const turnId of turnStartedIds) {
const terminals = eventsForTurn(events, turnId).filter(isTerminalEvent);
@@ -277,7 +280,7 @@ test("Test 3: Lifecycle doesn't get stuck in running", async () => {
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
expect(ts.turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
@@ -313,8 +316,9 @@ test("Test 4: Autonomous run", async () => {
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
(e): e is EventWithTurnId =>
e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
);
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;

View File

@@ -42,34 +42,35 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../prov
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPermissionUpdate,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListModelsOptions,
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
type AgentPermissionUpdate,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig,
type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import {
createProviderEnv,
@@ -250,6 +251,7 @@ interface ClaudeAgentSessionOptions {
defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
agentId?: string;
launchEnv?: Record<string, string>;
persistSession?: boolean;
logger: Logger;
@@ -1152,8 +1154,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
};
}
const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1";
export class ClaudeAgentClient implements AgentClient {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1181,6 +1181,7 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
persistSession: options?.persistSession,
logger: this.logger,
@@ -1209,6 +1210,7 @@ export class ClaudeAgentClient implements AgentClient {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
@@ -1477,6 +1479,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean;
@@ -1526,10 +1529,11 @@ class ClaudeAgentSession implements AgentSession {
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
this.launchEnv = options.launchEnv;
this.agentId = options.agentId;
this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession;
this.logger = options.logger;
this.logger = options.logger.child({ agentId: this.agentId });
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
const handle = options.handle;
@@ -1869,13 +1873,16 @@ class ClaudeAgentSession implements AgentSession {
async close(): Promise<void> {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
},
"Claude session close: start",
"provider.claude.session_close.start",
);
this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed"));
@@ -1910,8 +1917,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
this.logger.trace(
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
"Claude session close: completed",
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnState: this.turnState,
},
"provider.claude.session_close.complete",
);
}
@@ -2188,16 +2200,41 @@ class ClaudeAgentSession implements AgentSession {
label: string,
): Promise<void> {
if (!promise) {
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.skip",
);
return;
}
const startedAt = Date.now();
this.logger.trace({ label }, "Claude query operation wait start");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
},
"provider.claude.query_operation.start",
);
try {
await withTimeout(promise, 3_000, "timeout");
this.logger.trace(
{ label, durationMs: Date.now() - startedAt },
"Claude query operation settled",
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
label,
durationMs: Date.now() - startedAt,
},
"provider.claude.query_operation.settled",
);
} catch (error) {
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
@@ -2593,7 +2630,16 @@ class ClaudeAgentSession implements AgentSession {
}
const pump = this.runQueryPump().catch((error) => {
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.exit_unexpected",
);
});
this.queryPumpPromise = pump;
@@ -2609,24 +2655,34 @@ class ClaudeAgentSession implements AgentSession {
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
if (!claudeDebug) {
return;
}
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
messageType: message.type,
messageSubtype: "subtype" in message ? message.subtype : undefined,
messageUuid: "uuid" in message ? message.uuid : undefined,
rawEvent: message,
},
"Claude query pump: raw SDK message",
"provider.claude.raw_event",
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
@@ -2739,16 +2795,18 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
if (claudeDebug) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
turnId,
},
"Claude query pump: SDK message",
);
}
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: turnId ?? undefined,
messageType: message.type,
identifiers,
rawEvent: message,
},
"provider.claude.parsed_event",
);
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
@@ -2816,7 +2874,6 @@ class ClaudeAgentSession implements AgentSession {
this.logger.warn(
{
claudeSessionId: this.claudeSessionId,
error: staleResumeError,
},
"Claude resumed session no longer exists; invalidating persisted session",
@@ -2846,7 +2903,15 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace("interruptActiveTurn: no query to interrupt");
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
},
"provider.claude.interrupt.no_query",
);
return;
}
this.pendingInterruptAbort = true;
@@ -3455,6 +3520,16 @@ class ClaudeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.claude.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -24,6 +24,7 @@ import {
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
name: string;
@@ -34,6 +35,7 @@ interface CollaborationModeRecord {
}
interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>;
@@ -102,6 +104,100 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")");
}
type CapturedFakeCodexRecord = Record<string, unknown>;
async function runCustomCodexProviderTurn(
providerId: string,
baseUrl: string,
): Promise<CapturedFakeCodexRecord[]> {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-"));
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
writeFileSync(
fakeAppServerPath,
`
const fs = require("node:fs");
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
let buffer = "";
fs.appendFileSync(capturePath, JSON.stringify({
kind: "env",
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
}) + "\\n");
function record(method, params) {
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n");
}
function resultFor(method) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") return { data: [] };
if (method === "config/read") return { config: {} };
if (method === "getUserSavedConfig") return { config: {} };
if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] };
if (method === "thread/start") return { thread: { id: "thread-1" } };
if (method === "turn/start") return {};
return {};
}
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
const message = JSON.parse(line);
record(message.method, message.params);
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
}
});
`,
);
const registry = buildProviderRegistry(createTestLogger(), {
providerOverrides: {
[providerId]: {
extends: "codex",
label: "Custom Codex",
command: [process.execPath, fakeAppServerPath],
env: {
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: baseUrl,
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
},
},
},
});
const session = await registry[providerId].createClient(createTestLogger()).createSession({
provider: providerId,
cwd: "/workspace/project",
modeId: "auto",
model: "custom-model",
});
try {
await session.startTurn("use the custom endpoint");
return readFileSync(capturedRequestsPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord);
} finally {
await session.close();
rmSync(tempDir, { recursive: true, force: true });
}
}
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
const threadStart = records.find((record) => record.method === "thread/start");
const params = threadStart?.params as Record<string, unknown> | undefined;
return params?.config;
}
describe("Codex app-server provider", () => {
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
@@ -242,6 +338,121 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
"https://custom-relay.example.com",
);
expect(capturedRequests[0]).toEqual({
kind: "env",
OPENAI_API_KEY: "sk-custom",
OPENAI_BASE_URL: "https://custom-relay.example.com",
});
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-iisb",
model_providers: {
"codex-iisb": {
name: "Custom Codex",
base_url: "https://custom-relay.example.com/v1",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
wire_api: "responses",
},
},
});
});
test("does not append v1 twice for custom Codex provider base URLs", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-custom",
"https://custom-relay.example.com/v1/",
);
expect(capturedThreadStartConfig(capturedRequests)).toEqual({
model_provider: "codex-custom",
model_providers: {
"codex-custom": expect.objectContaining({
base_url: "https://custom-relay.example.com/v1",
}),
},
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
return { thread: { id: "replacement-empty-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
getUserSavedConfig: () => {
threadRequests.push("getUserSavedConfig");
return { config: {} };
},
"config/read": () => {
threadRequests.push("config/read");
return { config: {} };
},
"model/list": () => {
threadRequests.push("model/list");
return {
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
};
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
Promise.resolve(false);
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const outcome = await Promise.race([
provider
.resumeSession({
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
})
.then(
() => "resolved" as const,
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no rollout found for thread id archived-thread-id",
);
return "rejected" as const;
},
),
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
]);
if (outcome === "timed_out") {
appServer.child.kill("SIGTERM");
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app");
@@ -998,6 +1209,37 @@ describe("Codex app-server provider", () => {
]);
});
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
const requests: Array<{ method: string; params: unknown }> = [];
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: [] };
}
if (method === "thread/resume") {
throw new Error("no rollout found for thread id archived-thread-id");
}
if (method === "thread/start") {
return { thread: { id: "replacement-empty-thread-id" } };
}
return {};
}),
};
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
"no rollout found for thread id archived-thread-id",
);
expect(session.currentThreadId).toBe("archived-thread-id");
expect(requests).toEqual([
{ method: "thread/loaded/list", params: {} },
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
]);
});
test("appends blank-line spacing to /goal status messages", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession({}, { goalsEnabled: true });

View File

@@ -1,32 +1,33 @@
import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
McpServerConfig,
AgentPersistenceHandle,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type McpServerConfig,
type AgentPersistenceHandle,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type ToolCallTimelineItem,
type AgentUsage,
type ListModelsOptions,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import type { Logger } from "pino";
import { homedir } from "node:os";
@@ -55,7 +56,10 @@ import { findExecutable, isCommandAvailable } from "../../../utils/executable.js
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
CodexAppServerClient,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import {
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
@@ -174,9 +178,16 @@ interface CodexAppServerClientLike {
interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
label: string;
extends: string;
};
customCodexConfig?: Record<string, unknown> | null;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
getTraceContext: () => CodexAppServerTraceContext,
) => CodexAppServerClientLike;
}
@@ -2525,6 +2536,50 @@ function buildCodexAppServerInitializeParams(): {
};
}
function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutTrailingSlashes = trimmed.replace(/\/+$/u, "");
if (withoutTrailingSlashes.endsWith("/v1")) {
return withoutTrailingSlashes;
}
return `${withoutTrailingSlashes}/v1`;
}
function buildCodexCustomProviderConfig(
runtimeSettings: ProviderRuntimeSettings | undefined,
customProvider: CodexAppServerAgentDeps["customProvider"],
): Record<string, unknown> | null {
if (customProvider?.extends !== CODEX_PROVIDER) {
return null;
}
const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL;
if (typeof baseUrl !== "string") {
return null;
}
const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return null;
}
const providerConfig: Record<string, unknown> = {
name: customProvider.label,
base_url: normalizedBaseUrl,
wire_api: "responses",
};
if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) {
providerConfig.env_key = "OPENAI_API_KEY";
providerConfig.requires_openai_auth = false;
}
return {
model_provider: customProvider.id,
model_providers: {
[customProvider.id]: providerConfig,
},
};
}
interface CodexSubAgentCallState {
callId: string;
toolCall: ToolCallTimelineItem;
@@ -2603,8 +2658,13 @@ class CodexAppServerAgentSession implements AgentSession {
private readonly deps: CodexAppServerAgentDeps = {},
private readonly ephemeral: boolean = false,
private readonly goalsEnabled: boolean = false,
private readonly agentId?: string,
) {
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
this.logger = logger.child({
module: "agent",
provider: CODEX_PROVIDER,
agentId: this.agentId,
});
if (config.modeId === undefined) {
throw new Error("Codex agent requires modeId to be specified");
}
@@ -2641,7 +2701,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> {
if (this.connected) return;
const child = await this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger);
this.client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
@@ -2659,6 +2719,14 @@ class CodexAppServerAgentSession implements AgentSession {
this.connected = true;
}
private traceContext(): CodexAppServerTraceContext {
return {
agentId: this.agentId,
sessionId: this.currentThreadId ?? undefined,
turnId: this.activeForegroundTurnId ?? undefined,
};
}
private async loadCollaborationModes(): Promise<void> {
if (!this.client) return;
try {
@@ -2679,7 +2747,16 @@ class CodexAppServerAgentSession implements AgentSession {
};
});
} catch (error) {
this.logger.trace({ error }, "Failed to load collaboration modes");
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.collaboration_modes_failed",
);
this.collaborationModes = [];
}
this.refreshResolvedCollaborationMode();
@@ -2711,7 +2788,16 @@ class CodexAppServerAgentSession implements AgentSession {
}
this.cachedSkills = skills;
} catch (error) {
this.logger.trace({ error }, "Failed to load skills list");
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.skills_failed",
);
this.cachedSkills = [];
}
}
@@ -2901,9 +2987,10 @@ class CodexAppServerAgentSession implements AgentSession {
}
await this.client.request("thread/resume", params);
} catch (error) {
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
this.currentThreadId = null;
await this.ensureThread();
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
}
}
@@ -3587,6 +3674,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.extra?.codex) {
Object.assign(innerConfig, this.config.extra.codex);
}
if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig);
}
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
}
@@ -3604,6 +3694,16 @@ class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.codex.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);
@@ -3619,6 +3719,7 @@ class CodexAppServerAgentSession implements AgentSession {
private handleNotification(method: string, params: unknown): void {
const parsed = CodexNotificationSchema.parse({ method, params });
this.traceParsedNotification(method, params, parsed);
switch (parsed.kind) {
case "thread_started":
this.handleThreadStartedNotification(parsed);
@@ -3675,6 +3776,25 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private traceParsedNotification(
method: string,
params: unknown,
parsed: z.infer<typeof CodexNotificationSchema>,
): void {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
parsed,
},
"provider.codex.parsed_event",
);
}
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
if (!threadId || threadId === this.currentThreadId) {
return null;
@@ -4267,7 +4387,17 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
this.warnedUnknownNotificationMethods.add(method);
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
},
"provider.codex.event_unhandled",
);
}
private warnInvalidNotificationPayload(method: string, params: unknown): void {
@@ -4528,6 +4658,16 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
};
}
private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => {
@@ -4535,7 +4675,14 @@ export class CodexAppServerAgentClient implements AgentClient {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
this.logger.trace({ versionOutput, enabled }, "Resolved codex goals feature gate");
this.logger.trace(
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.goals_resolved",
);
return enabled;
} catch (error) {
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
@@ -4548,7 +4695,7 @@ export class CodexAppServerAgentClient implements AgentClient {
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean },
options?: { goalsEnabled?: boolean; agentId?: string },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const args = [...launchPrefix.args, "app-server"];
@@ -4557,10 +4704,12 @@ export class CodexAppServerAgentClient implements AgentClient {
}
this.logger.trace(
{
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options?.goalsEnabled === true,
},
"Spawning Codex app server",
"provider.codex.spawn",
);
const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32",
@@ -4592,10 +4741,12 @@ export class CodexAppServerAgentClient implements AgentClient {
sessionConfig,
null,
this.logger,
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.deps,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
options?.persistSession === false,
goalsEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
@@ -4618,10 +4769,12 @@ export class CodexAppServerAgentClient implements AgentClient {
merged,
handle,
this.logger,
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
this.deps,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
false,
goalsEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
@@ -4632,7 +4785,7 @@ export class CodexAppServerAgentClient implements AgentClient {
): Promise<PersistedAgentDescriptor[]> {
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger) ??
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger);
try {

View File

@@ -35,6 +35,12 @@ interface PendingRequest {
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
export interface CodexAppServerTraceContext {
agentId?: string;
sessionId?: string;
turnId?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -54,6 +60,24 @@ function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
return typeof msg.method === "string" && msg.id === undefined;
}
function readProviderSessionId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
return typeof params.threadId === "string" ? params.threadId : undefined;
}
function readProviderTurnId(params: unknown): string | undefined {
if (!isRecord(params)) {
return undefined;
}
if (typeof params.turnId === "string") {
return params.turnId;
}
const turn = params.turn;
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
export class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
@@ -66,6 +90,7 @@ export class CodexAppServerClient {
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
this.rl = readline.createInterface({ input: child.stdout });
this.rl.on("line", (line) => {
@@ -224,6 +249,19 @@ export class CodexAppServerClient {
}
if (isJsonRpcNotification(raw)) {
const traceContext = this.getTraceContext();
this.logger.trace(
{
provider: "codex",
agentId: traceContext.agentId,
sessionId: traceContext.sessionId ?? readProviderSessionId(raw.params),
turnId: traceContext.turnId ?? readProviderTurnId(raw.params),
method: raw.method,
params: raw.params,
rawEvent: raw,
},
"provider.codex.raw_event",
);
this.notificationHandler?.(raw.method, raw.params);
}
}

View File

@@ -64,6 +64,39 @@ describe("codex tool-call mapper", () => {
});
});
it("unwraps pwsh wrapper strings for commandExecution on Windows", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-pwsh-string",
status: "running",
command:
'"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"',
cwd: "C:\\repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "C:\\repo",
});
});
it("unwraps cmd wrapper arrays for commandExecution on Windows", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-cmd-array",
status: "running",
command: ["cmd.exe", "/c", "echo hello"],
cwd: "C:\\repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "C:\\repo",
});
});
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",

View File

@@ -200,21 +200,48 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (!wrapperMatch) {
const unixWrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (unixWrapperMatch) {
const candidate = unixWrapperMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
const windowsWrapperMatch = trimmed.match(
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
);
if (!windowsWrapperMatch) {
return trimmed;
}
const candidate = wrapperMatch[1]?.trim() ?? "";
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? "";
if (!wrappedCommand) {
return trimmed;
}
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
if (!commandMatch) {
return trimmed;
}
const candidate = commandMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
return stripMatchingEdgeQuotes(candidate);
}
function stripMatchingEdgeQuotes(value: string): string {
if (
(candidate.startsWith('"') && candidate.endsWith('"')) ||
(candidate.startsWith("'") && candidate.endsWith("'"))
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return candidate.slice(1, -1);
return value.slice(1, -1);
}
return candidate;
return value;
}
function isWindowsShellCommand(command: string): boolean {
const normalized = command.replace(/^["']|["']$/g, "");
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
}
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
@@ -236,6 +263,14 @@ function normalizeCommandExecutionCommand(value: unknown): string | undefined {
const unwrapped = parts[2]?.trim();
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
}
if (
parts.length >= 3 &&
isWindowsShellCommand(parts[0] ?? "") &&
/^(-command|-c|\/c)$/i.test(parts[1] ?? "")
) {
const unwrapped = parts.slice(2).join(" ").trim();
return unwrapped.length > 0 ? stripMatchingEdgeQuotes(unwrapped) : undefined;
}
return parts.join(" ");
}

View File

@@ -1,10 +1,17 @@
import type { Logger } from "pino";
import { homedir } from "node:os";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { ACPAgentClient } from "./acp-agent.js";
import {
ACPAgentClient,
type ACPBeforeModeWriteResult,
type ACPProviderModeWriteResult,
type ACPProviderModeWriterContext,
type SessionStateResponse,
} from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -22,21 +29,31 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true,
};
const COPILOT_MODES: AgentMode[] = [
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
const COPILOT_AUTOPILOT_MODE_ID =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
export const COPILOT_ALLOW_ALL_MODE_ID = "allow-all";
const COPILOT_ALLOW_ALL_CONFIG_ID = "allow_all";
const COPILOT_ALLOW_ALL_ON = "on";
const COPILOT_ALLOW_ALL_OFF = "off";
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
export const COPILOT_MODES: AgentMode[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
id: COPILOT_AGENT_MODE_ID,
label: "Agent",
description: "Default agent mode for conversational interactions",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
id: COPILOT_PLAN_MODE_ID,
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
id: COPILOT_ALLOW_ALL_MODE_ID,
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
},
];
@@ -53,6 +70,11 @@ export class CopilotACPAgentClient extends ACPAgentClient {
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
capabilities: COPILOT_CAPABILITIES,
});
}
@@ -113,3 +135,126 @@ export class CopilotACPAgentClient extends ACPAgentClient {
}
}
}
export function transformCopilotSessionResponse(
response: SessionStateResponse,
): SessionStateResponse {
if (!response.modes) {
return response;
}
const allowAllEnabled = isCopilotAllowAllEnabled(response.configOptions ?? []);
return {
...response,
modes: {
...response.modes,
availableModes: response.modes.availableModes
?.filter(
(mode) => mode.id !== COPILOT_AUTOPILOT_MODE_ID && mode.id !== COPILOT_ALLOW_ALL_MODE_ID,
)
.concat({
id: COPILOT_ALLOW_ALL_MODE_ID,
name: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
}),
currentModeId: allowAllEnabled
? COPILOT_ALLOW_ALL_MODE_ID
: (transformCopilotModeId(response.modes.currentModeId ?? COPILOT_AGENT_MODE_ID) ??
COPILOT_AGENT_MODE_ID),
},
};
}
export function transformCopilotConfigOptions(
configOptions: SessionConfigOption[],
): SessionConfigOption[] {
const allowAllEnabled = isCopilotAllowAllEnabled(configOptions);
return configOptions.map((option) => {
if (option.type !== "select" || option.category !== "mode") {
return option;
}
// Trust Copilot's allow_all config value as the source of truth when it changes in-process.
const options = flattenCopilotModeOptions(option.options)
.filter(
(choice) =>
choice.value !== COPILOT_AUTOPILOT_MODE_ID && choice.value !== COPILOT_ALLOW_ALL_MODE_ID,
)
.concat({
value: COPILOT_ALLOW_ALL_MODE_ID,
name: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
});
return {
...option,
currentValue: allowAllEnabled
? COPILOT_ALLOW_ALL_MODE_ID
: (transformCopilotModeId(option.currentValue) ?? COPILOT_AGENT_MODE_ID),
options,
};
});
}
function flattenCopilotModeOptions(
options: SelectConfigOption["options"],
): Array<{ value: string; name: string; description?: string | null }> {
const flattened: Array<{ value: string; name: string; description?: string | null }> = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
continue;
}
flattened.push(...option.options);
}
return flattened;
}
export function transformCopilotModeId(modeId: string): string | null {
return modeId === COPILOT_AUTOPILOT_MODE_ID ? COPILOT_AGENT_MODE_ID : modeId;
}
export async function writeCopilotProviderMode(
context: ACPProviderModeWriterContext,
): Promise<ACPProviderModeWriteResult> {
// COMPAT(copilotAutopilotMode): added in v0.1.75, remove after 2026-11-12 once old clients no longer send Copilot's old ACP autopilot mode ID.
const requestsAllowAll =
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID ||
context.requestedModeId === COPILOT_AUTOPILOT_MODE_ID;
if (!requestsAllowAll) {
return { handled: false };
}
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
value: COPILOT_ALLOW_ALL_ON,
});
return {
handled: true,
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
configOptions: response.configOptions,
};
}
export async function beforeCopilotModeWriter(
context: ACPProviderModeWriterContext,
): Promise<ACPBeforeModeWriteResult> {
if (
context.currentModeId !== COPILOT_ALLOW_ALL_MODE_ID ||
context.requestedModeId === COPILOT_ALLOW_ALL_MODE_ID
) {
return {};
}
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId,
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
value: COPILOT_ALLOW_ALL_OFF,
});
return { configOptions: response.configOptions };
}
function isCopilotAllowAllEnabled(configOptions: SessionConfigOption[]): boolean {
return configOptions.some(
(option) =>
option.type === "select" &&
option.id === COPILOT_ALLOW_ALL_CONFIG_ID &&
option.currentValue === COPILOT_ALLOW_ALL_ON,
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
globalEvent: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
@@ -99,6 +100,12 @@ export class TestOpenCodeClient {
return { stream: this.eventStream };
},
},
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),

View File

@@ -30,29 +30,30 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
import { z } from "zod";
import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
ListModesOptions,
ListModelsOptions,
ToolCallDetail,
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type ListModesOptions,
type ListModelsOptions,
type ToolCallDetail,
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
@@ -738,21 +739,6 @@ function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersis
return {};
}
function getStreamEventTurnId(event: AgentStreamEvent): string | undefined {
switch (event.type) {
case "turn_started":
case "turn_completed":
case "turn_failed":
case "turn_canceled":
case "timeline":
case "permission_requested":
case "permission_resolved":
return event.turnId;
default:
return undefined;
}
}
function isPiRequestAbortError(error: unknown): boolean {
if (error instanceof Error && error.name === "AbortError") {
return true;
@@ -1045,7 +1031,7 @@ export class PiDirectAgentSession implements AgentSession {
return;
}
const eventTurnId = getStreamEventTurnId(event);
const eventTurnId = getAgentStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}

View File

@@ -1,9 +1,10 @@
import type {
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentStreamEvent,
AgentTimelineItem,
import {
getAgentStreamEventTurnId,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentStreamEvent,
type AgentTimelineItem,
} from "../agent-sdk-types.js";
export type ProviderFinalTextReducer = (params: {
@@ -44,7 +45,7 @@ export async function runProviderTurn({
if (settled) {
return;
}
const eventTurnId = "turnId" in event ? event.turnId : undefined;
const eventTurnId = getAgentStreamEventTurnId(event);
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}

View File

@@ -1,8 +1,9 @@
import type {
AgentPromptInput,
AgentRunOptions,
AgentSession,
AgentStreamEvent,
import {
getAgentStreamEventTurnId,
type AgentPromptInput,
type AgentRunOptions,
type AgentSession,
type AgentStreamEvent,
} from "../../agent-sdk-types.js";
function isTerminalEvent(event: AgentStreamEvent): boolean {
@@ -29,7 +30,7 @@ export async function* streamSession(
};
const matchesTurn = (event: AgentStreamEvent): boolean => {
const eventTurnId = (event as { turnId?: string }).turnId;
const eventTurnId = getAgentStreamEventTurnId(event);
return turnId == null || eventTurnId == null || eventTurnId === turnId;
};

View File

@@ -3,6 +3,8 @@ import pino from "pino";
import { EventEmitter } from "node:events";
import { STTManager } from "./stt-manager.js";
import { PersistedConfigSchema } from "../persisted-config.js";
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
import type {
SpeechToTextProvider,
StreamingTranscriptionSession,
@@ -16,9 +18,11 @@ type StreamingOnHandler = Parameters<StreamingOn>[1];
class FakeStt implements SpeechToTextProvider {
public readonly id = "fake";
public lastLanguage?: string;
constructor(private readonly result: TranscriptionResult) {}
createSession(_params: SessionParams): StreamingTranscriptionSession {
createSession(params: SessionParams): StreamingTranscriptionSession {
this.lastLanguage = params.language;
const emitter = new EventEmitter();
const result = this.result;
let segmentId = "seg-1";
@@ -92,6 +96,116 @@ class SequencedFakeStt implements SpeechToTextProvider {
}
describe("STTManager", () => {
function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string {
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: params.env ?? ({} as NodeJS.ProcessEnv),
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
});
return result.speech.sttLanguages.voice;
}
async function transcribeWithResolvedVoiceLanguage(params: {
env?: NodeJS.ProcessEnv;
persisted?: unknown;
}): Promise<FakeStt> {
const fakeStt = new FakeStt({ text: "hi", isLowConfidence: false });
const manager = new STTManager("s1", pino({ level: "silent" }), fakeStt, {
language: resolveVoiceLanguage(params),
});
await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000");
return fakeStt;
}
it("defaults to English when no voice language config is set", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({});
expect(fakeStt.lastLanguage).toBe("en");
});
it("uses PASEO_VOICE_LANGUAGE over PASEO_DICTATION_LANGUAGE", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_VOICE_LANGUAGE: "pt",
PASEO_DICTATION_LANGUAGE: "es",
} as NodeJS.ProcessEnv,
});
expect(fakeStt.lastLanguage).toBe("pt");
});
it("uses PASEO_DICTATION_LANGUAGE when PASEO_VOICE_LANGUAGE is unset", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_DICTATION_LANGUAGE: "pt",
} as NodeJS.ProcessEnv,
});
expect(fakeStt.lastLanguage).toBe("pt");
});
it("treats empty voice language env vars as unset", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_VOICE_LANGUAGE: "",
PASEO_DICTATION_LANGUAGE: " ",
} as NodeJS.ProcessEnv,
});
expect(fakeStt.lastLanguage).toBe("en");
});
it("uses settings voice STT language when no env var is set", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
persisted: {
features: {
voiceMode: {
stt: {
language: "fr",
},
},
},
},
});
expect(fakeStt.lastLanguage).toBe("fr");
});
it("uses env voice language over settings voice STT language", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_VOICE_LANGUAGE: "pt",
} as NodeJS.ProcessEnv,
persisted: {
features: {
voiceMode: {
stt: {
language: "fr",
},
},
},
},
});
expect(fakeStt.lastLanguage).toBe("pt");
});
it("falls back to settings dictation STT language when voice language is unset", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
persisted: {
features: {
dictation: {
stt: {
language: "es",
},
},
},
},
});
expect(fakeStt.lastLanguage).toBe("es");
});
it("returns empty text for low-confidence transcriptions", async () => {
const manager = new STTManager(
"s1",

View File

@@ -111,6 +111,10 @@ export interface SessionTranscriptionResult extends TranscriptionResult {
format: string;
}
export interface STTManagerOptions {
language?: string;
}
/**
* Per-session STT manager
* Handles speech-to-text transcription
@@ -119,15 +123,18 @@ export class STTManager {
private readonly sessionId: string;
private readonly logger: pino.Logger;
private readonly resolveStt: () => SpeechToTextProvider | null;
private readonly language: string;
constructor(
sessionId: string,
logger: pino.Logger,
stt: Resolvable<SpeechToTextProvider | null>,
options?: STTManagerOptions,
) {
this.sessionId = sessionId;
this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId });
this.resolveStt = toResolver(stt);
this.language = options?.language ?? "en";
}
public getProvider(): SpeechToTextProvider | null {
@@ -171,7 +178,7 @@ export class STTManager {
const session = stt.createSession({
logger: this.logger.child({ component: "stt-session" }),
language: "en",
language: this.language,
});
const pcmForModel = preparePcmForModel(audio, format, session.requiredSampleRate);

View File

@@ -195,8 +195,14 @@ function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
export interface PaseoSpeechSttLanguages {
dictation: string;
voice: string;
}
export interface PaseoSpeechConfig {
providers: RequestedSpeechProviders;
sttLanguages?: PaseoSpeechSttLanguages;
local?: PaseoLocalSpeechConfig;
}

View File

@@ -151,7 +151,7 @@ function waitForSignal<T>(
class NonPersistentReloadSession implements AgentSession {
readonly provider = "claude" as const;
readonly id = null;
readonly id: string | null;
readonly capabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
@@ -161,7 +161,12 @@ class NonPersistentReloadSession implements AgentSession {
supportsToolInvocations: false,
} as const;
constructor(private readonly onClose: () => void) {}
constructor(
private readonly onClose: () => void,
id: string | null = null,
) {
this.id = id;
}
async run(): Promise<AgentRunResult> {
return {
@@ -259,6 +264,37 @@ class NonPersistentReloadClient implements AgentClient {
}
}
class FailingResumeSession extends NonPersistentReloadSession {
constructor(onClose: () => void) {
super(onClose, "failing-resume-session");
}
describePersistence(): AgentPersistenceHandle | null {
return {
provider: "claude",
sessionId: this.id,
metadata: { cwd: process.cwd() },
};
}
}
class FailingResumeClient extends NonPersistentReloadClient {
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
this.createSessionCalls += 1;
return new FailingResumeSession(() => {
this.closeCalls += 1;
});
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeSessionCalls += 1;
throw new Error("resume exploded");
}
}
function resolveSpeechConfig() {
if (hasLocalSpeech) {
return {
@@ -482,6 +518,36 @@ test("refresh_agent rebuilds a live agent even when it has no persistence handle
}
});
test("refresh_agent rejects when persisted session resume fails", async () => {
const cwd = tmpCwd();
const client = new FailingResumeClient();
const localCtx = await createDaemonTestContext({
agentClients: {
claude: client,
},
});
try {
const created = await localCtx.client.createAgent({
config: {
provider: "claude",
cwd,
},
});
await localCtx.client.archiveAgent(created.id);
await expect(localCtx.client.refreshAgent(created.id)).rejects.toMatchObject({
name: "DaemonRpcError",
code: "agent_refresh_failed",
requestType: "refresh_agent_request",
});
expect(client.resumeSessionCalls).toBe(1);
} finally {
await localCtx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
});
test("resume_agent auto-unarchives archived agents", async () => {
const cwd = tmpCwd();
try {

View File

@@ -44,7 +44,7 @@ export const agentConfigs = {
provider: "copilot",
model: "claude-haiku-4.5",
modes: {
full: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
full: "allow-all",
ask: "https://agentclientprotocol.com/protocol/session-modes#agent",
},
},

View File

@@ -9,6 +9,8 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
const OPENCODE_REAL_TEST_MODEL = "opencode/big-pickle";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-"));
}
@@ -47,13 +49,13 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
expect(models.models.some((model) => model.id === OPENCODE_REAL_TEST_MODEL)).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode initial prompt wait regression",
model: "opencode/big-pickle",
model: OPENCODE_REAL_TEST_MODEL,
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
});
@@ -83,35 +85,4 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 90_000);
test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode zai fatal retry regression",
model: "zai/glm-5.1",
initialPrompt: "Reply with exactly: GLM_51_OK",
});
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect((finish.error ?? "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 90_000);
});

View File

@@ -3,6 +3,8 @@ import { EventEmitter } from "node:events";
import pino from "pino";
import { DictationStreamManager } from "./dictation-stream-manager.js";
import { PersistedConfigSchema } from "../persisted-config.js";
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
import type {
SpeechToTextProvider,
StreamingTranscriptionSession,
@@ -51,10 +53,12 @@ class FakeRealtimeSession extends EventEmitter implements StreamingTranscription
class FakeSttProvider implements SpeechToTextProvider {
public readonly id = "fake";
public lastLanguage?: string;
constructor(private readonly session: FakeRealtimeSession) {}
createSession(
_params: Parameters<SpeechToTextProvider["createSession"]>[0],
params: Parameters<SpeechToTextProvider["createSession"]>[0],
): StreamingTranscriptionSession {
this.lastLanguage = params.language;
return this.session;
}
}
@@ -123,6 +127,97 @@ describe("DictationStreamManager (finish buffer-too-small tolerance)", () => {
});
describe("DictationStreamManager (provider-agnostic provider)", () => {
function resolveDictationLanguage(params: {
env?: NodeJS.ProcessEnv;
persisted?: unknown;
}): string {
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: params.env ?? ({} as NodeJS.ProcessEnv),
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
});
return result.speech.sttLanguages.dictation;
}
async function startWithResolvedDictationLanguage(params: {
env?: NodeJS.ProcessEnv;
persisted?: unknown;
}): Promise<FakeSttProvider> {
const session = new FakeRealtimeSession();
const sttProvider = new FakeSttProvider(session);
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: () => {},
sessionId: "s1",
stt: sttProvider,
language: resolveDictationLanguage(params),
});
await manager.handleStart("d-lang", "audio/pcm;rate=24000;bits=16");
return sttProvider;
}
it("defaults to English when dictation language config is unset", async () => {
const sttProvider = await startWithResolvedDictationLanguage({});
expect(sttProvider.lastLanguage).toBe("en");
});
it("uses PASEO_DICTATION_LANGUAGE when set", async () => {
const sttProvider = await startWithResolvedDictationLanguage({
env: {
PASEO_DICTATION_LANGUAGE: "pt",
} as NodeJS.ProcessEnv,
});
expect(sttProvider.lastLanguage).toBe("pt");
});
it("treats empty PASEO_DICTATION_LANGUAGE as unset", async () => {
const sttProvider = await startWithResolvedDictationLanguage({
env: {
PASEO_DICTATION_LANGUAGE: " ",
} as NodeJS.ProcessEnv,
});
expect(sttProvider.lastLanguage).toBe("en");
});
it("uses settings dictation STT language when env var is unset", async () => {
const sttProvider = await startWithResolvedDictationLanguage({
persisted: {
features: {
dictation: {
stt: {
language: "fr",
},
},
},
},
});
expect(sttProvider.lastLanguage).toBe("fr");
});
it("uses env dictation language over settings dictation STT language", async () => {
const sttProvider = await startWithResolvedDictationLanguage({
env: {
PASEO_DICTATION_LANGUAGE: "pt",
} as NodeJS.ProcessEnv,
persisted: {
features: {
dictation: {
stt: {
language: "fr",
},
},
},
},
});
expect(sttProvider.lastLanguage).toBe("pt");
});
it("does not require OPENAI_API_KEY", async () => {
const original = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;

View File

@@ -130,6 +130,7 @@ export class DictationStreamManager {
private readonly emit: (msg: DictationStreamOutboundMessage) => void;
private readonly sessionId: string;
private readonly resolveStt: () => SpeechToTextProvider | null;
private readonly language: string;
private readonly finalTimeoutMs: number;
private readonly autoCommitSeconds: number;
private readonly streams = new Map<string, DictationStreamState>();
@@ -139,6 +140,7 @@ export class DictationStreamManager {
emit: (msg: DictationStreamOutboundMessage) => void;
sessionId: string;
stt: Resolvable<SpeechToTextProvider | null>;
language?: string;
finalTimeoutMs?: number;
autoCommitSeconds?: number;
}) {
@@ -146,6 +148,7 @@ export class DictationStreamManager {
this.emit = params.emit;
this.sessionId = params.sessionId;
this.resolveStt = toResolver(params.stt);
this.language = params.language ?? "en";
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
this.autoCommitSeconds =
params.autoCommitSeconds ??
@@ -176,7 +179,7 @@ export class DictationStreamManager {
try {
stt = sttProvider.createSession({
logger: this.logger.child({ dictationId }),
language: "en",
language: this.language,
prompt: transcriptionPrompt,
});
} catch (error) {

View File

@@ -106,6 +106,31 @@ describe("resolveLogConfig", () => {
},
});
});
it("defaults file output to info when log.file is present without a level", () => {
const config: PersistedConfig = {
log: {
console: {
level: "warn",
},
file: {
path: "daemon.log",
},
},
};
expect(resolveLogConfig(config, { paseoHome })).toEqual({
level: "info",
console: {
level: "warn",
format: "json",
},
file: {
level: "info",
path: path.resolve(paseoHome, "daemon.log"),
},
});
});
});
describe("loadConfig logger config", () => {

View File

@@ -43,7 +43,7 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_LEVEL: LogLevel = "info";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const REDACT_PATHS = [
"authorization",

View File

@@ -483,6 +483,26 @@ describe("PersistedConfigSchema voice mode config", () => {
expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local");
});
test("accepts trimmed STT language fields", () => {
const parsed = PersistedConfigSchema.parse({
features: {
dictation: {
stt: {
language: " fr ",
},
},
voiceMode: {
stt: {
language: " de ",
},
},
},
});
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {

View File

@@ -86,6 +86,7 @@ const FeatureDictationSchema = z
.object({
provider: SpeechProviderIdSchema.optional(),
model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(),
confidenceThreshold: z.number().optional(),
})
.strict()
@@ -107,6 +108,7 @@ const FeatureVoiceModeSchema = z
.object({
provider: SpeechProviderIdSchema.optional(),
model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(),
})
.strict()
.optional(),

View File

@@ -4,6 +4,22 @@ import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AgentManager } from "../agent/agent-manager.js";
import { AgentStorage } from "../agent/agent-storage.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
ListModelsOptions,
} from "../agent/agent-sdk-types.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { ScheduleService } from "./service.js";
@@ -13,6 +29,15 @@ interface ScheduleServiceInternals {
executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult>;
}
const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
};
describe("ScheduleService", () => {
let tempDir: string;
let agentStorage: AgentStorage;
@@ -179,6 +204,182 @@ describe("ScheduleService", () => {
);
});
test("archives new-agent schedule sessions after the run finishes", async () => {
class CountingScheduleSession implements AgentSession {
readonly provider = "claude";
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
readonly id: string;
closed = false;
private turnCount = 0;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
constructor(private readonly config: AgentSessionConfig) {
this.id = "scheduled-session-1";
}
async run(_prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
return {
sessionId: this.id,
finalText: "done",
timeline: [{ type: "assistant_message", text: "done" }],
};
}
async startTurn(
_prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
const turnId = `turn-${++this.turnCount}`;
setImmediate(() => {
this.emit({ type: "turn_started", provider: this.provider, turnId });
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: { type: "assistant_message", text: "done" },
});
this.emit({
type: "turn_completed",
provider: this.provider,
turnId,
usage: { inputTokens: 1, outputTokens: 1 },
});
});
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? null,
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return this.config.modeId ?? null;
}
async setMode(modeId: string): Promise<void> {
this.config.modeId = modeId;
}
getPendingPermissions(): AgentPermissionRequest[] {
return [];
}
async respondToPermission(
_requestId: string,
_response: AgentPermissionResponse,
): Promise<void> {}
describePersistence(): AgentPersistenceHandle {
return {
provider: this.provider,
sessionId: this.id,
metadata: { ...this.config },
};
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
}
private emit(event: AgentStreamEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
}
class CountingScheduleClient implements AgentClient {
readonly provider = "claude";
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
readonly sessions: CountingScheduleSession[] = [];
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new CountingScheduleSession(config);
this.sessions.push(session);
return session;
}
async resumeSession(handle: AgentPersistenceHandle): Promise<AgentSession> {
const metadata = handle.metadata as Partial<AgentSessionConfig> | undefined;
const session = new CountingScheduleSession({
...metadata,
provider: this.provider,
cwd: metadata?.cwd ?? tempDir,
});
this.sessions.push(session);
return session;
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
}
const client = new CountingScheduleClient();
const manager = new AgentManager({
logger: createTestLogger(),
clients: { claude: client },
registry: agentStorage,
});
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
now: () => now,
});
const created = await service.create({
prompt: "finish and stop",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
approvalPolicy: "never",
},
},
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
const agentId = inspected.runs[0]?.agentId;
expect(agentId).toBeTruthy();
expect(client.sessions).toHaveLength(1);
expect(client.sessions[0]?.closed).toBe(true);
expect(manager.getAgent(agentId!)).toBeNull();
const storedAgent = await agentStorage.get(agentId!);
expect(storedAgent?.archivedAt).toBeTruthy();
});
test("defaults new-agent modeId to provider's unattended mode", async () => {
const manager = new AgentManager({
logger: createTestLogger(),
@@ -213,8 +414,9 @@ describe("ScheduleService", () => {
const inspected = await service.inspect(created.id);
const agentId = inspected.runs[0]?.agentId;
expect(agentId).toBeTruthy();
const agent = manager.getAgent(agentId!);
expect(agent?.currentModeId).toBe("bypassPermissions");
const agent = await agentStorage.get(agentId!);
expect(agent?.lastModeId).toBe("bypassPermissions");
expect(agent?.archivedAt).toBeTruthy();
});
test("advances stale nextRunAt on daemon restart", async () => {

View File

@@ -545,7 +545,22 @@ export class ScheduleService {
"paseo.schedule-run": runId,
};
const agent = await this.agentManager.createAgent(config, undefined, { labels });
const result = await this.agentManager.runAgent(agent.id, wrappedPrompt);
let result;
try {
result = await this.agentManager.runAgent(agent.id, wrappedPrompt);
} catch (error) {
try {
await this.agentManager.archiveAgent(agent.id);
} catch (archiveError) {
this.logger.warn(
{ err: archiveError, agentId: agent.id, scheduleId: schedule.id, runId },
"Failed to archive scheduled agent after failed run",
);
}
throw error;
}
await this.agentManager.archiveAgent(agent.id);
const timelineText = curateAgentActivity(result.timeline);
return {
agentId: agent.id,

View File

@@ -119,16 +119,17 @@ import {
StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js";
import type {
AgentPersistenceHandle,
AgentPermissionResponse,
AgentProvider,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentSessionConfig,
AgentStreamEvent,
ProviderSnapshotEntry,
import {
getAgentStreamEventTurnId,
type AgentPersistenceHandle,
type AgentPermissionResponse,
type AgentProvider,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentSessionConfig,
type AgentStreamEvent,
type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
@@ -545,6 +546,7 @@ export interface SessionOptions {
daemonConfigStore: DaemonConfigStore;
mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager;
@@ -571,6 +573,7 @@ export interface SessionOptions {
dictation?: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
getSpeechReadiness?: () => SpeechReadinessSnapshot;
};
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
@@ -782,6 +785,7 @@ export class Session {
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
private unregisterVoiceCallerContext?: (agentId: string) => void;
private getSpeechReadiness?: () => SpeechReadinessSnapshot;
private readonly sttLanguage: string;
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
private readonly isDev: boolean;
@@ -813,6 +817,7 @@ export class Session {
daemonConfigStore,
mcpBaseUrl,
stt,
sttLanguage,
tts,
terminalManager,
providerSnapshotManager,
@@ -874,6 +879,7 @@ export class Session {
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
this.sttLanguage = sttLanguage ?? "en";
this.subscribeToOptionalManagers();
this.bindVoiceBridges({ voice, voiceBridge, dictation });
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
@@ -889,13 +895,13 @@ export class Session {
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
});
this.initializePerSessionManagers({ tts, stt, dictation });
this.initializePerSessionManagers({ tts, stt, sttLanguage, dictation });
// Initialize agent MCP client asynchronously
void this.initializeAgentMcp();
this.subscribeToAgentEvents();
this.sessionLogger.trace("Session created");
this.sessionLogger.trace({}, "agent.session.lifecycle.created");
}
updateAppVersion(appVersion: string | null): void {
@@ -1017,15 +1023,20 @@ export class Session {
private async interruptAgentIfRunning(agentId: string): Promise<void> {
const snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) {
this.sessionLogger.trace({ agentId }, "interruptAgentIfRunning: agent not found");
this.sessionLogger.trace({ agentId }, "agent.session.interrupt.not_found");
throw new Error(`Agent ${agentId} not found`);
}
const hasInFlightRun = this.agentManager.hasInFlightRun(agentId);
if (!hasInFlightRun) {
this.sessionLogger.trace(
{ agentId, lifecycle: snapshot.lifecycle, hasInFlightRun },
"interruptAgentIfRunning: skipping because agent is not running",
{
agentId,
provider: snapshot.provider,
lifecycle: snapshot.lifecycle,
hasInFlightRun,
},
"agent.session.interrupt.skip_not_running",
);
return;
}
@@ -1070,7 +1081,7 @@ export class Session {
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(runOptions),
},
"startAgentStream: requested",
"agent.session.start_stream.request",
);
let iterator: AsyncGenerator<AgentStreamEvent>;
try {
@@ -1080,7 +1091,7 @@ export class Session {
: this.agentManager.streamAgent(agentId, prompt, runOptions);
this.sessionLogger.trace(
{ agentId, shouldReplace },
"startAgentStream: agent iterator returned",
"agent.session.start_stream.iterator_returned",
);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to start agent run");
@@ -1092,9 +1103,9 @@ export class Session {
for await (const _ of iterator) {
// Events are forwarded via the session's AgentManager subscription.
}
this.sessionLogger.trace({ agentId }, "startAgentStream: iterator drained");
this.sessionLogger.trace({ agentId }, "agent.session.iterator.drained");
} catch (error) {
this.sessionLogger.trace({ agentId, err: error }, "startAgentStream: iterator threw");
this.sessionLogger.trace({ agentId, err: error }, "agent.session.iterator.error");
this.handleAgentRunError(agentId, error, "Agent stream failed");
}
})();
@@ -1135,10 +1146,7 @@ export class Session {
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
this.sessionLogger.trace(
{ agentToolCount },
`Agent MCP initialized with ${agentToolCount} tools`,
);
this.sessionLogger.trace({ agentToolCount }, "agent.session.mcp_init");
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to initialize Agent MCP");
}
@@ -1188,16 +1196,20 @@ export class Session {
private initializePerSessionManagers(params: {
tts: SessionOptions["tts"];
stt: SessionOptions["stt"];
sttLanguage: SessionOptions["sttLanguage"];
dictation: SessionOptions["dictation"];
}): void {
const { tts, stt, dictation } = params;
const { tts, stt, sttLanguage, dictation } = params;
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt);
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
language: sttLanguage,
});
this.dictationStreamManager = new DictationStreamManager({
logger: this.sessionLogger,
sessionId: this.sessionId,
emit: (msg) => this.handleDictationManagerMessage(msg),
stt: dictation?.stt ?? null,
language: dictation?.sttLanguage,
finalTimeoutMs: dictation?.finalTimeoutMs,
});
}
@@ -1210,6 +1222,16 @@ export class Session {
this.unsubscribeAgentEvents = this.agentManager.subscribe(
(event) => {
if (event.type === "agent_state") {
this.sessionLogger.trace(
{
agentId: event.agent.id,
provider: event.agent.provider,
providerSessionId: event.agent.persistence?.sessionId ?? undefined,
turnId: event.agent.activeForegroundTurnId ?? undefined,
lifecycle: event.agent.lifecycle,
},
"agent.session.forward_update",
);
void this.forwardAgentUpdate(event.agent);
return;
}
@@ -1260,6 +1282,17 @@ export class Session {
if (!serializedEvent) {
return;
}
this.sessionLogger.trace(
{
agentId: event.agentId,
provider: event.event.provider,
turnId: getAgentStreamEventTurnId(event.event),
seq: event.seq,
epoch: event.epoch,
event: event.event,
},
"agent.session.forward_stream",
);
const payload = {
agentId: event.agentId,
@@ -1612,8 +1645,11 @@ export class Session {
}
try {
this.sessionLogger.trace(
{ messageType: msg.type, payloadBytes: JSON.stringify(msg).length },
"inbound message",
{
messageType: msg.type,
payloadBytes: JSON.stringify(msg).length,
},
"agent.session.inbound",
);
try {
await this.dispatchInboundMessage(msg);
@@ -2738,6 +2774,7 @@ export class Session {
logger: this.sessionLogger.child({ component: "voice-turn-controller" }),
turnDetection,
stt,
sttLanguage: this.sttLanguage,
callbacks: {
onSpeechStarted: async () => {
this.sessionLogger.debug("Voice VAD speech_started");
@@ -3048,6 +3085,17 @@ export class Session {
const { handle, overrides, requestId } = msg;
if (!handle) {
this.sessionLogger.warn("Resume request missing persistence handle");
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: "Unable to resume agent: missing persistence handle",
code: "agent_resume_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
@@ -3084,14 +3132,26 @@ export class Session {
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error({ err: error }, "Failed to resume agent");
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: message,
code: "agent_resume_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to resume agent: ${getErrorMessage(error)}`,
content: `Failed to resume agent: ${message}`,
},
});
}
@@ -3212,14 +3272,26 @@ export class Session {
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`);
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: message,
code: "agent_refresh_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to refresh agent: ${getErrorMessage(error)}`,
content: `Failed to refresh agent: ${message}`,
},
});
}
@@ -7180,8 +7252,12 @@ export class Session {
const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments);
this.sessionLogger.trace(
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
"send_agent_message_request: dispatching shared sendPromptToAgent",
{
agentId,
messageId: msg.messageId,
textPrefix: msg.text.slice(0, 80),
},
"agent.session.send_agent_message",
);
let dispatchResult: { outOfBand: boolean };
try {
@@ -7966,8 +8042,11 @@ export class Session {
*/
private emit(msg: SessionOutboundMessage): void {
this.sessionLogger.trace(
{ messageType: msg.type, payloadBytes: JSON.stringify(msg).length },
"outbound message",
{
messageType: msg.type,
payloadBytes: JSON.stringify(msg).length,
},
"agent.session.outbound",
);
if (
msg.type === "audio_output" &&
@@ -8035,7 +8114,7 @@ export class Session {
* Clean up session resources
*/
public async cleanup(): Promise<void> {
this.sessionLogger.trace("Cleaning up");
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents();

View File

@@ -29,13 +29,21 @@ export interface LocalSpeechProviderConfig {
export interface ResolvedLocalSpeechConfig {
local: LocalSpeechProviderConfig | undefined;
sttLanguages: LocalSpeechSttLanguageConfig;
}
export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
const DEFAULT_STT_LANGUAGE = "en";
export interface LocalSpeechSttLanguageConfig {
dictation: string;
voice: string;
}
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
const LanguageSchema = z.string().trim().min(1).default(DEFAULT_STT_LANGUAGE);
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(z.coerce.number().finite()).optional();
@@ -47,6 +55,8 @@ const LocalSpeechResolutionSchema = z.object({
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
dictationLanguage: LanguageSchema,
voiceLanguage: LanguageSchema,
voiceLocalTtsSpeakerId: OptionalIntegerSchema,
voiceLocalTtsSpeed: OptionalFiniteNumberSchema,
});
@@ -90,6 +100,37 @@ function firstDefinedValue<T>(values: Array<T | null | undefined>): T | undefine
return undefined;
}
function firstNonEmptyString(values: Array<string | null | undefined>): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
function buildLocalSpeechLanguageResolutionInput(params: {
env: NodeJS.ProcessEnv;
persisted: PersistedConfig;
}): Record<string, unknown> {
const { env, persisted } = params;
return {
dictationLanguage: firstNonEmptyString([
env.PASEO_DICTATION_LANGUAGE,
persisted.features?.dictation?.stt?.language,
DEFAULT_STT_LANGUAGE,
]),
voiceLanguage: firstNonEmptyString([
env.PASEO_VOICE_LANGUAGE,
env.PASEO_DICTATION_LANGUAGE,
persisted.features?.voiceMode?.stt?.language,
persisted.features?.dictation?.stt?.language,
DEFAULT_STT_LANGUAGE,
]),
};
}
function buildLocalSpeechResolutionInput(params: {
paseoHome: string;
env: NodeJS.ProcessEnv;
@@ -132,6 +173,7 @@ function buildLocalSpeechResolutionInput(params: {
),
DEFAULT_LOCAL_TTS_MODEL,
]),
...buildLocalSpeechLanguageResolutionInput({ env, persisted }),
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
persisted.features?.voiceMode?.tts?.speakerId,
@@ -159,6 +201,10 @@ export function resolveLocalSpeechConfig(params: {
(parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined);
return {
sttLanguages: {
dictation: parsed.dictationLanguage,
voice: parsed.voiceLanguage,
},
local: parsed.includeProviderConfig
? {
modelsDir: parsed.modelsDir,

View File

@@ -51,6 +51,10 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v2-int8");
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
expect(result.speech.sttLanguages).toEqual({
dictation: "en",
voice: "en",
});
});
test("resolves feature-scoped local model env vars", () => {
@@ -71,6 +75,8 @@ describe("resolveSpeechConfig", () => {
PASEO_VOICE_LOCAL_TTS_MODEL: "kitten",
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
PASEO_DICTATION_LANGUAGE: "es",
PASEO_VOICE_LANGUAGE: "pt",
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
OPENAI_API_KEY: "env-key",
PASEO_VOICE_STT_PROVIDER: "openai",
@@ -119,10 +125,45 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.local?.models.voiceTts).toBe("kitten-nano-en-v0_1-fp16");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(5);
expect(result.speech.local?.models.voiceTtsSpeed).toBe(1.35);
expect(result.speech.sttLanguages).toEqual({
dictation: "es",
voice: "pt",
});
expect(result.openai?.apiKey).toBe("env-key");
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
});
test("resolves STT language from env, settings, and voice-to-dictation fallback", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: {
stt: {
language: "fr",
},
},
voiceMode: {
stt: {
language: "de",
},
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {
PASEO_DICTATION_LANGUAGE: "es",
PASEO_VOICE_LANGUAGE: " ",
} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.sttLanguages).toEqual({
dictation: "es",
voice: "es",
});
});
test("ignores deprecated shared local model env vars", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {

View File

@@ -173,6 +173,7 @@ export function resolveSpeechConfig(params: {
openai,
speech: {
providers,
sttLanguages: local.sttLanguages,
...(local.local ? { local: local.local } : {}),
},
};

View File

@@ -67,7 +67,13 @@ function createStubTurnDetection(id: string): TurnDetectionProvider {
}
function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig {
return { providers };
return {
providers,
sttLanguages: {
dictation: "en",
voice: "en",
},
};
}
describe("createSpeechService readiness", () => {

View File

@@ -342,9 +342,11 @@ function resolveEffectiveProviderIds(params: {
export interface SpeechService {
resolveStt: () => SpeechToTextProvider | null;
resolveSttLanguage: () => string;
resolveTts: () => TextToSpeechProvider | null;
resolveTurnDetection: () => TurnDetectionProvider | null;
resolveDictationStt: () => SpeechToTextProvider | null;
resolveDictationSttLanguage: () => string;
getReadiness: () => SpeechReadinessSnapshot;
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
start: () => void;
@@ -709,8 +711,10 @@ export function createSpeechService(params: {
return {
resolveTurnDetection: () => turnDetectionService,
resolveStt: () => sttService,
resolveSttLanguage: () => speechConfig?.sttLanguages?.voice ?? "en",
resolveTts: () => ttsService,
resolveDictationStt: () => dictationSttService,
resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en",
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
onReadinessChange: subscribeSpeechReadiness,
start,

View File

@@ -81,10 +81,14 @@ function createFakeTurnDetectionProvider(session: FakeTurnDetectionSession): Tur
};
}
function createFakeSttProvider(sessions: FakeSttSession[]): SpeechToTextProvider {
function createFakeSttProvider(
sessions: FakeSttSession[],
captureLanguage?: (language: string | undefined) => void,
): SpeechToTextProvider {
return {
id: "local",
createSession() {
createSession(params) {
captureLanguage?.(params.language);
const session = new FakeSttSession();
sessions.push(session);
return session;
@@ -98,10 +102,13 @@ async function settleSerialQueue(): Promise<void> {
await Promise.resolve();
}
function createControllerHarness() {
function createControllerHarness(options?: { sttLanguage?: string }) {
const detector = new FakeTurnDetectionSession();
const sttSessions: FakeSttSession[] = [];
const stt = createFakeSttProvider(sttSessions);
let lastSttLanguage: string | undefined;
const stt = createFakeSttProvider(sttSessions, (language) => {
lastSttLanguage = language;
});
const onSpeechStarted = vi.fn(async () => {});
const onSpeechStopped = vi.fn(async () => {});
const onPartialTranscript = vi.fn(
@@ -123,6 +130,7 @@ function createControllerHarness() {
logger: pino({ level: "silent" }),
turnDetection: createFakeTurnDetectionProvider(detector),
stt,
sttLanguage: options?.sttLanguage,
callbacks: {
onSpeechStarted,
onSpeechStopped,
@@ -136,6 +144,7 @@ function createControllerHarness() {
controller,
detector,
sttSessions,
getLastSttLanguage: () => lastSttLanguage,
onSpeechStarted,
onSpeechStopped,
onPartialTranscript,
@@ -145,6 +154,16 @@ function createControllerHarness() {
}
describe("voice turn controller", () => {
it("passes configured language to streaming STT", async () => {
const harness = createControllerHarness({ sttLanguage: "pt" });
await harness.controller.start();
harness.detector.emit("speech_started");
await settleSerialQueue();
expect(harness.getLastSttLanguage()).toBe("pt");
});
it("forwards audio to the detector and streaming STT without submitting buffered utterances", async () => {
const harness = createControllerHarness();

View File

@@ -94,6 +94,7 @@ export function createVoiceTurnController(params: {
logger: Logger;
turnDetection: TurnDetectionProvider;
stt: SpeechToTextProvider;
sttLanguage?: string;
callbacks: VoiceTurnControllerCallbacks;
}): VoiceTurnController {
const detector = params.turnDetection.createSession({
@@ -319,7 +320,7 @@ export function createVoiceTurnController(params: {
function createSttSession(): StreamingTranscriptionSession {
const session = params.stt.createSession({
logger: params.logger.child({ component: "stt" }),
language: "en",
language: params.sttLanguage ?? "en",
});
session.on("transcript", handleSttTranscript);
session.on("committed", ({ segmentId }) => {

View File

@@ -215,8 +215,17 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
undefined,
speechReadiness
? {
resolveStt: () => null,
resolveSttLanguage: () => "en",
resolveTts: () => null,
resolveTurnDetection: () => null,
resolveDictationStt: () => null,
resolveDictationSttLanguage: () => "en",
getReadiness: () => speechReadiness,
onReadinessChange: vi.fn(() => () => {}),
start: vi.fn(),
stop: vi.fn(),
ready: Promise.resolve(),
}
: undefined,
undefined,

View File

@@ -878,6 +878,7 @@ export class VoiceAssistantWebSocketServer {
daemonConfigStore: this.daemonConfigStore,
mcpBaseUrl: this.mcpBaseUrl,
stt: () => this.speech?.resolveStt() ?? null,
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
@@ -910,6 +911,7 @@ export class VoiceAssistantWebSocketServer {
? {
finalTimeoutMs: this.dictation?.finalTimeoutMs,
stt: () => this.speech?.resolveDictationStt() ?? null,
sttLanguage: this.speech?.resolveDictationSttLanguage() ?? "en",
getSpeechReadiness: () => this.speech!.getReadiness(),
}
: undefined,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.73",
"version": "0.1.75",
"private": true,
"type": "module",
"scripts": {

View File

@@ -161,6 +161,7 @@ In the mobile app, enter the password in the direct connection setup screen.
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
- `PASEO_DICTATION_LOCAL_STT_MODEL`, override local dictation STT model
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, override local voice STT/TTS models
- `PASEO_DICTATION_LANGUAGE`, `PASEO_VOICE_LANGUAGE`, override dictation and voice STT language
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
## Schema

View File

@@ -24,7 +24,7 @@ This keeps credentials and execution in your environment and avoids introducing
## Local Speech
Local speech defaults to model IDs `parakeet-tdt-0.6b-v3-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00).
Local speech defaults to model IDs `parakeet-tdt-0.6b-v3-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). STT language defaults to `en`.
Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files.
@@ -32,10 +32,12 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s
{
"version": 1,
"features": {
"dictation": { "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" } },
"dictation": {
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8", "language": "en" }
},
"voiceMode": {
"llm": { "provider": "claude", "model": "haiku" },
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" },
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8", "language": "en" },
"tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 }
}
},
@@ -47,6 +49,8 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s
}
```
Set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for realtime voice. If voice language is omitted, Paseo uses the dictation language before falling back to `en`.
## OpenAI Speech Option
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing `OPENAI_API_KEY`.
@@ -74,6 +78,8 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
- `PASEO_DICTATION_LANGUAGE`, dictation STT language
- `PASEO_VOICE_LANGUAGE`, realtime voice STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
## Operational Notes

View File

@@ -10,7 +10,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCK_FILE="$ROOT_DIR/package-lock.json"
PACKAGE_NIX="$ROOT_DIR/nix/package.nix"
HASH_FILE="$ROOT_DIR/nix/npm-deps.hash"
CHECK_MODE=false
if [[ "${1:-}" == "--check" ]]; then
@@ -42,8 +42,8 @@ if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-de
fi
echo "Computed hash: $NEW_HASH"
# 3. Read current hash
CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')"
# 3. Read current hash from the sidecar file
CURRENT_HASH="$(tr -d '[:space:]' < "$HASH_FILE")"
if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then
echo "Hash is already up to date."
@@ -56,8 +56,7 @@ else
exit 1
fi
echo "Updating npmDepsHash in nix/package.nix..."
sed -i.bak "s|npmDepsHash = \".*\"|npmDepsHash = \"$NEW_HASH\"|" "$PACKAGE_NIX"
rm -f "$PACKAGE_NIX.bak"
echo "Updating nix/npm-deps.hash..."
printf '%s\n' "$NEW_HASH" > "$HASH_FILE"
echo "Updated: $CURRENT_HASH -> $NEW_HASH"
fi