Compare commits

...

21 Commits

Author SHA1 Message Date
Mohamed Boudra
6f6677fc7a chore(release): cut 0.1.3 and add release playbook 2026-02-12 18:22:33 +07:00
Mohamed Boudra
972895d855 Reduce daemon startup noise and normalize tool-call mappers 2026-02-12 18:16:36 +07:00
Mohamed Boudra
50acf2dd44 Merge branch 'feature/cli-output-schema' 2026-02-12 18:04:41 +07:00
Mohamed Boudra
cb2e4aacfe feat(cli): add --output-schema flag for structured agent output 2026-02-12 18:04:39 +07:00
Mohamed Boudra
117bbfe2f4 feat: add provider availability detection and normalize legacy "default" model IDs 2026-02-12 18:04:21 +07:00
Mohamed Boudra
8bdd083512 Merge branch 'main' of github.com:getpaseo/paseo 2026-02-12 18:04:13 +07:00
Mohamed Boudra
f79468b793 Refactor Claude/Codex tool-call parsing to provider-local Zod passes (#25)
* refactor claude/codex tool call parsing to provider-local zod passes

* tighten provider tool-call schemas and remove alias probing

* refactor provider tool-call mappers to strict two-pass zod flow

* test: lock opencode mapper against cross-provider speak normalization

* refactor: enforce schema-only two-pass tool-call normalization
2026-02-12 19:02:11 +08:00
Mohamed Boudra
018edef3bb feat: add onboarding command and migrate git diff to checkout API 2026-02-12 17:00:35 +07:00
Mohamed Boudra
44c32fb8fb website: update Mac download link to v0.1.2 direct DMG 2026-02-12 15:31:11 +07:00
Mohamed Boudra
e542854ae0 website: add privacy policy page and footer link
Add a minimal privacy policy at /privacy that explains:
- Paseo is self-hosted, no data collection
- The relay uses end-to-end encryption and cannot read messages
- No analytics, tracking, or ads
- Third-party agent providers handle their own auth

Add footer link on home page styled as muted/subtle.
2026-02-12 14:20:32 +07:00
Mohamed Boudra
fba7c73c8b feat(cli): add agent update command for name and labels 2026-02-12 14:15:33 +07:00
Mohamed Boudra
f55d0cd25f Animate file explorer refresh icon while fetching 2026-02-12 14:08:30 +07:00
Mohamed Boudra
3319c2d40c Fix dev runner entry and sherpa TTS initialization 2026-02-12 14:05:46 +07:00
Mohamed Boudra
bb93fc2a61 Add refresh loading feedback in file explorer 2026-02-12 14:05:46 +07:00
Mohamed Boudra
0d0f2826d9 website: make install command primary hero CTA 2026-02-12 14:05:46 +07:00
Mohamed Boudra
d682c74469 Fix explorer tab fallback when checkout status is unresolved 2026-02-12 14:05:46 +07:00
Mohamed Boudra
f55e010a19 Hide Changes tab when not git repo and add refresh button to Files pane
- Hide Changes tab completely when isGit is false instead of just muting it
- Automatically switch to Files tab if user was on Changes when it becomes hidden
- Add refresh button next to sort button in Files pane header
- Refresh button refreshes root directory, expanded directories, and file preview if open
2026-02-12 14:05:46 +07:00
Mohamed Boudra
5f864a4dd7 Add speech-start grace period before voice interrupt 2026-02-12 14:05:46 +07:00
Mohamed Boudra
6053ee7c34 fix(cli): list all non-archived agents by default and drop ls --ui 2026-02-12 14:05:46 +07:00
Mohamed Boudra
3a303d2095 ci: fix desktop release version script regex literals 2026-02-11 14:47:01 +07:00
Mohamed Boudra
84079bfc36 ci: allow package reads and use npm ci for release build 2026-02-11 14:42:12 +07:00
95 changed files with 5869 additions and 2142 deletions

View File

@@ -20,6 +20,7 @@ jobs:
publish-tauri:
permissions:
contents: write
packages: read
runs-on: macos-latest
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
@@ -44,7 +45,7 @@ jobs:
targets: aarch64-apple-darwin
- name: Install JS dependencies
run: npm install --workspace=@getpaseo/app --workspace=@getpaseo/desktop --include-workspace-root
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -66,28 +67,28 @@ jobs:
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
const tauriRe = /(\"version\"\\s*:\\s*\")([^\"]+)(\")/;
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
if (!tauriRe.test(tauriConfText)) {
throw new Error(`Failed to find version field in ${tauriConfPath}`);
}
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\\r?\\n/);
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
let inPackage = false;
let updated = false;
const nextLines = cargoLines.map((line) => {
if (/^\\[package\\]\\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\\[/.test(line)) inPackage = false;
if (/^\[package\]\s*$/.test(line)) inPackage = true;
else if (inPackage && /^\[/.test(line)) inPackage = false;
if (inPackage && /^version\\s*=\\s*\".*\"\\s*$/.test(line)) {
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
updated = true;
return `version = \"${version}\"`;
return `version = "${version}"`;
}
return line;
});
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\\n')}\\n`);
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
NODE
- name: Build and publish Tauri release

View File

@@ -148,6 +148,25 @@ Use the Playwright MCP to test the app in Metro web. Navigate to `http://localho
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
## Release playbook
Use the scripted release flow from repo root. Avoid manual version bumps or publish commands unless debugging.
```bash
# 1) bump all workspaces and refresh workspace links
npm run version:all:patch
# 2) run release gate checks (typecheck, build, pack dry-run)
npm run release:check
# 3) publish relay/server/cli packages
npm run release:publish
```
Notes:
- `release:prepare` is part of the flow and refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
## Orchestrator Mode
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.

573
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.2",
"version": "0.1.3",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -56,6 +56,22 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "2.0.52",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.12"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "2.0.0",
"license": "Apache-2.0",
@@ -1385,6 +1401,27 @@
"react-native": "*"
}
},
"node_modules/@clack/core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.0.0",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
"version": "0.4.1",
"license": "MIT OR Apache-2.0",
@@ -3679,6 +3716,20 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"dev": true,
@@ -4157,6 +4208,446 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
@@ -11796,6 +12287,17 @@
"version": "16.13.1",
"license": "MIT"
},
"node_modules/hono": {
"version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/hosted-git-info": {
"version": "7.0.2",
"license": "ISC",
@@ -12028,6 +12530,17 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
@@ -12784,6 +13297,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"license": "MIT",
@@ -12851,6 +13375,14 @@
"version": "0.4.1",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause",
"optional": true,
"peer": true
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"dev": true,
@@ -19788,7 +20320,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -19796,7 +20328,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.2",
"@getpaseo/server": "0.1.3",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -19897,10 +20429,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@getpaseo/relay": "0.1.2",
"@getpaseo/server": "0.1.2",
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.3",
"@getpaseo/server": "0.1.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -19950,14 +20483,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.2",
"version": "0.1.3",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -19973,12 +20506,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.2",
"@getpaseo/relay": "0.1.3",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20026,22 +20559,6 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@ai-sdk/openai": {
"version": "2.0.52",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.12"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"license": "MIT",
@@ -20345,7 +20862,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"workspaces": [
"packages/server",
@@ -33,10 +33,11 @@
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
"cli": "npx tsx packages/cli/src/index.js",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
"release:check": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm run release:prepare",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public"
},

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"scripts": {
"start": "expo start",
@@ -30,7 +30,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.2",
"@getpaseo/server": "0.1.3",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -343,7 +343,8 @@ export function ComboSelect({
const anchorRef = useRef<View>(null);
const selectedOption = options.find((opt) => opt.id === value);
const displayValue = selectedOption?.label ?? (value || "");
const displayValue = selectedOption?.label ?? "";
const isEmpty = options.length === 0;
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
@@ -355,7 +356,7 @@ export function ComboSelect({
value={displayValue}
placeholder={placeholder}
onPress={handleOpen}
disabled={disabled}
disabled={disabled || isEmpty}
isLoading={isLoading}
controlRef={anchorRef}
icon={icon}
@@ -567,8 +568,8 @@ export function AgentConfigRow({
title="Select provider"
value={selectedProvider}
options={providerOptions}
placeholder="Select..."
disabled={disabled}
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
disabled={disabled || providerOptions.length === 0}
onSelect={onSelectProvider}
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}

View File

@@ -17,6 +17,14 @@ interface AgentStatusBarProps {
serverId: string;
}
function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized;
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles();
const IS_WEB = Platform.OS === "web";
@@ -61,14 +69,16 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
});
}
const normalizedRuntimeModelId = normalizeModelId(agent.runtimeInfo?.model);
const normalizedConfiguredModelId = normalizeModelId(agent.model);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel = useMemo(() => {
if (!models || !agent.model) return null;
return models.find((m) => m.id === agent.model) ?? null;
}, [models, agent.model]);
if (!models || !preferredModelId) return null;
return models.find((m) => m.id === preferredModelId) ?? null;
}, [models, preferredModelId]);
const displayModel = selectedModel
? selectedModel.label
: agent.model ?? "default";
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const explicitThinkingId =
@@ -156,7 +166,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-model-menu"
>
{models?.map((model) => {
const isActive = model.id === agent.model;
const isActive = model.id === activeModelId;
return (
<DropdownMenuItem
key={model.id}
@@ -297,7 +307,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{models?.map((model) => {
const isActive = model.id === agent.model;
const isActive = model.id === activeModelId;
return (
<DropdownMenuItem
key={model.id}

View File

@@ -262,26 +262,35 @@ function SidebarContent({
const { theme } = useUnistyles();
const { status } = useCheckoutStatusQuery({ serverId, cwd });
const isGit = status?.isGit ?? false;
const hasResolvedCheckoutStatus = status !== null;
// Switch to Files tab if Changes tab is hidden and user was on it
useEffect(() => {
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
onTabPress("files");
}
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
<Pressable
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
!isGit && styles.tabTextMuted,
]}
{isGit && (
<Pressable
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
Changes
</Text>
</Pressable>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
]}
>
Changes
</Text>
</Pressable>
)}
<Pressable
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
FlatList,
@@ -13,9 +14,13 @@ import {
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -33,6 +38,7 @@ import {
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import type {
@@ -263,6 +269,78 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
setSortOption(SORT_OPTIONS[nextIndex].value);
}, [sortOption, setSortOption]);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, agentId],
queryFn: async () => {
if (!agentId) {
return null;
}
const directoryPaths = Array.from(expandedPaths);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
await Promise.all([
...directoryPaths.map((path) =>
requestDirectoryListing(agentId, path, {
recordHistory: false,
setCurrentPath: false,
})
),
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []),
]);
return null;
},
enabled: false,
});
const handleRefresh = useCallback(() => {
void refetchExplorer();
}, [refetchExplorer]);
const refreshIconRotation = useSharedValue(0);
useEffect(() => {
if (isRefreshFetching) {
refreshIconRotation.value = 0;
refreshIconRotation.value = withRepeat(
withTiming(360, {
duration: 700,
easing: Easing.linear,
}),
-1,
false
);
return;
}
cancelAnimation(refreshIconRotation);
const remainder = refreshIconRotation.value % 360;
if (Math.abs(remainder) < 0.001) {
refreshIconRotation.value = 0;
return;
}
const remaining = 360 - remainder;
const duration = Math.max(80, Math.round((remaining / 360) * 700));
refreshIconRotation.value = withTiming(
360,
{
duration,
easing: Easing.linear,
},
(finished) => {
if (finished) {
refreshIconRotation.value = 0;
}
}
);
}, [isRefreshFetching, refreshIconRotation]);
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
}));
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
const treeRows = useMemo(() => {
@@ -603,9 +681,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</GestureDetector>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
style={styles.treeList}
@@ -622,9 +718,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
pressed && styles.iconButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={16} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
style={styles.treeList}
@@ -1023,6 +1137,12 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
paneHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
@@ -1166,6 +1286,16 @@ const styles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonPressed: {
opacity: 0.8,
transform: [{ scale: 0.96 }],
},
refreshIcon: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
previewContent: {
flex: 1,
},

View File

@@ -506,6 +506,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
const {
status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError,
refresh: refreshPrStatus,
} = useCheckoutPrStatusQuery({
@@ -840,7 +841,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const diffErrorMessage =
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = prPayloadError?.message ?? null;
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
@@ -993,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
// View PR - when PR exists
if (hasPullRequest && prStatus?.url) {
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
@@ -1008,7 +1009,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}
// Create PR - when ahead of base and no PR
if (aheadCount > 0 && !hasPullRequest) {
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
@@ -1112,7 +1113,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,

View File

@@ -687,7 +687,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
agentItemUnselected: {
opacity: 0.75,
opacity: 1,
},
agentItemSelected: {
backgroundColor: theme.colors.surface2,

View File

@@ -177,7 +177,7 @@ export function ToolCallDetailsContent({
if (plainInputText !== null) {
sections.push(
<View key="unknown-plain-text" style={styles.plainTextSection}>
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
<Text selectable style={styles.plainText}>{plainInputText}</Text>
</View>
);
} else {
@@ -298,6 +298,13 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
padding: theme.spacing[3],
},
plainText: {
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
overflowWrap: "anywhere",
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

View File

@@ -322,7 +322,6 @@ export function SessionProvider({
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
@@ -666,22 +665,6 @@ export function SessionProvider({
[serverId, setFileExplorer]
);
const gitDiffMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => {
if (!agentId) {
throw new Error("Agent id is required");
}
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitDiff(agentId);
if (payload.error) {
throw new Error(payload.error);
}
return { agentId: payload.agentId, diff: payload.diff ?? "" };
},
});
const refreshAgentMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => {
if (!agentId) {
@@ -1275,15 +1258,6 @@ export function SessionProvider({
return next;
});
setGitDiffs(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
setFileExplorer(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
@@ -1343,7 +1317,6 @@ export function SessionProvider({
setAgents,
setAgentLastActivity,
setPendingPermissions,
setGitDiffs,
setFileExplorer,
setHasHydratedAgents,
updateConnectionStatus,
@@ -1635,24 +1608,6 @@ export function SessionProvider({
[]
);
const requestGitDiff = useCallback(
(agentId: string) => {
gitDiffMutation
.mutateAsync({ agentId })
.then((result) => {
setGitDiffs(serverId, (prev) =>
new Map(prev).set(result.agentId, result.diff)
);
})
.catch((error) => {
setGitDiffs(serverId, (prev) =>
new Map(prev).set(agentId, `Error: ${error.message}`)
);
});
},
[serverId, gitDiffMutation, setGitDiffs]
);
const requestDirectoryListing = useCallback(
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
const normalizedPath = path && path.length > 0 ? path : ".";

View File

@@ -64,38 +64,63 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
const voiceTransportReadyRef = useRef(false);
const voiceResyncInFlightRef = useRef(false);
const silenceGraceStartMsRef = useRef<number | null>(null);
const speechInterruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const speechInterruptStartMsRef = useRef<number | null>(null);
const speechStartInterruptSentRef = useRef(false);
const isVoiceModeRef = useRef(false);
const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({
isDetecting: false,
isSpeaking: false,
});
const clearSpeechStartInterruptTimer = useCallback((reason: string) => {
const timer = speechInterruptTimerRef.current;
if (timer) {
clearTimeout(timer);
speechInterruptTimerRef.current = null;
const startedAt = speechInterruptStartMsRef.current;
speechInterruptStartMsRef.current = null;
if (startedAt !== null) {
console.log("[Voice] Cleared speech-start interrupt timer", {
reason,
elapsedMs: Date.now() - startedAt,
});
} else {
console.log("[Voice] Cleared speech-start interrupt timer", { reason });
}
return;
}
speechInterruptStartMsRef.current = null;
}, []);
const interruptActiveVoiceTurn = useCallback((source: string) => {
const session = realtimeSessionRef.current;
const sessionAudioPlayer = session?.audioPlayer ?? null;
const sessionClient = session?.client ?? null;
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
if (sessionIsPlayingAudio && sessionAudioPlayer) {
if (bargeInPlaybackStopRef.current === null) {
bargeInPlaybackStopRef.current = Date.now();
}
sessionAudioPlayer.stop();
}
try {
if (sessionClient) {
void sessionClient.abortRequest().catch((error) => {
console.error("[Voice] Failed to send abort_request:", error);
});
}
console.log("[Voice] Sent abort_request before streaming audio", { source });
} catch (error) {
console.error("[Voice] Failed to send abort_request:", error);
}
}, []);
const realtimeAudio = useSpeechmaticsAudio({
onSpeechStart: () => {
console.log("[Voice] Segment started (speech confirmed)");
// Stop audio playback if playing
const session = realtimeSessionRef.current;
const sessionAudioPlayer = session?.audioPlayer ?? null;
const sessionClient = session?.client ?? null;
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
if (sessionIsPlayingAudio && sessionAudioPlayer) {
if (bargeInPlaybackStopRef.current === null) {
bargeInPlaybackStopRef.current = Date.now();
}
sessionAudioPlayer.stop();
}
// Abort any in-flight orchestrator turn before the new speech segment streams
try {
if (sessionClient) {
void sessionClient.abortRequest().catch((error) => {
console.error("[Voice] Failed to send abort_request:", error);
});
}
console.log("[Voice] Sent abort_request before streaming audio");
} catch (error) {
console.error("[Voice] Failed to send abort_request:", error);
}
},
onSpeechEnd: () => {
const silenceMs =
@@ -108,6 +133,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
console.log("[Voice] Segment finalized", { silenceMs });
}
silenceGraceStartMsRef.current = null;
clearSpeechStartInterruptTimer("speech ended");
speechStartInterruptSentRef.current = false;
},
onAudioSegment: ({ audioData, isLast }) => {
if (!voiceTransportReadyRef.current) {
@@ -158,6 +185,58 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
realtimeSessionRef.current = activeSession;
}, [activeSession]);
useEffect(() => {
isVoiceModeRef.current = isVoiceMode;
}, [isVoiceMode]);
useEffect(() => {
if (!isVoiceMode) {
clearSpeechStartInterruptTimer("voice mode disabled");
speechStartInterruptSentRef.current = false;
return;
}
if (realtimeAudio.isSpeaking) {
if (
speechStartInterruptSentRef.current ||
speechInterruptTimerRef.current !== null
) {
return;
}
speechInterruptStartMsRef.current = Date.now();
speechInterruptTimerRef.current = setTimeout(() => {
speechInterruptTimerRef.current = null;
speechInterruptStartMsRef.current = null;
if (
!isVoiceModeRef.current ||
!vadStateRef.current.isSpeaking ||
speechStartInterruptSentRef.current
) {
return;
}
speechStartInterruptSentRef.current = true;
console.log("[Voice] Speech persisted beyond grace; interrupting turn", {
graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs,
});
interruptActiveVoiceTurn("speech_start_grace_elapsed");
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
return;
}
clearSpeechStartInterruptTimer("speech stopped before grace");
}, [
clearSpeechStartInterruptTimer,
interruptActiveVoiceTurn,
isVoiceMode,
realtimeAudio.isSpeaking,
]);
useEffect(() => {
return () => {
clearSpeechStartInterruptTimer("voice provider unmounted");
};
}, [clearSpeechStartInterruptTimer]);
useEffect(() => {
const next = {
isDetecting: realtimeAudio.isDetecting,
@@ -187,6 +266,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
// Fully idle (neither detecting nor speaking).
if (!next.isDetecting && !next.isSpeaking) {
silenceGraceStartMsRef.current = null;
speechStartInterruptSentRef.current = false;
}
vadStateRef.current = next;

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
describe("useAgentFormState", () => {
describe("__private__.combineInitialValues", () => {
@@ -146,5 +153,127 @@ describe("useAgentFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("normalizes legacy model id 'default' from initial values to auto", () => {
const resolved = __private__.resolveFormState(
{ model: "default" },
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
);
expect(resolved.model).toBe("");
});
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
const resolved = __private__.resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: {
codex: {
model: "default",
},
},
},
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>()
);
expect(resolved.model).toBe("");
});
it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS
.filter((definition) => definition.id === "claude")
.map((definition) => [definition.id as AgentProvider, definition])
);
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
allowedProviderMap
);
expect(resolved.provider).toBe("claude");
});
it("does not force fallback provider when allowed provider map is empty", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
null,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<string>(),
new Map<AgentProvider, AgentProviderDefinition>()
);
expect(resolved.provider).toBe("codex");
});
});
});

View File

@@ -89,15 +89,25 @@ type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>;
};
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id, definition])
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
allProviderDefinitions.map((definition) => [definition.id, definition])
);
const fallbackDefinition = providerDefinitions[0];
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
fallbackDefinition?.defaultModeId ?? "";
function normalizeSelectedModelId(
modelId: string | null | undefined
): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return "";
}
return normalized;
}
function resolveDefaultModel(
availableModels: AgentModelDefinition[] | null
): AgentModelDefinition | null {
@@ -136,25 +146,33 @@ function resolveFormState(
availableModels: AgentModelDefinition[] | null,
userModified: UserModifiedFields,
currentState: FormState,
validServerIds: Set<string>
validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap
): FormState {
// Start with current state - we only update non-user-modified fields
const result = { ...currentState };
const fallbackProvider = allowedProviderMap.keys().next().value as
| AgentProvider
| undefined;
// 1. Resolve provider first (other fields depend on it)
if (!userModified.provider) {
if (initialValues?.provider && providerDefinitionMap.has(initialValues.provider)) {
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
result.provider = initialValues.provider;
} else if (
preferences?.provider &&
providerDefinitionMap.has(preferences.provider as AgentProvider)
allowedProviderMap.has(preferences.provider as AgentProvider)
) {
result.provider = preferences.provider as AgentProvider;
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
result.provider = fallbackProvider;
}
// else keep current (initialized to DEFAULT_PROVIDER)
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
result.provider = fallbackProvider;
}
const providerDef = providerDefinitionMap.get(result.provider);
const providerDef = allowedProviderMap.get(result.provider);
const providerPrefs = preferences?.providerPreferences?.[result.provider];
// 2. Resolve modeId (depends on provider)
@@ -181,25 +199,24 @@ function resolveFormState(
if (!userModified.model) {
const isValidModel = (m: string) =>
availableModels?.some((am) => am.id === m) ?? false;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
if (
typeof initialValues?.model === "string" &&
initialValues.model.length > 0
) {
if (initialModel) {
// If models aren't loaded yet, trust the initial value
// It will be validated once models load
if (!availableModels || isValidModel(initialValues.model)) {
result.model = initialValues.model;
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
result.model = providerPrefs.model;
if (!availableModels || isValidModel(initialModel)) {
result.model = initialModel;
} else if (preferredModel && isValidModel(preferredModel)) {
result.model = preferredModel;
} else {
result.model = "";
}
} else if (typeof providerPrefs?.model === "string" && providerPrefs.model.length > 0) {
} else if (preferredModel) {
// If models haven't loaded yet, optimistically apply the stored preference.
// We'll validate once models load and clear it if it isn't available.
if (!availableModels || isValidModel(providerPrefs.model)) {
result.model = providerPrefs.model;
if (!availableModels || isValidModel(preferredModel)) {
result.model = preferredModel;
} else {
result.model = "";
}
@@ -353,6 +370,45 @@ export function useAgentFormState(
const client = sessionState?.client ?? null;
const isConnected = sessionState?.connection?.isConnected ?? false;
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listAvailableProviders();
if (payload.error) {
throw new Error(payload.error);
}
return payload.providers
.filter((entry) => entry.available)
.map((entry) => entry.provider);
},
});
const providerDefinitions = useMemo(() => {
const availableProviders = availableProvidersQuery.data;
if (!availableProviders) {
return [];
}
const available = new Set(availableProviders);
return allProviderDefinitions.filter((definition) =>
available.has(definition.id as AgentProvider)
);
}, [availableProvidersQuery.data]);
const providerDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition])
),
[providerDefinitions]
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
@@ -363,7 +419,14 @@ export function useAgentFormState(
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(isVisible && isTargetDaemonReady && formState.serverId && client && isConnected),
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
providerDefinitionMap.has(formState.provider)
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
@@ -403,7 +466,8 @@ export function useAgentFormState(
availableModels,
userModified,
formStateRef.current,
validServerIds
validServerIds,
providerDefinitionMap
);
// Only update if something changed
@@ -428,6 +492,7 @@ export function useAgentFormState(
availableModels,
userModified,
validServerIds,
providerDefinitionMap,
]);
// Auto-select the first online host when:
@@ -500,11 +565,11 @@ export function useAgentFormState(
...prev,
provider,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
model: providerPrefs?.model ?? "",
model: normalizeSelectedModelId(providerPrefs?.model),
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
},
[preferences?.providerPreferences, updatePreferences]
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
);
const setModeFromUser = useCallback(
@@ -518,9 +583,10 @@ export function useAgentFormState(
const setModelFromUser = useCallback(
(modelId: string) => {
setFormState((prev) => ({ ...prev, model: modelId }));
const normalizedModelId = normalizeSelectedModelId(modelId);
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
setUserModified((prev) => ({ ...prev, model: true }));
void updateProviderPreferences(formState.provider, { model: modelId });
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
},
[formState.provider, updateProviderPreferences]
);
@@ -639,6 +705,8 @@ export function useAgentFormState(
setThinkingOptionFromUser,
setWorkingDir,
setWorkingDirFromUser,
providerDefinitions,
providerDefinitionMap,
agentDefinition,
modeOptions,
availableModels,

View File

@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
return {
status: query.data?.status ?? null,
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
payloadError: query.data?.error ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,

View File

@@ -41,7 +41,7 @@ export function useFileExplorerActions(serverId: string) {
);
const requestDirectoryListing = useCallback(
(
async (
agentId: string,
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean }
@@ -77,42 +77,40 @@ export function useFileExplorerActions(serverId: string) {
return;
}
void client
.exploreFileSystem(agentId, normalizedPath, "list")
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: payload.error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
return nextState;
});
})
.catch((error) => {
updateExplorerState(agentId, (state) => ({
try {
const payload = await client.exploreFileSystem(agentId, normalizedPath, "list");
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory",
lastError: payload.error ?? null,
pendingRequest: null,
}));
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
return nextState;
});
} catch (error) {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory",
pendingRequest: null,
}));
}
},
[client, updateExplorerState]
);
const requestFilePreview = useCallback(
(agentId: string, path: string) => {
async (agentId: string, path: string) => {
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState(agentId, (state) => ({
...state,
@@ -131,36 +129,34 @@ export function useFileExplorerActions(serverId: string) {
return;
}
void client
.exploreFileSystem(agentId, normalizedPath, "file")
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
} else if (payload.error) {
nextState.lastError = payload.error;
}
return nextState;
});
})
.catch(() => {
updateExplorerState(agentId, (state) => ({
try {
const payload = await client.exploreFileSystem(agentId, normalizedPath, "file");
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
pendingRequest: null,
}));
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
} else if (payload.error) {
nextState.lastError = payload.error;
}
return nextState;
});
} catch {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
pendingRequest: null,
}));
}
},
[client, updateExplorerState]
);

View File

@@ -1,70 +0,0 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { useSessionStore } from "@/stores/session-store";
import { usePanelStore } from "@/stores/panel-store";
const GIT_DIFF_STALE_TIME = 30_000;
function gitDiffQueryKey(serverId: string, agentId: string) {
return ["gitDiff", serverId, agentId] as const;
}
interface UseGitDiffQueryOptions {
serverId: string;
agentId: string;
}
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
const queryClient = useQueryClient();
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const explorerTab = usePanelStore((state) => state.explorerTab);
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const query = useQuery({
queryKey: gitDiffQueryKey(serverId, agentId),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
const response = await client.getGitDiff(agentId);
return response.diff;
},
enabled: !!client && isConnected && !!agentId,
staleTime: GIT_DIFF_STALE_TIME,
refetchInterval: 10_000,
});
// Revalidate when sidebar opens with "changes" tab active
useEffect(() => {
if (!isOpen || explorerTab !== "changes" || !agentId) {
return;
}
// Invalidate to trigger background refetch (shows stale data while fetching)
queryClient.invalidateQueries({
queryKey: gitDiffQueryKey(serverId, agentId),
});
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
return {
diff: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refresh,
};
}

View File

@@ -785,6 +785,13 @@ export function DraftAgentScreen({
dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" });
throw new Error("No host selected");
}
if (providerDefinitions.length === 0) {
dispatch({
type: "DRAFT_SET_ERROR",
message: "No available providers on the selected host",
});
throw new Error("No available providers on the selected host");
}
if (gitBlockingError) {
dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError });
throw new Error(gitBlockingError);
@@ -894,6 +901,7 @@ export function DraftAgentScreen({
isDirectoryNotExists,
isNonGitDirectory,
modeOptions,
providerDefinitions,
persistFormPreferences,
router,
selectedMode,

View File

@@ -201,9 +201,6 @@ export interface SessionState {
// Permissions
pendingPermissions: Map<string, PendingPermission>;
// Git diffs
gitDiffs: Map<string, string>;
// File explorer
fileExplorer: Map<string, AgentFileExplorerState>;
@@ -258,9 +255,6 @@ interface SessionStoreActions {
// Permissions
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
// Git diffs
setGitDiffs: (serverId: string, diffs: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => void;
// File explorer
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
@@ -332,7 +326,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
initializingAgents: new Map(),
agents: new Map(),
pendingPermissions: new Map(),
gitDiffs: new Map(),
fileExplorer: new Map(),
queuedMessages: new Map(),
};
@@ -745,28 +738,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
// Git diffs
setGitDiffs: (serverId, diffs) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextDiffs = typeof diffs === "function" ? diffs(session.gitDiffs) : diffs;
if (session.gitDiffs === nextDiffs) {
return prev;
}
logSessionStoreUpdate("setGitDiffs", serverId, { count: nextDiffs.size });
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, gitDiffs: nextDiffs },
},
};
});
},
// File explorer
setFileExplorer: (serverId, state) => {
set((prev) => {

View File

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

View File

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

View File

@@ -1,5 +1,14 @@
import type { ComponentType } from "react";
import { Bot, Brain, Eye, Pencil, Search, SquareTerminal, Wrench } from "lucide-react-native";
import {
Bot,
Brain,
Eye,
MicVocal,
Pencil,
Search,
SquareTerminal,
Wrench,
} from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
@@ -20,6 +29,9 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
return Brain;
}
if (lowerName === "speak") {
return MicVocal;
}
if (detail) {
return TOOL_DETAIL_ICONS[detail.type];

View File

@@ -9,4 +9,6 @@ export const REALTIME_VOICE_VAD_CONFIG = {
silenceDurationMs: 2000,
speechConfirmationMs: 120,
detectionGracePeriodMs: 700,
// Delay speech-start interrupts to ignore transient noise triggers.
interruptGracePeriodMs: 1000,
} as const;

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.2",
"version": "0.1.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -21,8 +21,9 @@
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
},
"dependencies": {
"@getpaseo/relay": "0.1.2",
"@getpaseo/server": "0.1.2",
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.3",
"@getpaseo/server": "0.1.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -16,7 +16,9 @@ import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { runUpdateCommand } from './commands/agent/update.js'
import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
const VERSION = '0.1.0'
@@ -42,11 +44,10 @@ export function createCli(): Command {
// Primary agent commands (top-level)
program
.command('ls')
.description('List agents. By default shows background agents (without ui=true) in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLsCommand))
@@ -66,6 +67,7 @@ export function createCli(): Command {
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
@@ -126,7 +128,23 @@ export function createCli(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
program
.command('update')
.description('Update an agent (alias for "paseo agent update")')
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runUpdateCommand))
// Top-level local daemon shortcuts
program.addCommand(onboardCommand())
program.addCommand(daemonStartCommand())
program

View File

@@ -9,6 +9,7 @@ import { runSendCommand } from './send.js'
import { runInspectCommand } from './inspect.js'
import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { runUpdateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
export function createAgentCommand(): Command {
@@ -22,11 +23,10 @@ export function createAgentCommand(): Command {
// Primary agent commands (same as top-level)
agent
.command('ls')
.description('List agents. By default shows background agents (without ui=true) in current directory.')
.option('-a, --all', 'Include all statuses (not just running)')
.option('-g, --global', 'Show agents from all directories (not just current)')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runLsCommand))
@@ -43,6 +43,7 @@ export function createAgentCommand(): Command {
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand))
@@ -121,5 +122,20 @@ export function createAgentCommand(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
agent
.command('update')
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runUpdateCommand))
return agent
}

View File

@@ -74,26 +74,22 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
export type AgentLsResult = ListResult<AgentListItem>
export interface AgentLsOptions extends CommandOptions {
/** -a: Include all statuses (not just running/idle) */
/** -a: Include archived agents */
all?: boolean
/** -g: Show agents globally (not just current directory) */
/** Legacy flag retained for CLI compatibility */
global?: boolean
/** Filter by specific status */
status?: string
/** Filter by specific cwd (overrides default cwd filtering) */
/** Filter by specific cwd */
cwd?: string
/** Filter by labels (key=value format) */
label?: string[]
/** Filter to UI agents only (equivalent to --label ui=true) */
ui?: boolean
}
/**
* Agent ls command with correct semantics from design doc:
* - `paseo agent ls` running/idle agents in current directory
* - `paseo agent ls -a` all statuses in current directory
* - `paseo agent ls -g` → running/idle agents globally
* - `paseo agent ls -ag` → everything everywhere
* Agent ls command semantics:
* - `paseo agent ls` → all non-archived agents
* - `paseo agent ls -a` → include archived agents
*/
export async function runLsCommand(
options: AgentLsOptions,
@@ -117,39 +113,26 @@ export async function runLsCommand(
try {
let agents = await client.fetchAgents()
// Status filtering:
// By default, only show running/idle agents (not error, archived, etc.)
// With -a flag, show all statuses
// By default, exclude archived agents. `-a` includes them.
if (!options.all) {
agents = agents.filter((a) => {
// Show running and idle agents, exclude archived
return (a.status === 'running' || a.status === 'idle') && !a.archivedAt
})
agents = agents.filter((a) => !a.archivedAt)
}
// If explicit status filter is provided, use it
// If explicit status filter is provided, apply it.
if (options.status) {
agents = agents.filter((a) => a.status === options.status)
}
// Directory filtering:
// By default, only show agents in current working directory
// With -g flag, show agents globally (all directories)
if (!options.global) {
const currentCwd = options.cwd ?? process.cwd()
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, '')
agents = agents.filter((a) => {
// Normalize paths for comparison
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = currentCwd.replace(/\/$/, '')
// Match exact cwd or subdirectories
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
}
// Label filtering:
// Parse --label flags and --ui flag
// --ui is equivalent to --label ui=true
// By default (no --ui flag), show background agents (those WITHOUT ui=true)
// Parse --label filters (key=value).
const labelFilters: Record<string, string> = {}
if (options.label) {
for (const labelStr of options.label) {
@@ -161,14 +144,9 @@ export async function runLsCommand(
}
}
}
// Add ui=true filter if --ui flag is set
if (options.ui) {
labelFilters['ui'] = 'true'
}
// Apply label filtering
// Apply label filtering only when explicitly requested.
if (Object.keys(labelFilters).length > 0) {
// Filter to agents that have ALL specified labels (AND semantics)
agents = agents.filter((a) => {
const agentLabels = a.labels
for (const [key, value] of Object.entries(labelFilters)) {
@@ -178,11 +156,6 @@ export async function runLsCommand(
}
return true
})
} else {
// Default: show background agents only (those without ui=true)
agents = agents.filter((a) => {
return a.labels['ui'] !== 'true'
})
}
await client.close()

View File

@@ -9,7 +9,7 @@ import { lookup } from 'mime-types'
/** Result type for agent run command */
export interface AgentRunResult {
agentId: string
status: 'created' | 'running'
status: 'created' | 'running' | 'completed' | 'timeout' | 'permission' | 'error'
provider: string
cwd: string
title: string | null
@@ -39,24 +39,178 @@ export interface AgentRunOptions extends CommandOptions {
cwd?: string
label?: string[]
ui?: boolean
outputSchema?: string
}
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult['status']
): AgentRunResult {
return {
agentId: agent.id,
status: agent.status === 'running' ? 'running' : 'created',
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
provider: agent.provider,
cwd: agent.cwd,
title: agent.title,
}
}
function loadOutputSchema(value: string): Record<string, unknown> {
const trimmed = value.trim()
if (!trimmed) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: '--output-schema cannot be empty',
details: 'Provide a JSON schema file path or inline JSON object',
}
throw error
}
let source = trimmed
if (!trimmed.startsWith('{')) {
try {
source = readFileSync(resolve(trimmed), 'utf8')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: `Failed to read output schema file: ${trimmed}`,
details: message,
}
throw error
}
}
let parsed: unknown
try {
parsed = JSON.parse(source)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Failed to parse output schema JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Output schema must be a JSON object',
}
throw error
}
return parsed as Record<string, unknown>
}
function extractFirstJsonObject(text: string): string | null {
const source = text.trim()
if (!source) {
return null
}
const startIndexes: number[] = []
for (let i = 0; i < source.length; i += 1) {
if (source[i] === '{') {
startIndexes.push(i)
}
}
for (const start of startIndexes) {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < source.length; i += 1) {
const ch = source[i]!
if (inString) {
if (escaped) {
escaped = false
continue
}
if (ch === '\\') {
escaped = true
continue
}
if (ch === '"') {
inString = false
}
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === '{') {
depth += 1
continue
}
if (ch === '}') {
depth -= 1
if (depth === 0) {
const candidate = source.slice(start, i + 1).trim()
try {
JSON.parse(candidate)
return candidate
} catch {
// Keep scanning.
}
}
}
}
}
return null
}
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
const trimmed = lastMessage.trim()
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
let parsed: unknown
try {
parsed = JSON.parse(jsonText)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response is not valid JSON',
details: message,
}
throw error
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response JSON must be an object',
}
throw error
}
return parsed as Record<string, unknown>
}
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
return {
...agentRunSchema,
serialize: () => output,
}
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
_command: Command
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined
// Validate prompt is provided
if (!prompt || prompt.trim().length === 0) {
@@ -78,6 +232,16 @@ export async function runRunCommand(
throw error
}
// --output-schema always runs in attached/wait mode
if (outputSchema && options.detach) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: '--output-schema cannot be used with --detach',
details: 'Structured output requires waiting for the agent to finish',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
@@ -162,12 +326,75 @@ export async function runRunCommand(
modeId: options.mode,
model: options.model,
initialPrompt: prompt,
outputSchema,
images,
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
if (outputSchema) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
if (state.status === 'timeout') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Timed out waiting for structured output',
}
throw error
}
if (state.status === 'permission') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent is waiting for permission before producing structured output',
}
throw error
}
if (state.status === 'error') {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: state.error ?? 'Agent failed before producing structured output',
}
throw error
}
const lastMessage = state.lastMessage?.trim()
if (!lastMessage) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
}
const output = parseStructuredOutput(lastMessage)
await client.close()
return {
type: 'single',
data: toRunResult(agent, 'completed'),
schema: structuredRunSchema(output),
}
}
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
await client.close()
const finalAgent = state.final ?? agent
const status: AgentRunResult['status'] =
state.status === 'idle' ? 'completed' : state.status
return {
type: 'single',
data: toRunResult(finalAgent, status),
schema: agentRunSchema,
}
}
await client.close()
return {
@@ -177,6 +404,11 @@ export async function runRunCommand(
}
} catch (err) {
await client.close().catch(() => {})
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'AGENT_CREATE_FAILED',

View File

@@ -0,0 +1,177 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for agent update command */
export interface AgentUpdateResult {
agentId: string
name: string | null
labels: string
}
/** Schema for update command output */
export const updateSchema: OutputSchema<AgentUpdateResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'NAME', field: 'name' },
{ header: 'LABELS', field: 'labels' },
],
}
export interface AgentUpdateOptions extends CommandOptions {
name?: string
label?: string[]
host?: string
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {}
if (!labels) {
return parsed
}
for (const rawLabel of labels) {
for (const segment of rawLabel.split(',')) {
const label = segment.trim()
if (!label) {
continue
}
const eqIndex = label.indexOf('=')
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${label}`,
details: 'Labels must be in key=value format',
}
throw error
}
const key = label.slice(0, eqIndex).trim()
const value = label.slice(eqIndex + 1)
if (!key) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${label}`,
details: 'Labels must include a non-empty key in key=value format',
}
throw error
}
parsed[key] = value
}
}
return parsed
}
function formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels)
if (entries.length === 0) {
return '-'
}
return entries.map(([key, value]) => `${key}=${value}`).join(',')
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
_command: Command
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
}
throw error
}
const name = options.name?.trim()
if (options.name !== undefined && !name) {
const error: CommandError = {
code: 'INVALID_NAME',
message: 'Name cannot be empty',
details: 'Use --name <name> with a non-empty value',
}
throw error
}
const labels = parseLabelOptions(options.label)
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: 'NO_CHANGES_PROVIDED',
message: 'Nothing to update',
details: 'Provide at least one of: --name <name>, --label <key=value>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
const agent = await client.fetchAgent(agentIdArg)
if (!agent) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
const agentId = agent.id
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
})
const updated = await client.fetchAgent(agentId)
if (!updated) {
throw new Error(`Agent not found after update: ${agentId}`)
}
await client.close()
return {
type: 'single',
data: {
agentId,
name: updated.title,
labels: formatLabels(updated.labels),
},
schema: updateSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'UPDATE_FAILED',
message: `Failed to update agent: ${message}`,
}
throw error
}
}

View File

@@ -0,0 +1,694 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
import { Command } from 'commander'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import {
ensureLocalSpeechModels,
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type LocalSpeechModelId,
type CliConfigOverrides,
type PersistedConfig,
} from '@getpaseo/server'
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
resolveTcpHostFromListen,
startLocalDaemonDetached,
tailDaemonLog,
type DaemonStartOptions,
} from './daemon/local-daemon.js'
import { tryConnectToDaemon } from '../utils/client.js'
interface OnboardOptions extends DaemonStartOptions {
timeout?: string
voice?: 'ask' | 'enable' | 'disable'
}
type OnboardPersistedConfig = PersistedConfig & {
providers?: PersistedConfig['providers'] & {
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
}
features?: PersistedConfig['features'] & {
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
}
}
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
class OnboardCancelledError extends Error {}
const plainNoteFormat = (line: string): string => line
function renderNote(message: string, title: string): void {
note(message, title, { format: plainNoteFormat })
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function parseTimeoutMs(raw: string | undefined): number {
if (!raw || raw.trim().length === 0) {
return DEFAULT_READY_TIMEOUT_MS
}
const seconds = Number(raw)
if (!Number.isFinite(seconds) || seconds <= 0) {
throw new Error(`Invalid timeout value: ${raw}`)
}
return Math.ceil(seconds * 1000)
}
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {}
if (options.listen) {
cliOverrides.listen = options.listen
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`
}
if (options.relay === false) {
cliOverrides.relayEnabled = false
}
if (options.allowedHosts) {
const raw = options.allowedHosts.trim()
cliOverrides.allowedHosts =
raw.toLowerCase() === 'true'
? true
: raw.split(',').map(host => host.trim()).filter(Boolean)
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false
}
return cliOverrides
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, 'config.json')
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
return {
...config,
providers: {
...config.providers,
local: {
...config.providers?.local,
autoDownload: enabled,
},
},
features: {
...config.features,
dictation: {
...config.features?.dictation,
enabled,
},
voiceMode: {
...config.features?.voiceMode,
enabled,
},
},
}
}
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
const voiceModeEnabled = config.features?.voiceMode?.enabled
if (typeof voiceModeEnabled === 'boolean') {
return voiceModeEnabled
}
const dictationEnabled = config.features?.dictation?.enabled
if (typeof dictationEnabled === 'boolean') {
return dictationEnabled
}
return null
}
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
if (mode === 'enable') {
return true
}
if (mode === 'disable') {
return false
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
return false
}
const answer = await confirm({
message: 'Enable voice features? (downloads local STT/TTS models now)',
active: 'Yes',
inactive: 'No',
initialValue: false,
})
if (isCancel(answer)) {
throw new OnboardCancelledError('Onboarding cancelled by user.')
}
return answer
}
type DownloadProgress = {
modelId: string | null
pct: number | null
}
type LocalModelDownloadProgress = {
modelId: string | null
pct: number | null
}
type LocalSpeechDownloadLogger = {
child: (_bindings: Record<string, unknown>) => LocalSpeechDownloadLogger
info: (obj?: unknown, msg?: string) => void
error: (_obj?: unknown, _msg?: string) => void
}
type LocalSpeechDownloadEvent =
| {
type: 'progress'
progress: LocalModelDownloadProgress
}
| {
type: 'phase'
phase: 'extracting' | 'verifying' | 'finalizing' | 'completed'
}
function resolveRequiredLocalModelIds(config: ReturnType<typeof loadConfig>): LocalSpeechModelId[] {
const providers = config.speech?.providers
const local = config.speech?.local
if (!providers || !local) {
return []
}
const ids = new Set<LocalSpeechModelId>()
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === 'local') {
ids.add(local.models.dictationStt)
}
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === 'local') {
ids.add(local.models.voiceStt)
}
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === 'local') {
ids.add(local.models.voiceTts)
}
return Array.from(ids)
}
function parseLocalModelDownloadProgress(payload: unknown): LocalModelDownloadProgress | null {
if (!payload || typeof payload !== 'object') {
return null
}
const value = payload as Record<string, unknown>
const modelId = typeof value.modelId === 'string' ? value.modelId : null
const pctRaw = value.pct
const pct = typeof pctRaw === 'number' && Number.isFinite(pctRaw) ? Math.max(0, Math.min(100, Math.floor(pctRaw))) : null
return {
modelId,
pct,
}
}
function renderLocalModelProgress(params: {
modelId: LocalSpeechModelId
modelIndex: number
modelCount: number
pct: number | null
}): string {
const prefix = `Downloading speech model ${params.modelIndex}/${params.modelCount}: ${params.modelId}`
if (params.pct === null) {
return `${prefix}...`
}
return `${prefix} (${params.pct}%)`
}
function createLocalSpeechDownloadLogger(
onEvent: (event: LocalSpeechDownloadEvent) => void
): LocalSpeechDownloadLogger {
const logger: LocalSpeechDownloadLogger = {
child: () => logger,
info: (obj?: unknown, msg?: string) => {
if (msg === 'Downloading model artifact') {
const progress = parseLocalModelDownloadProgress(obj)
if (!progress) {
return
}
onEvent({
type: 'progress',
progress,
})
return
}
if (msg === 'Extracting model archive') {
onEvent({ type: 'phase', phase: 'extracting' })
return
}
if (msg === 'Verifying downloaded model files') {
onEvent({ type: 'phase', phase: 'verifying' })
return
}
if (msg === 'Finalizing model artifacts') {
onEvent({ type: 'phase', phase: 'finalizing' })
return
}
if (msg === 'Model download completed') {
onEvent({ type: 'phase', phase: 'completed' })
return
}
},
error: () => {
// no-op: onboarding handles surfaced errors from ensureLocalSpeechModels.
},
}
return logger
}
async function prepareLocalSpeechModelsBeforeStart(args: {
config: ReturnType<typeof loadConfig>
richUi: boolean
}): Promise<void> {
const local = args.config.speech?.local
const modelIds = resolveRequiredLocalModelIds(args.config)
if (!local || modelIds.length === 0) {
return
}
if (local.autoDownload === false) {
log.warn('Local speech model auto-download is disabled. Voice may be unavailable until models are installed.')
return
}
const modelList = modelIds.join(', ')
const modelCount = modelIds.length
const downloadSpinner = args.richUi ? spinner() : null
let lastPlainStatus = ''
const emitStatus = (status: string): void => {
if (downloadSpinner) {
downloadSpinner.message(status)
return
}
if (status === lastPlainStatus) {
return
}
console.log(status)
lastPlainStatus = status
}
if (downloadSpinner) {
downloadSpinner.start(`Preparing local speech models (${modelCount})...`)
} else {
log.message(`Preparing local speech models (${modelCount}): ${modelList}`)
}
try {
for (const [index, modelId] of modelIds.entries()) {
const modelIndex = index + 1
emitStatus(`Checking speech model ${modelIndex}/${modelCount}: ${modelId}`)
const perModelLogger = createLocalSpeechDownloadLogger((event) => {
if (event.type === 'progress') {
const progress = event.progress
if (progress.modelId && progress.modelId !== modelId) {
return
}
emitStatus(
renderLocalModelProgress({
modelId,
modelIndex,
modelCount,
pct: progress.pct,
})
)
return
}
if (event.phase === 'extracting') {
emitStatus(`Extracting speech model ${modelIndex}/${modelCount}: ${modelId}`)
return
}
if (event.phase === 'verifying') {
emitStatus(`Verifying speech model ${modelIndex}/${modelCount}: ${modelId}`)
return
}
if (event.phase === 'finalizing') {
emitStatus(`Finalizing speech model ${modelIndex}/${modelCount}: ${modelId}`)
return
}
})
await ensureLocalSpeechModels({
modelsDir: local.modelsDir,
modelIds: [modelId],
autoDownload: true,
logger: perModelLogger as any,
})
emitStatus(`Speech model ready ${modelIndex}/${modelCount}: ${modelId}`)
}
if (downloadSpinner) {
downloadSpinner.stop(`Local speech models ready (${modelCount})`)
} else {
log.message(`Local speech models ready (${modelCount}): ${modelList}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (downloadSpinner) {
downloadSpinner.error(`Failed to prepare local speech models: ${message}`)
} else {
log.error(`Failed to prepare local speech models: ${message}`)
}
throw error
}
}
function parseDownloadProgress(logTail: string): DownloadProgress | null {
const lines = logTail.split('\n').filter(Boolean)
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]
if (!line || !line.includes('Downloading model artifact')) {
continue
}
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/)
const modelMatch = line.match(
/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/
)
return {
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
}
}
return null
}
function renderProgressLine(progress: DownloadProgress): string {
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
if (progress.pct === null) {
return `Downloading speech model${modelSuffix}...`
}
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
}
async function waitForDaemonReady(args: {
home: string
timeoutMs: number
onStatus?: (message: string) => void
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs
let lastStatus = ''
let lastPrintedAt = 0
while (Date.now() < deadline) {
const state = resolveLocalDaemonState({ home: args.home })
const host = resolveTcpHostFromListen(state.listen)
if (state.running && host) {
const client = await tryConnectToDaemon({ host, timeout: 1200 })
if (client) {
try {
await client.fetchAgents()
return { listen: state.listen, host }
} catch {
// Daemon process is alive but not API-ready yet.
} finally {
await client.close().catch(() => {})
}
}
} else if (state.running && !host) {
return { listen: state.listen, host: null }
}
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
const progressLine = progress ? renderProgressLine(progress) : null
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
if (statusMessage !== lastStatus) {
args.onStatus?.(statusMessage)
lastStatus = statusMessage
lastPrintedAt = Date.now()
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
console.log(statusMessage)
lastPrintedAt = Date.now()
}
await sleep(200)
}
const recentLogs = tailDaemonLog(args.home, 60)
throw new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
}
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
const daemonLogPath = path.join(paseoHome, 'daemon.log')
const nextStepsLines = [
pairingUrl
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
: '1. Open Paseo and connect to your daemon.',
'2. Web app: https://app.paseo.sh',
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
'4. Docs: https://paseo.sh/docs',
'5. Example: paseo run --output-schema schema.json "extract fields"',
]
const quickReferenceLines = [
'1. paseo --help',
'2. paseo ls',
'3. paseo run "your prompt"',
'4. paseo status',
`5. Daemon logs: ${daemonLogPath}`,
]
if (!richUi) {
console.log('')
console.log('Next steps:')
for (const line of nextStepsLines) {
console.log(line)
}
console.log('')
console.log('CLI quick reference:')
for (const line of quickReferenceLines) {
console.log(line)
}
return
}
renderNote(nextStepsLines.join('\n'), 'Next steps')
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
}
export function onboardCommand(): Command {
return new Command('onboard')
.description('Run first-time setup, start daemon, and print pairing instructions')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
)
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
.action(async (options: OnboardOptions) => {
await runOnboard(options)
})
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
const richUi = process.stdin.isTTY && process.stdout.isTTY
if (richUi) {
intro('Welcome to Paseo')
}
if (options.listen && options.port) {
cancel('Cannot use --listen and --port together')
process.exit(1)
}
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
try {
timeoutMs = parseTimeoutMs(options.timeout)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
cancel(message)
process.exit(1)
}
const paseoHome = resolveLocalPaseoHome(options.home)
if (richUi) {
renderNote(paseoHome, 'Paseo home')
}
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted)
const shouldPrompt = options.voice === 'ask' || options.voice === undefined
let voiceEnabled: boolean
try {
voiceEnabled =
shouldPrompt && persistedVoiceSelection !== null
? persistedVoiceSelection
: await resolveVoiceSelection(options.voice)
} catch (error) {
if (error instanceof OnboardCancelledError) {
cancel('Onboarding cancelled.')
process.exit(0)
return
}
throw error
}
if (shouldPrompt && persistedVoiceSelection !== null) {
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
}
persisted = applyVoiceSelection(persisted, voiceEnabled)
savePersistedConfig(paseoHome, persisted)
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
const voiceStatus = voiceEnabled
? 'Voice features enabled. Local speech models will be downloaded if missing.'
: 'Voice features disabled. Local speech models will not be downloaded now.'
log.message(voiceStatus)
try {
await prepareLocalSpeechModelsBeforeStart({
config,
richUi,
})
} catch {
process.exit(1)
}
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
const startSpinner = richUi ? spinner() : null
if (!stateBeforeStart.running) {
try {
if (startSpinner) {
startSpinner.start('Starting daemon...')
} else {
log.message('Starting daemon...')
}
const startup = await startLocalDaemonDetached(options)
if (startSpinner) {
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
} else {
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
}
log.message(`Logs: ${startup.logPath}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (startSpinner) {
startSpinner.error(message)
} else {
log.error(message)
}
process.exit(1)
}
} else {
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
}
let readyState: { listen: string; host: string | null }
const readySpinner = richUi ? spinner() : null
try {
if (readySpinner) {
readySpinner.start('Waiting for daemon to become ready...')
} else {
log.message('Waiting for daemon to become ready...')
}
readyState = await waitForDaemonReady({
home: options.home ?? paseoHome,
timeoutMs,
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
})
if (readySpinner) {
readySpinner.stop(`Daemon ready on ${readyState.listen}`)
} else {
log.message(`Daemon ready on ${readyState.listen}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (readySpinner) {
readySpinner.error(message)
} else {
log.error(message)
}
process.exit(1)
return
}
if (config.relayEnabled === false) {
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
printNextSteps(null, paseoHome, richUi)
if (richUi) {
outro('Paseo daemon is running.')
}
return
}
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
if (!pairing.url) {
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
printNextSteps(null, paseoHome, richUi)
if (richUi) {
outro('Paseo daemon is running.')
}
return
}
renderNote(
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
'Scan to pair'
)
renderNote(pairing.url, 'Pairing link')
printNextSteps(pairing.url, paseoHome, richUi)
if (richUi) {
outro('Paseo is ready!')
}
}

View File

@@ -2,6 +2,6 @@ import { createCli } from './cli.js'
const program = createCli()
if (process.argv.length <= 2) {
process.argv.push('start')
process.argv.push('onboard')
}
program.parse()

View File

@@ -31,6 +31,18 @@ function normalizeFormat(raw: unknown): OutputOptions['format'] {
/** Extract output options from command options */
function extractOutputOptions(options: CommandOptions): OutputOptions {
const hasStructuredOutputSchema =
typeof options.outputSchema === 'string' && options.outputSchema.trim().length > 0
if (hasStructuredOutputSchema) {
return {
format: 'json',
quiet: false,
noHeaders: options.headers === false,
noColor: options.color === false,
}
}
return {
format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
quiet: options.quiet ?? defaultOutputOptions.quiet,

View File

@@ -16,6 +16,7 @@
* - paseo ls --json returns valid JSON (or error)
* - paseo ls -a flag is accepted
* - paseo ls -g flag is accepted
* - paseo ls does not support --ui
*/
import assert from 'node:assert'
@@ -52,6 +53,7 @@ try {
assert(result.stdout.includes('-g'), 'help should mention -g flag')
assert(result.stdout.includes('--global'), 'help should mention --global flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(!result.stdout.includes('--ui'), 'help should not mention --ui')
console.log('✓ paseo ls --help shows options\n')
}
@@ -138,6 +140,17 @@ try {
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted\n')
}
// Test 9: paseo ls --ui is rejected (flag removed)
{
console.log('Test 9: paseo ls --ui is rejected')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls --ui`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag')
const output = result.stdout + result.stderr
assert(output.includes('unknown option'), 'should report unknown option for --ui')
console.log('✓ paseo ls --ui is rejected\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })

View File

@@ -22,7 +22,7 @@
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
@@ -33,6 +33,18 @@ console.log('=== Run Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const schemaPath = join(paseoHome, 'run-output-schema.json')
await writeFile(
schemaPath,
JSON.stringify({
type: 'object',
properties: {
status: { type: 'string' },
},
required: ['status'],
additionalProperties: false,
})
)
try {
// Test 1: run --help shows options
@@ -46,6 +58,7 @@ try {
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
console.log('✓ run --help shows options\n')
@@ -138,9 +151,34 @@ try {
console.log('✓ run --cwd flag is accepted\n')
}
// Test 9: -q (quiet) flag is accepted with run
// Test 9: run --output-schema flag is accepted
{
console.log('Test 9: -q (quiet) flag is accepted with run')
console.log('Test 9: run --output-schema flag is accepted')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --output-schema flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --output-schema flag is accepted\n')
}
// Test 10: run --output-schema cannot be used with --detach
{
console.log('Test 10: run --output-schema cannot be used with --detach')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail with --detach and --output-schema')
const output = result.stdout + result.stderr
assert(
output.includes('--output-schema cannot be used with --detach'),
'error should explain detach incompatibility'
)
console.log('✓ run --output-schema cannot be used with --detach\n')
}
// Test 11: -q (quiet) flag is accepted with run
{
console.log('Test 11: -q (quiet) flag is accepted with run')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
const output = result.stdout + result.stderr
@@ -149,9 +187,9 @@ try {
console.log('✓ -q (quiet) flag is accepted with run\n')
}
// Test 10: Combined flags work together
// Test 12: Combined flags work together
{
console.log('Test 10: Combined flags work together')
console.log('Test 12: Combined flags work together')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
const output = result.stdout + result.stderr
@@ -160,9 +198,9 @@ try {
console.log('✓ Combined flags work together\n')
}
// Test 11: paseo --help shows run command
// Test 13: paseo --help shows run command
{
console.log('Test 11: paseo --help shows run command')
console.log('Test 13: paseo --help shows run command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('run'), 'help should mention run command')

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env npx tsx
/**
* Phase 16: Agent Update Command Tests
*
* Tests the agent update command for metadata updates.
* Since daemon may not be running, we test:
* - Help and argument parsing
* - Validation for required update fields
* - Graceful daemon connection errors
* - Top-level alias support (`paseo update`)
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Agent Update Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: agent update --help shows options
{
console.log('Test 1: agent update --help shows options')
const result = await $`npx paseo agent update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ agent update --help shows options\n')
}
// Test 2: agent update requires ID argument
{
console.log('Test 2: agent update requires ID argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent update requires ID argument\n')
}
// Test 3: agent update requires at least one update field
{
console.log('Test 3: agent update requires update field')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without --name/--label')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('nothing to update') ||
output.toLowerCase().includes('name') ||
output.toLowerCase().includes('label')
assert(hasError, 'error should mention missing update fields')
console.log('✓ agent update requires update field\n')
}
// Test 4: agent update handles daemon not running
{
console.log('Test 4: agent update handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error should mention connection issue')
console.log('✓ agent update handles daemon not running\n')
}
// Test 5: agent update accepts multiple/comma-separated labels
{
console.log('Test 5: agent update accepts multi-label syntax')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label ui=true,area=frontend --label priority=high`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --label flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent update accepts multi-label syntax\n')
}
// Test 6: agent --help shows update subcommand
{
console.log('Test 6: agent --help shows update subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('update'), 'help should mention update subcommand')
console.log('✓ agent --help shows update subcommand\n')
}
// Test 7: top-level update alias --help works
{
console.log('Test 7: top-level update --help works')
const result = await $`npx paseo update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
console.log('✓ top-level update --help works\n')
}
// Test 8: top-level update alias accepts flags
{
console.log('Test 8: top-level update alias accepts flags')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo update abc123 --name "Alias Name" --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept top-level update flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ top-level update alias accepts flags\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All agent update tests passed ===')

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
$.verbose = false
function randomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
}
console.log('=== Onboarding Command ===\n')
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
const port = randomPort()
try {
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
const onboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npm run -s cli --`.nothrow()
assert.strictEqual(onboard.exitCode, 0, `onboard should succeed: ${onboard.stderr}`)
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
assert(onboard.stdout.includes('CLI quick reference'), 'onboard output should include CLI quick reference')
assert(onboard.stdout.includes('paseo --help'), 'onboard output should include --help shortcut')
assert(onboard.stdout.includes('paseo ls'), 'onboard output should include ls shortcut')
assert(onboard.stdout.includes('paseo run "your prompt"'), 'onboard output should include run shortcut')
assert(onboard.stdout.includes('paseo status'), 'onboard output should include status shortcut')
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
const status =
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon status --home ${paseoHome}`.nothrow()
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
console.log('Test 2: non-interactive onboarding persists voice disabled config')
const configRaw = await readFile(join(paseoHome, 'config.json'), 'utf-8')
const config = JSON.parse(configRaw) as {
features?: {
dictation?: { enabled?: boolean }
voiceMode?: { enabled?: boolean }
}
providers?: {
local?: { autoDownload?: boolean }
}
}
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
assert(
!daemonLog.includes('Ensuring local speech models'),
'daemon should not attempt local speech model setup when voice is disabled'
)
console.log('✓ non-interactive run persisted voice disabled choices\n')
} finally {
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== Onboarding tests passed ===')

View File

@@ -1,12 +1,12 @@
{
"name": "desktop",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "desktop",
"version": "0.1.2",
"version": "0.1.3",
"license": "ISC",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.2",
"version": "0.1.3",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -53,7 +53,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/relay": "0.1.2",
"@getpaseo/relay": "0.1.3",
"@ai-sdk/openai": "2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",

View File

@@ -2,35 +2,19 @@ import { fileURLToPath } from "url";
import { existsSync } from "node:fs";
import { runSupervisor } from "./supervisor.js";
function resolveWorkerEntry(): string {
const candidates = [
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return candidates[0];
}
function resolveWorkerExecArgv(): string[] {
const workerEntry = resolveWorkerEntry();
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(WORKER_ENTRY)) {
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
}
runSupervisor({
name: "DevRunner",
startupMessage: "Starting server worker (crash restarts enabled)",
resolveWorkerEntry,
resolveWorkerEntry: () => WORKER_ENTRY,
workerArgs: process.argv.slice(2),
workerEnv: process.env,
workerExecArgv: resolveWorkerExecArgv(),
// Always run worker with tsx so dev server uses TypeScript sources directly.
workerExecArgv: ["--import", "tsx"],
restartOnCrash: true,
shutdownReasons: ["cli_shutdown"],
});

View File

@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
function createMockLogger() {
return {
debug: vi.fn(),
@@ -415,6 +418,60 @@ describe("DaemonClient", () => {
vi.useRealTimers();
});
test("lists available providers via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listAvailableProviders();
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "list_available_providers_request"; requestId: string };
};
expect(request.message.type).toBe("list_available_providers_request");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "list_available_providers_response",
payload: {
providers: [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "Missing binary" },
],
error: null,
fetchedAt: "2026-02-12T00:00:00.000Z",
requestId: request.message.requestId,
},
},
})
);
await expect(promise).resolves.toEqual({
providers: [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "Missing binary" },
],
error: null,
fetchedAt: "2026-02-12T00:00:00.000Z",
requestId: request.message.requestId,
});
});
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();

View File

@@ -15,9 +15,7 @@ import type {
CreateAgentRequestMessage,
FileDownloadTokenResponse,
FileExplorerResponse,
GitDiffResponse,
GitSetupOptions,
HighlightedDiffResponse,
CheckoutStatusResponse,
CheckoutCommitResponse,
CheckoutMergeResponse,
@@ -32,6 +30,7 @@ import type {
ListCommandsResponse,
ExecuteCommandResponse,
ListProviderModelsResponseMessage,
ListAvailableProvidersResponse,
SpeechModelsListResponse,
SpeechModelsDownloadResponse,
ListTerminalsResponse,
@@ -171,6 +170,7 @@ export type CreateAgentRequestOptions = {
provider?: AgentProvider;
cwd?: string;
initialPrompt?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
git?: GitSetupOptions;
worktreeName?: string;
@@ -178,8 +178,6 @@ export type CreateAgentRequestOptions = {
labels?: Record<string, string>;
} & AgentConfigOverrides;
type GitDiffPayload = GitDiffResponse["payload"];
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
type SubscribeCheckoutDiffPayload = Extract<
SessionOutboundMessage,
@@ -198,6 +196,7 @@ type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
@@ -228,6 +227,7 @@ export type WaitForFinishResult = {
status: "idle" | "error" | "permission" | "timeout";
final: AgentSnapshotPayload | null;
error: string | null;
lastMessage: string | null;
};
type Waiter<T> = {
@@ -988,6 +988,7 @@ export class DaemonClient {
requestId,
config,
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
...(options.images && options.images.length > 0
? { images: options.images }
: {}),
@@ -1074,6 +1075,40 @@ export class DaemonClient {
return { archivedAt: result.archivedAt };
}
async updateAgent(
agentId: string,
updates: { name?: string; labels?: Record<string, string> }
): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "update_agent_request",
agentId,
...(updates.name !== undefined ? { name: updates.name } : {}),
...(updates.labels && Object.keys(updates.labels).length > 0
? { labels: updates.labels }
: {}),
requestId,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "update_agent_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
if (!payload.accepted) {
throw new Error(payload.error ?? "updateAgent rejected");
}
}
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
@@ -1873,60 +1908,6 @@ export class DaemonClient {
});
}
async getGitDiff(
agentId: string,
requestId?: string
): Promise<GitDiffPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "git_diff_request",
agentId,
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "git_diff_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async getHighlightedDiff(
agentId: string,
requestId?: string
): Promise<HighlightedDiffPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "highlighted_diff_request",
agentId,
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "highlighted_diff_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async validateBranch(
options: { cwd: string; branchName: string },
requestId?: string
@@ -2078,6 +2059,31 @@ export class DaemonClient {
});
}
async listAvailableProviders(options?: {
requestId?: string;
}): Promise<ListAvailableProvidersPayload> {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "list_available_providers_request",
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 30000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "list_available_providers_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async listSpeechModels(requestId?: string): Promise<SpeechModelsListPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
@@ -2284,6 +2290,7 @@ export class DaemonClient {
status: payload.status,
final: payload.final,
error: payload.error,
lastMessage: payload.lastMessage,
};
}

View File

@@ -130,6 +130,28 @@ describe("AgentManager", () => {
expect(snapshot.model).toBeUndefined();
});
test("normalizeConfig strips legacy 'default' model id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000102",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
model: "default",
});
expect(snapshot.model).toBeUndefined();
});
test("createAgent fails when cwd does not exist", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");

View File

@@ -29,6 +29,7 @@ import type {
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { AgentStorage } from "./agent-storage.js";
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
@@ -53,6 +54,12 @@ export type AgentAttentionCallback = (params: {
reason: "finished" | "error" | "permission";
}) => void;
export type ProviderAvailability = {
provider: AgentProvider;
available: boolean;
error: string | null;
};
export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>;
maxTimelineItems?: number;
@@ -333,6 +340,42 @@ export class AgentManager {
.slice(0, limit);
}
async listProviderAvailability(): Promise<ProviderAvailability[]> {
const checks = AGENT_PROVIDER_IDS.map(async (providerId) => {
const provider = providerId as AgentProvider;
const client = this.clients.get(provider);
if (!client) {
return {
provider,
available: false,
error: `No client registered for provider '${provider}'`,
} satisfies ProviderAvailability;
}
try {
const available = await client.isAvailable();
return {
provider,
available,
error: null,
} satisfies ProviderAvailability;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
{ err: error, provider },
"Failed to check provider availability"
);
return {
provider,
available: false,
error: message,
} satisfies ProviderAvailability;
}
});
return Promise.all(checks);
}
getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id);
return agent ? { ...agent } : null;
@@ -527,6 +570,16 @@ export class AgentManager {
this.emitState(agent);
}
async setLabels(
agentId: string,
labels: Record<string, string>
): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
await this.persistSnapshot(agent);
this.emitState(agent);
}
notifyAgentState(agentId: string): void {
const agent = this.agents.get(agentId);
if (!agent || agent.internal) {
@@ -1450,7 +1503,9 @@ export class AgentManager {
if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
normalized.model = trimmed.length > 0 ? trimmed : undefined;
const normalizedId = trimmed.toLowerCase();
normalized.model =
trimmed.length > 0 && normalizedId !== "default" ? trimmed : undefined;
}
return normalized;

View File

@@ -1300,16 +1300,21 @@ class ClaudeAgentSession implements AgentSession {
}
this.activeSidechains.set(parentToolUseId, toolName);
const toolCall = mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
metadata: { subAgentActivity: toolName },
});
if (!toolCall) {
return [];
}
return [
{
type: "timeline",
item: mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
metadata: { subAgentActivity: toolName },
}),
item: toolCall,
provider: "claude",
},
];
@@ -1497,14 +1502,14 @@ class ClaudeAgentSession implements AgentSession {
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
metadata.planText = input.plan;
}
const detail =
const toolDetail =
kind === "tool"
? mapClaudeRunningToolCall({
name: toolName,
callId: options.toolUseID ?? requestId,
input,
output: null,
}).detail
})?.detail
: undefined;
const request: AgentPermissionRequest = {
@@ -1513,7 +1518,7 @@ class ClaudeAgentSession implements AgentSession {
name: toolName,
kind,
input,
detail,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -1591,9 +1596,12 @@ class ClaudeAgentSession implements AgentSession {
}
private pushToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
target?: AgentTimelineItem[]
) {
if (!item) {
return;
}
if (target) {
target.push(item);
return;

View File

@@ -19,15 +19,58 @@ import {
toolDetailBranchByName,
} from "../tool-call-detail-primitives.js";
const ClaudeKnownToolDetailSchema = z.union([
const ClaudeToolEnvelopeSchema = z
.object({
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.passthrough();
const ClaudeSpeakToolDetailSchema = z
.object({
name: z.literal("speak"),
input: z
.union([
z.string().transform((text) => ({ text })),
z.object({ text: z.string() }).passthrough(),
])
.nullable(),
output: z.unknown().nullable(),
})
.transform(({ input }) => {
const text = input?.text?.trim() ?? "";
if (!text) {
return undefined;
}
return {
type: "unknown",
input: text,
output: null,
} satisfies ToolCallDetail;
});
const ClaudeToolDetailPass2Schema = z.union([
toolDetailBranchByName("Bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByName("Read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("view_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByName("Read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("view_file", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByName("Write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
@@ -38,7 +81,12 @@ const ClaudeKnownToolDetailSchema = z.union([
toolDetailBranchByName("edit", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName("str_replace_editor", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByName(
"str_replace_editor",
ToolEditInputSchema,
ToolEditOutputSchema,
toEditToolDetail
),
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
@@ -48,6 +96,7 @@ const ClaudeKnownToolDetailSchema = z.union([
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
ClaudeSpeakToolDetailSchema,
]);
export function deriveClaudeToolDetail(
@@ -55,17 +104,27 @@ export function deriveClaudeToolDetail(
input: unknown,
output: unknown
): ToolCallDetail {
const parsed = ClaudeKnownToolDetailSchema.safeParse({
const pass1 = ClaudeToolEnvelopeSchema.safeParse({
name,
input,
output,
});
if (parsed.success && parsed.data) {
return parsed.data;
}
return {
type: "unknown",
input: input ?? null,
output: output ?? null,
});
if (!pass1.success) {
return {
type: "unknown",
input: input ?? null,
output: output ?? null,
};
}
const pass2 = ClaudeToolDetailPass2Schema.safeParse(pass1.data);
if (pass2.success && pass2.data) {
return pass2.data;
}
return {
type: "unknown",
input: pass1.data.input,
output: pass1.data.output,
};
}

View File

@@ -6,14 +6,24 @@ import {
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
if (!item) {
throw new Error("Expected mapped tool call");
}
return item;
}
describe("claude tool-call mapper", () => {
it("maps running shell calls with canonical fields", () => {
const item = mapClaudeRunningToolCall({
callId: "claude-call-1",
name: "Bash",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
});
const item = expectMapped(
mapClaudeRunningToolCall({
callId: "claude-call-1",
name: "Bash",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
})
);
expect(item.type).toBe("tool_call");
expect(item.status).toBe("running");
@@ -27,45 +37,53 @@ describe("claude tool-call mapper", () => {
});
it("maps running known tool variants with detail for early summaries", () => {
const readItem = mapClaudeRunningToolCall({
callId: "claude-running-read",
name: "read_file",
input: { file_path: "README.md" },
output: null,
});
const readItem = expectMapped(
mapClaudeRunningToolCall({
callId: "claude-running-read",
name: "read_file",
input: { file_path: "README.md" },
output: null,
})
);
expect(readItem.detail).toEqual({
type: "read",
filePath: "README.md",
});
const writeItem = mapClaudeRunningToolCall({
callId: "claude-running-write",
name: "write_file",
input: { file_path: "src/new.ts" },
output: null,
});
const writeItem = expectMapped(
mapClaudeRunningToolCall({
callId: "claude-running-write",
name: "write_file",
input: { file_path: "src/new.ts" },
output: null,
})
);
expect(writeItem.detail).toEqual({
type: "write",
filePath: "src/new.ts",
});
const editItem = mapClaudeRunningToolCall({
callId: "claude-running-edit",
name: "apply_patch",
input: { file_path: "src/index.ts" },
output: null,
});
const editItem = expectMapped(
mapClaudeRunningToolCall({
callId: "claude-running-edit",
name: "apply_patch",
input: { file_path: "src/index.ts" },
output: null,
})
);
expect(editItem.detail).toEqual({
type: "edit",
filePath: "src/index.ts",
});
const searchItem = mapClaudeRunningToolCall({
callId: "claude-running-search",
name: "web_search",
input: { query: "tool call mapping" },
output: null,
});
const searchItem = expectMapped(
mapClaudeRunningToolCall({
callId: "claude-running-search",
name: "web_search",
input: { query: "tool call mapping" },
output: null,
})
);
expect(searchItem.detail).toEqual({
type: "search",
query: "tool call mapping",
@@ -73,12 +91,14 @@ describe("claude tool-call mapper", () => {
});
it("maps completed read calls with detail enrichment", () => {
const item = mapClaudeCompletedToolCall({
callId: "claude-call-2",
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
});
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-call-2",
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -91,33 +111,37 @@ describe("claude tool-call mapper", () => {
});
it("preserves read content from array/object output variants", () => {
const arrayContent = mapClaudeCompletedToolCall({
callId: "claude-read-array",
name: "read_file",
input: { file_path: "README.md" },
output: {
content: [
{ type: "output_text", text: "alpha" },
{ type: "output_text", content: "beta" },
],
},
});
const arrayContent = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-read-array",
name: "read_file",
input: { file_path: "README.md" },
output: {
content: [
{ type: "output_text", text: "alpha" },
{ type: "output_text", content: "beta" },
],
},
})
);
expect(arrayContent.detail?.type).toBe("read");
if (arrayContent.detail?.type === "read") {
expect(arrayContent.detail.content).toBe("alpha\nbeta");
}
const objectContent = mapClaudeCompletedToolCall({
callId: "claude-read-object",
name: "read_file",
input: { file_path: "README.md" },
output: {
structured_content: {
content: { type: "output_text", text: "gamma" },
const objectContent = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-read-object",
name: "read_file",
input: { file_path: "README.md" },
output: {
structured_content: {
content: { type: "output_text", text: "gamma" },
},
},
},
});
})
);
expect(objectContent.detail?.type).toBe("read");
if (objectContent.detail?.type === "read") {
@@ -126,13 +150,15 @@ describe("claude tool-call mapper", () => {
});
it("maps failed calls with required error", () => {
const item = mapClaudeFailedToolCall({
callId: "claude-call-3",
name: "shell",
input: { command: "false" },
output: null,
error: { message: "Command failed" },
});
const item = expectMapped(
mapClaudeFailedToolCall({
callId: "claude-call-3",
name: "shell",
input: { command: "false" },
output: null,
error: { message: "Command failed" },
})
);
expect(item.status).toBe("failed");
expect(item.error).toEqual({ message: "Command failed" });
@@ -140,35 +166,41 @@ describe("claude tool-call mapper", () => {
});
it("maps write/edit/search known shapes with distinct detail types", () => {
const writeItem = mapClaudeCompletedToolCall({
callId: "claude-write-1",
name: "write_file",
input: { file_path: "src/new.ts", content: "export const x = 1;" },
output: null,
});
const writeItem = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-write-1",
name: "write_file",
input: { file_path: "src/new.ts", content: "export const x = 1;" },
output: null,
})
);
expect(writeItem.detail?.type).toBe("write");
if (writeItem.detail?.type === "write") {
expect(writeItem.detail.filePath).toBe("src/new.ts");
}
const editItem = mapClaudeCompletedToolCall({
callId: "claude-edit-1",
name: "apply_patch",
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
output: null,
});
const editItem = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-edit-1",
name: "apply_patch",
input: { file_path: "src/index.ts", patch: "@@\\n-old\\n+new\\n" },
output: null,
})
);
expect(editItem.detail?.type).toBe("edit");
if (editItem.detail?.type === "edit") {
expect(editItem.detail.filePath).toBe("src/index.ts");
expect(editItem.detail.unifiedDiff).toContain("@@");
}
const searchItem = mapClaudeCompletedToolCall({
callId: "claude-search-1",
name: "web_search",
input: { query: "tool call mapping" },
output: null,
});
const searchItem = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-search-1",
name: "web_search",
input: { query: "tool call mapping" },
output: null,
})
);
expect(searchItem.detail).toEqual({
type: "search",
query: "tool call mapping",
@@ -176,12 +208,14 @@ describe("claude tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = mapClaudeCompletedToolCall({
callId: "claude-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
});
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -191,4 +225,33 @@ describe("claude tool-call mapper", () => {
output: { ok: true },
});
});
it("normalizes claude speak tool names through schema transforms", () => {
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-speak-1",
name: "mcp__paseo__speak",
input: { text: "Voice response from Claude." },
output: { ok: true },
})
);
expect(item.name).toBe("speak");
expect(item.detail).toEqual({
type: "unknown",
input: "Voice response from Claude.",
output: null,
});
});
it("drops tool calls when callId is missing", () => {
const item = mapClaudeCompletedToolCall({
callId: null,
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
});
expect(item).toBeNull();
});
});

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
type MapperParams = {
@@ -12,87 +11,152 @@ type MapperParams = {
metadata?: Record<string, unknown>;
};
const ClaudeMapperParamsSchema = z
const ClaudeToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const ClaudeRawToolCallSchema = z
.object({
callId: z.string().optional().nullable(),
name: z.string().min(1),
input: z.unknown().optional(),
output: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
error: z.unknown().nullable().optional(),
status: ClaudeToolCallStatusSchema,
})
.passthrough();
const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({
error: z.unknown(),
const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
callId:
typeof raw.callId === "string" && raw.callId.trim().length > 0
? raw.callId
: null,
name: raw.name.trim(),
input: raw.input ?? null,
output: raw.output ?? null,
metadata: raw.metadata,
error: raw.error ?? null,
status: raw.status,
}));
const ClaudeToolCallPass2BaseSchema = z.object({
callId: z.string().min(1),
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
error: z.unknown().nullable(),
status: ClaudeToolCallStatusSchema,
toolKind: z.enum(["speak", "other"]),
});
function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string {
return coerceToolCallId({
providerPrefix: "claude",
rawCallId: callId,
toolName: name,
input,
});
}
const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
toolKind: true,
});
function buildBase(params: MapperParams): {
callId: string;
name: string;
detail: Extract<ToolCallTimelineItem, { type: "tool_call" }>["detail"];
metadata?: Record<string, unknown>;
} {
const parsedParams = ClaudeMapperParamsSchema.parse(params);
const input = parsedParams.input ?? null;
const output = parsedParams.output ?? null;
const detail = deriveClaudeToolDetail(parsedParams.name, input, output);
const ClaudeToolCallPass2EnvelopeSchema = z.union([
ClaudeToolCallPass2InputSchema.extend({
name: z.literal("mcp__paseo__speak"),
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "speak" as const,
})),
ClaudeToolCallPass2InputSchema.transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "other" as const,
})),
]);
const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"),
name: z.literal("mcp__paseo__speak"),
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("other"),
}),
]);
type ClaudeToolCallPass2 = z.infer<typeof ClaudeToolCallPass2Schema>;
function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem {
const name = normalized.toolKind === "speak" ? ("speak" as const) : normalized.name;
const detail = deriveClaudeToolDetail(name, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
callId: normalized.callId,
name,
detail,
status: "failed",
error: normalized.error ?? { message: "Tool call failed" },
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
return {
callId: coerceCallId(parsedParams.callId, parsedParams.name, input),
name: parsedParams.name,
type: "tool_call",
callId: normalized.callId,
name,
detail,
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
status: normalized.status,
error: null,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "running",
error: null,
};
function mapClaudeToolCall(
params: MapperParams,
status: z.infer<typeof ClaudeToolCallStatusSchema>,
error: unknown | null
): ToolCallTimelineItem | null {
const pass1 = ClaudeToolCallPass1Schema.safeParse({
...params,
status,
error,
});
if (!pass1.success) {
return null;
}
const pass2Envelope = ClaudeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
if (!pass2Envelope.success) {
return null;
}
const pass2 = ClaudeToolCallPass2Schema.safeParse(pass2Envelope.data);
if (!pass2.success) {
return null;
}
return toToolCallTimelineItem(pass2.data);
}
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "completed",
error: null,
};
export function mapClaudeRunningToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "running", null);
}
export function mapClaudeCompletedToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "completed", null);
}
export function mapClaudeFailedToolCall(
params: MapperParams & { error: unknown }
): ToolCallTimelineItem {
const parsedParams = ClaudeFailedMapperParamsSchema.parse(params);
const base = buildBase(parsedParams);
return {
type: "tool_call",
...base,
status: "failed",
error: parsedParams.error,
};
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "failed", params.error);
}
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "canceled",
error: null,
};
export function mapClaudeCanceledToolCall(
params: MapperParams
): ToolCallTimelineItem | null {
return mapClaudeToolCall(params, "canceled", null);
}

View File

@@ -982,6 +982,9 @@ function mapCodexExecNotificationToToolCall(params: {
: null,
cwd: params.cwd ?? null,
});
if (!mapped) {
return null;
}
return params.running ? toRunningToolCall(mapped) : mapped;
}
@@ -993,7 +996,7 @@ function mapCodexPatchNotificationToToolCall(params: {
stderr?: string | null;
success?: boolean | null;
running: boolean;
}): ToolCallTimelineItem {
}): ToolCallTimelineItem | null {
const files = parseCodexPatchChanges(params.changes);
const firstPath = files[0]?.path;
const firstPatchText = files
@@ -1038,6 +1041,9 @@ function mapCodexPatchNotificationToToolCall(params: {
: { message: params.stderr?.trim() || "Patch apply failed" },
cwd: params.cwd ?? null,
});
if (!mapped) {
return null;
}
return params.running ? toRunningToolCall(mapped) : mapped;
}
@@ -1829,7 +1835,7 @@ class CodexAppServerAgentSession implements AgentSession {
developer_instructions: entry.developer_instructions ?? null,
}));
} catch (error) {
this.logger.debug({ error }, "Failed to load collaboration modes");
this.logger.trace({ error }, "Failed to load collaboration modes");
this.collaborationModes = [];
}
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
@@ -1856,7 +1862,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
this.cachedSkills = skills;
} catch (error) {
this.logger.debug({ error }, "Failed to load skills list");
this.logger.trace({ error }, "Failed to load skills list");
this.cachedSkills = [];
}
}
@@ -2565,11 +2571,13 @@ class CodexAppServerAgentSession implements AgentSession {
cwd: this.config.cwd ?? null,
running: true,
});
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
callId: parsed.callId,
changes: parsed.changes,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem) {
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
callId: parsed.callId,
changes: parsed.changes,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
@@ -2587,12 +2595,14 @@ class CodexAppServerAgentSession implements AgentSession {
success: parsed.success,
running: false,
});
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
callId: parsed.callId,
changes: parsed.changes,
stdout: parsed.stdout,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem) {
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
callId: parsed.callId,
changes: parsed.changes,
stdout: parsed.stdout,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
@@ -2675,7 +2685,7 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
this.warnedUnknownNotificationMethods.add(method);
this.logger.warn({ method, params }, "Unhandled Codex app-server notification method");
this.logger.trace({ method, params }, "Unhandled Codex app-server notification method");
}
private warnInvalidNotificationPayload(method: string, params: unknown): void {

View File

@@ -503,14 +503,15 @@ export async function parseRolloutFile(
record.kind === "timeline"
? [record.item]
: record.kind === "call"
? [
mapCodexRolloutToolCall({
? (() => {
const mapped = mapCodexRolloutToolCall({
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
}),
]
});
return mapped ? [mapped] : [];
})()
: []
);
return dedupeMirroredTextTimelineItems(timeline);

View File

@@ -24,23 +24,43 @@ export type CodexToolDetailContext = {
cwd?: string | null;
};
export const CODEX_BUILTIN_TOOL_NAMES = new Set([
"shell",
"bash",
"exec",
"exec_command",
"command",
"read",
"read_file",
"write",
"write_file",
"create_file",
"edit",
"apply_patch",
"apply_diff",
"web_search",
"search",
]);
const CodexToolEnvelopeSchema = z
.object({
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().nullable().optional(),
})
.passthrough();
const CodexSpeakToolDetailSchema = z
.object({
name: z.literal("speak"),
input: z
.union([
z.string().transform((text) => ({ text })),
z.object({ text: z.string() }).passthrough(),
])
.nullable(),
output: z.unknown().nullable(),
cwd: z.string().nullable().optional(),
})
.transform(({ input }) => {
const text = input?.text?.trim() ?? "";
if (!text) {
return undefined;
}
return {
type: "unknown",
input: text,
output: null,
} satisfies ToolCallDetail;
});
const CodexLooseEditOutputSchema = z.unknown().transform((value) => {
const parsed = ToolEditOutputSchema.safeParse(value);
return parsed.success ? parsed.data : null;
});
export function normalizeCodexFilePath(
filePath: string,
@@ -60,7 +80,7 @@ function normalizePathForCwd(cwd: string | null): (filePath: string) => string |
return (filePath) => normalizeCodexFilePath(filePath, cwd);
}
const CodexKnownToolDetailSchema = z.union([
const CodexToolDetailPass2Schema = z.union([
toolDetailBranchByNameWithCwd("Bash", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
),
@@ -82,14 +102,22 @@ const CodexKnownToolDetailSchema = z.union([
toolDetailBranchByNameWithCwd("command", ToolShellInputSchema, ToolShellOutputSchema, (input, output) =>
toShellToolDetail(input, output)
),
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, ToolReadOutputWithPathSchema, (input, output, cwd) =>
toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("read", ToolReadInputSchema, z.unknown(), (input, output, cwd) => {
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
normalizePath: normalizePathForCwd(cwd),
});
}),
toolDetailBranchByNameWithCwd(
"read_file",
ToolReadInputSchema,
ToolReadOutputWithPathSchema,
(input, output, cwd) => toReadToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
z.unknown(),
(input, output, cwd) => {
const parsedOutput = ToolReadOutputWithPathSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null, {
normalizePath: normalizePathForCwd(cwd),
});
}
),
toolDetailBranchByNameWithCwd("write", ToolWriteInputSchema, ToolWriteOutputSchema, (input, output, cwd) =>
toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
@@ -106,19 +134,19 @@ const CodexKnownToolDetailSchema = z.union([
ToolWriteOutputSchema,
(input, output, cwd) => toWriteToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, ToolEditOutputSchema, (input, output, cwd) =>
toolDetailBranchByNameWithCwd("edit", ToolEditInputSchema, CodexLooseEditOutputSchema, (input, output, cwd) =>
toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd(
"apply_patch",
ToolEditInputSchema,
ToolEditOutputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd(
"apply_diff",
ToolEditInputSchema,
ToolEditOutputSchema,
CodexLooseEditOutputSchema,
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
),
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
@@ -127,6 +155,7 @@ const CodexKnownToolDetailSchema = z.union([
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input)
),
CodexSpeakToolDetailSchema,
]);
export function deriveCodexToolDetail(params: {
@@ -135,18 +164,28 @@ export function deriveCodexToolDetail(params: {
output: unknown;
cwd?: string | null;
}): ToolCallDetail {
const parsed = CodexKnownToolDetailSchema.safeParse({
const pass1 = CodexToolEnvelopeSchema.safeParse({
name: params.name,
input: params.input,
output: params.output,
cwd: params.cwd ?? null,
});
if (parsed.success && parsed.data) {
return parsed.data;
}
return {
type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
cwd: params.cwd ?? null,
});
if (!pass1.success) {
return {
type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
};
}
const pass2 = CodexToolDetailPass2Schema.safeParse(pass1.data);
if (pass2.success && pass2.data) {
return pass2.data;
}
return {
type: "unknown",
input: pass1.data.input,
output: pass1.data.output,
};
}

View File

@@ -5,6 +5,14 @@ import {
mapCodexToolCallFromThreadItem,
} from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
if (!item) {
throw new Error("Expected mapped tool call");
}
return item;
}
describe("codex tool-call mapper", () => {
it("maps commandExecution start into running canonical call", () => {
const item = mapCodexToolCallFromThreadItem({
@@ -301,12 +309,14 @@ describe("codex tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = mapCodexRolloutToolCall({
callId: "codex-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -327,13 +337,15 @@ describe("codex tool-call mapper", () => {
"+new",
"*** End Patch",
].join("\n");
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply",
name: "apply_patch",
input: patch,
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
cwd: "/tmp/repo",
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-apply",
name: "apply_patch",
input: patch,
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
cwd: "/tmp/repo",
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -359,16 +371,18 @@ describe("codex tool-call mapper", () => {
"*** End Patch",
].join("\n");
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply-object",
name: "apply_patch",
input: {
path: "/tmp/repo/src/object.ts",
content: patch,
},
output: null,
cwd: "/tmp/repo",
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-apply-object",
name: "apply_patch",
input: {
path: "/tmp/repo/src/object.ts",
content: patch,
},
output: null,
cwd: "/tmp/repo",
})
);
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
@@ -412,58 +426,6 @@ describe("codex tool-call mapper", () => {
}
});
it("maps fileChange patch alias fields into edit unified diff detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-change-patch-alias",
status: "completed",
changes: [
{
path: "/tmp/repo/src/from-patch-alias.ts",
kind: "modify",
patch: "@@\n-oldAlias\n+newAlias\n",
},
],
},
{ cwd: "/tmp/repo" }
);
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/from-patch-alias.ts");
expect(item.detail.unifiedDiff).toContain("-oldAlias");
expect(item.detail.unifiedDiff).toContain("+newAlias");
expect(item.detail.newString).toBeUndefined();
}
});
it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-change-unified-diff-alias",
status: "completed",
changes: [
{
path: "/tmp/repo/src/from-unified-diff-alias.ts",
kind: "modify",
unified_diff: "@@\n-beforeAlias\n+afterAlias\n",
},
],
},
{ cwd: "/tmp/repo" }
);
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts");
expect(item.detail.unifiedDiff).toContain("-beforeAlias");
expect(item.detail.unifiedDiff).toContain("+afterAlias");
expect(item.detail.newString).toBeUndefined();
}
});
it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
@@ -484,17 +446,80 @@ describe("codex tool-call mapper", () => {
});
it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => {
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply-path-only",
name: "apply_patch",
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
cwd: "/tmp/repo",
});
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-call-apply-path-only",
name: "apply_patch",
input: { path: "/tmp/repo/src/path-only-rollout.ts" },
output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] },
cwd: "/tmp/repo",
})
);
expect(item.detail.type).toBe("unknown");
if (item.detail.type === "unknown") {
expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" });
}
});
it("normalizes codex paseo speak mcp calls and extracts spoken text", () => {
const item = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
id: "codex-speak-thread-1",
status: "completed",
server: "paseo",
tool: "speak",
arguments: { text: "Voice response from Codex." },
result: { ok: true },
});
expect(item).toBeTruthy();
expect(item?.name).toBe("speak");
expect(item?.detail).toEqual({
type: "unknown",
input: "Voice response from Codex.",
output: null,
});
});
it("normalizes codex paseo speak rollout names and extracts spoken text", () => {
const item = expectMapped(
mapCodexRolloutToolCall({
callId: "codex-speak-rollout-1",
name: "paseo.speak",
input: { text: "Rollout speech text." },
output: { ok: true },
})
);
expect(item.name).toBe("speak");
expect(item.detail).toEqual({
type: "unknown",
input: "Rollout speech text.",
output: null,
});
});
it("drops rollout tool calls when callId is missing", () => {
const item = mapCodexRolloutToolCall({
callId: null,
name: "read_file",
input: { path: "/tmp/repo/README.md" },
output: { content: "hello" },
});
expect(item).toBeNull();
});
it("drops thread mcp tool calls when id is missing", () => {
const item = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
status: "completed",
tool: "read_file",
arguments: { path: "/tmp/repo/README.md" },
result: { content: "hello" },
});
expect(item).toBeNull();
});
});

View File

@@ -1,14 +1,11 @@
import { z } from "zod";
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { CommandValueSchema } from "../tool-call-detail-primitives.js";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import {
coerceToolCallId,
extractCodexShellOutput,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import {
CODEX_BUILTIN_TOOL_NAMES,
deriveCodexToolDetail,
normalizeCodexFilePath,
} from "./tool-call-detail-parser.js";
@@ -18,6 +15,14 @@ type CodexMapperOptions = { cwd?: string | null };
const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
const CodexToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const CodexRolloutToolCallParamsSchema = z
.object({
@@ -29,6 +34,159 @@ const CodexRolloutToolCallParamsSchema = z
})
.passthrough();
type CodexNormalizedToolCallEnvelope = {
callId: string;
name: string;
input?: unknown | null;
output?: unknown | null;
status?: ToolCallTimelineItem["status"];
error?: unknown | null;
metadata?: Record<string, unknown>;
cwd?: string | null;
};
const CodexNormalizedToolCallPass1Schema = z
.object({
callId: z.string().min(1),
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
status: CodexToolCallStatusSchema,
error: z.unknown().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
cwd: z.string().nullable().optional(),
})
.passthrough();
const CodexShellToolNameSchema = z.union([
z.literal("Bash"),
z.literal("shell"),
z.literal("bash"),
z.literal("exec"),
z.literal("exec_command"),
z.literal("command"),
]);
const CodexReadToolNameSchema = z.union([z.literal("read"), z.literal("read_file")]);
const CodexWriteToolNameSchema = z.union([
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
]);
const CodexEditToolNameSchema = z.union([
z.literal("edit"),
z.literal("apply_patch"),
z.literal("apply_diff"),
]);
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
const CodexSpeakToolNameSchema = z.literal("paseo.speak");
const CodexToolKindSchema = z.enum(["shell", "read", "write", "edit", "search", "speak", "unknown"]);
const CodexToolCallPass2BaseSchema = CodexNormalizedToolCallPass1Schema.extend({
toolKind: CodexToolKindSchema,
});
const CodexToolCallPass2EnvelopeSchema = z.union([
CodexNormalizedToolCallPass1Schema.extend({
name: CodexShellToolNameSchema,
}).transform((envelope) => ({ ...envelope, toolKind: "shell" as const })),
CodexNormalizedToolCallPass1Schema.extend({
name: CodexReadToolNameSchema,
}).transform((envelope) => ({ ...envelope, toolKind: "read" as const })),
CodexNormalizedToolCallPass1Schema.extend({
name: CodexWriteToolNameSchema,
}).transform((envelope) => ({ ...envelope, toolKind: "write" as const })),
CodexNormalizedToolCallPass1Schema.extend({
name: CodexEditToolNameSchema,
}).transform((envelope) => ({ ...envelope, toolKind: "edit" as const })),
CodexNormalizedToolCallPass1Schema.extend({
name: CodexSearchToolNameSchema,
}).transform((envelope) => ({ ...envelope, toolKind: "search" as const })),
CodexNormalizedToolCallPass1Schema.extend({
name: CodexSpeakToolNameSchema,
}).transform((envelope) => ({ ...envelope, toolKind: "speak" as const })),
CodexNormalizedToolCallPass1Schema.transform((envelope) => ({
...envelope,
name: envelope.name.trim(),
toolKind: "unknown" as const,
})),
]);
const CodexNormalizedToolCallPass2Schema = z.discriminatedUnion("toolKind", [
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("shell"),
name: CodexShellToolNameSchema,
}),
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("read"),
name: CodexReadToolNameSchema,
}),
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("write"),
name: CodexWriteToolNameSchema,
}),
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("edit"),
name: CodexEditToolNameSchema,
}),
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("search"),
name: CodexSearchToolNameSchema,
}),
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"),
name: CodexSpeakToolNameSchema,
}),
CodexToolCallPass2BaseSchema.extend({
toolKind: z.literal("unknown"),
}),
]);
type CodexNormalizedToolCallPass2 = z.infer<typeof CodexNormalizedToolCallPass2Schema>;
function toToolCallTimelineItem(envelope: CodexNormalizedToolCallPass2): ToolCallTimelineItem {
const name = envelope.toolKind === "speak" ? ("speak" as const) : envelope.name;
const parsedDetail = deriveCodexToolDetail({
name,
input: envelope.input,
output: envelope.output,
cwd: envelope.cwd ?? null,
});
const detail: ToolCallTimelineItem["detail"] =
envelope.toolKind === "edit" &&
envelope.status !== "running" &&
!hasRenderableEditDetail(parsedDetail)
? {
type: "unknown",
input: envelope.input,
output: envelope.output,
}
: parsedDetail;
if (envelope.status === "failed") {
return {
type: "tool_call",
callId: envelope.callId,
name,
status: "failed",
error: envelope.error ?? { message: "Tool call failed" },
detail,
...(envelope.metadata ? { metadata: envelope.metadata } : {}),
};
}
return {
type: "tool_call",
callId: envelope.callId,
name,
status: envelope.status,
error: null,
detail,
...(envelope.metadata ? { metadata: envelope.metadata } : {}),
};
}
// ---------------------------------------------------------------------------
// Thread-item parsing
// ---------------------------------------------------------------------------
@@ -36,10 +194,10 @@ const CodexRolloutToolCallParamsSchema = z
const CodexCommandExecutionItemSchema = z
.object({
type: z.literal("commandExecution"),
id: z.string().optional(),
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
command: CommandValueSchema.optional(),
command: CodexCommandValueSchema.optional(),
cwd: z.string().optional(),
aggregatedOutput: z.string().optional(),
exitCode: z.number().nullable().optional(),
@@ -49,7 +207,7 @@ const CodexCommandExecutionItemSchema = z
const CodexFileChangeItemSchema = z
.object({
type: z.literal("fileChange"),
id: z.string().optional(),
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
changes: z
@@ -60,10 +218,7 @@ const CodexFileChangeItemSchema = z
kind: z.string().optional(),
diff: z.string().optional(),
patch: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
content: z.string().optional(),
newString: z.string().optional(),
})
.passthrough()
)
@@ -74,12 +229,10 @@ const CodexFileChangeItemSchema = z
const CodexMcpToolCallItemSchema = z
.object({
type: z.literal("mcpToolCall"),
id: z.string().optional(),
callID: z.string().optional(),
call_id: z.string().optional(),
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
tool: z.string().optional(),
tool: z.string().min(1),
server: z.string().optional(),
arguments: z.unknown().optional(),
result: z.unknown().optional(),
@@ -89,7 +242,7 @@ const CodexMcpToolCallItemSchema = z
const CodexWebSearchItemSchema = z
.object({
type: z.literal("webSearch"),
id: z.string().optional(),
id: z.string().min(1),
status: z.string().optional(),
error: z.unknown().optional(),
query: z.string().optional(),
@@ -104,15 +257,6 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
CodexWebSearchItemSchema,
]);
function coerceCallId(raw: string | null | undefined, name: string, input: unknown): string {
return coerceToolCallId({
providerPrefix: "codex",
rawCallId: raw,
toolName: name,
input,
});
}
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const wrapperMatch = trimmed.match(
@@ -174,6 +318,10 @@ type CodexApplyPatchDirective = {
path: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective | null {
const trimmed = line.trim();
if (trimmed.startsWith("*** Add File:")) {
@@ -188,6 +336,16 @@ function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective |
return null;
}
function extractPatchPrimaryFilePath(patch: string): string | undefined {
for (const line of patch.split(/\r?\n/)) {
const directive = parseCodexApplyPatchDirective(line);
if (directive && directive.path.length > 0) {
return directive.path;
}
}
return undefined;
}
function looksLikeCodexApplyPatch(text: string): boolean {
const normalized = text.trimStart();
if (!normalized) {
@@ -285,6 +443,64 @@ function asEditTextFields(
return { newString: text };
}
function normalizeRolloutEditInput(input: unknown): unknown {
if (typeof input === "string") {
const textFields = asEditTextFields(input);
const path = extractPatchPrimaryFilePath(input);
return {
...(path ? { path } : {}),
...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
...(textFields.newString ? { content: textFields.newString } : {}),
};
}
if (!isRecord(input)) {
return input;
}
const candidatePatchText =
(typeof input.patch === "string" && input.patch) ||
(typeof input.diff === "string" && input.diff) ||
(typeof input.unified_diff === "string" && input.unified_diff) ||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
(typeof input.content === "string" && input.content) ||
undefined;
if (!candidatePatchText) {
return input;
}
const textFields = asEditTextFields(candidatePatchText);
const rawPath =
(typeof input.path === "string" && input.path.trim().length > 0 ? input.path : undefined) ||
(typeof input.file_path === "string" && input.file_path.trim().length > 0
? input.file_path
: undefined) ||
(typeof input.filePath === "string" && input.filePath.trim().length > 0
? input.filePath
: undefined) ||
extractPatchPrimaryFilePath(candidatePatchText);
const {
patch: _patch,
diff: _diff,
unified_diff: _unifiedDiffSnake,
unifiedDiff: _unifiedDiffCamel,
...rest
} = input;
const normalized: Record<string, unknown> = {
...rest,
...(rawPath ? { path: rawPath } : {}),
...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
...(textFields.newString ? { content: textFields.newString } : {}),
};
if (textFields.unifiedDiff && "content" in normalized) {
delete normalized.content;
}
return normalized;
}
function asEditFileOutputFields(
text: string | undefined
): { patch?: string; content?: string } {
@@ -298,28 +514,6 @@ function asEditFileOutputFields(
return { content: text };
}
function asPatchOrContentFields(text: string | undefined): { patch?: string; content?: string } {
if (typeof text !== "string" || text.length === 0) {
return {};
}
const classified = classifyDiffLikeText(text);
if (classified.isDiff) {
return { patch: truncateDiffText(classified.text) };
}
return { content: text };
}
function hasRenderableEditContent(detail: ToolCallDetail): boolean {
if (detail.type !== "edit") {
return false;
}
return (
(typeof detail.unifiedDiff === "string" && detail.unifiedDiff.length > 0) ||
(typeof detail.newString === "string" && detail.newString.length > 0) ||
(typeof detail.oldString === "string" && detail.oldString.length > 0)
);
}
function pickFirstPatchLikeString(values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
@@ -329,15 +523,15 @@ function pickFirstPatchLikeString(values: unknown[]): string | undefined {
return undefined;
}
function removePatchLikeFields(input: Record<string, unknown>): Record<string, unknown> {
const {
patch: _patch,
diff: _diff,
unified_diff: _unifiedDiffSnake,
unifiedDiff: _unifiedDiffCamel,
...rest
} = input;
return rest;
function hasRenderableEditDetail(detail: ToolCallTimelineItem["detail"]): boolean {
if (detail.type !== "edit") {
return true;
}
return (
(typeof detail.unifiedDiff === "string" && detail.unifiedDiff.trim().length > 0) ||
(typeof detail.newString === "string" && detail.newString.trim().length > 0) ||
(typeof detail.oldString === "string" && detail.oldString.trim().length > 0)
);
}
function resolveStatus(
@@ -368,47 +562,12 @@ function resolveStatus(
return output !== null && output !== undefined ? "completed" : "running";
}
function buildToolCall(params: {
callId: string;
name: string;
status: ToolCallTimelineItem["status"];
error: unknown | null;
detail: ToolCallDetail;
metadata?: Record<string, unknown>;
}): ToolCallTimelineItem {
if (params.status === "failed") {
return {
type: "tool_call",
callId: params.callId,
name: params.name,
status: "failed",
error: params.error ?? { message: "Tool call failed" },
detail: params.detail,
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
return {
type: "tool_call",
callId: params.callId,
name: params.name,
status: params.status,
error: null,
detail: params.detail,
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
function buildMcpToolName(server: string | undefined, tool: string): string {
const trimmedTool = tool.trim();
if (!trimmedTool) {
return "tool";
}
if (CODEX_BUILTIN_TOOL_NAMES.has(trimmedTool)) {
return trimmedTool;
}
const trimmedServer = typeof server === "string" ? server.trim() : "";
if (trimmedServer.length > 0) {
return `${trimmedServer}.${trimmedTool}`;
@@ -421,109 +580,23 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
return Object.keys(value).length > 0 ? value : null;
}
function extractPatchPrimaryFilePath(patch: string): string | undefined {
for (const line of patch.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith("*** Add File:")) {
return trimmed.replace("*** Add File:", "").trim();
}
if (trimmed.startsWith("*** Update File:")) {
return trimmed.replace("*** Update File:", "").trim();
}
if (trimmed.startsWith("*** Delete File:")) {
return trimmed.replace("*** Delete File:", "").trim();
}
}
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeApplyPatchInput(input: unknown): unknown {
if (typeof input === "string") {
const filePath = extractPatchPrimaryFilePath(input);
const textFields = asPatchOrContentFields(input);
return filePath ? { path: filePath, ...textFields } : textFields;
}
if (!isRecord(input)) {
return input;
}
const existingPath =
(typeof input.path === "string" && input.path.trim().length > 0 && input.path.trim()) ||
(typeof input.file_path === "string" &&
input.file_path.trim().length > 0 &&
input.file_path.trim()) ||
(typeof input.filePath === "string" &&
input.filePath.trim().length > 0 &&
input.filePath.trim());
const patchText =
(typeof input.patch === "string" && input.patch) ||
(typeof input.diff === "string" && input.diff) ||
(typeof input.unified_diff === "string" && input.unified_diff) ||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
undefined;
const contentText = typeof input.content === "string" ? input.content : undefined;
const inferredPatchFromContent = !patchText && typeof contentText === "string" ? contentText : undefined;
const patchOrContentText = patchText ?? inferredPatchFromContent;
if (existingPath && !patchOrContentText) {
return input;
}
if (!patchOrContentText) {
return input;
}
const base = removePatchLikeFields(input);
if (inferredPatchFromContent) {
delete (base as { content?: unknown }).content;
}
const filePath = existingPath || extractPatchPrimaryFilePath(patchOrContentText);
const textFields = asPatchOrContentFields(patchOrContentText);
return filePath ? { ...base, path: filePath, ...textFields } : { ...base, ...textFields };
}
function deriveApplyPatchDetailFromInput(
input: unknown,
cwd: string | null | undefined
): ToolCallDetail | null {
if (!isRecord(input)) {
function toToolCallFromNormalizedEnvelope(
envelope: CodexNormalizedToolCallEnvelope
): ToolCallTimelineItem | null {
const pass2Envelope = CodexToolCallPass2EnvelopeSchema.safeParse(envelope);
if (!pass2Envelope.success) {
return null;
}
const pathValue =
(typeof input.path === "string" && input.path.trim()) ||
(typeof input.file_path === "string" && input.file_path.trim()) ||
(typeof input.filePath === "string" && input.filePath.trim()) ||
"";
if (!pathValue) {
const parsed = CodexNormalizedToolCallPass2Schema.safeParse(pass2Envelope.data);
if (!parsed.success) {
return null;
}
const normalizedPath = normalizeCodexFilePath(pathValue, cwd) ?? pathValue;
const diffText =
(typeof input.patch === "string" && input.patch) ||
(typeof input.diff === "string" && input.diff) ||
(typeof input.unified_diff === "string" && input.unified_diff) ||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
(typeof input.content === "string" && input.content) ||
undefined;
const textFields = asEditTextFields(diffText);
return {
type: "edit",
filePath: normalizedPath,
...textFields,
};
return toToolCallTimelineItem(parsed.data);
}
function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema>
): ToolCallTimelineItem {
): CodexNormalizedToolCallEnvelope {
const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
const input = toNullableObject({
@@ -540,38 +613,25 @@ function mapCommandExecutionItem(
}
: null;
const detail = command
? {
type: "shell" as const,
command,
...(item.cwd ? { cwd: item.cwd } : {}),
...(parsedOutput ? { output: parsedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
}
: {
type: "unknown" as const,
input,
output,
};
const name = "shell";
const callId = coerceCallId(item.id, name, input);
const error = item.error ?? null;
const status = resolveStatus(item.status, error, output);
return buildToolCall({
callId,
return {
callId: item.id,
name,
input,
output,
status,
error,
detail,
});
cwd: item.cwd ?? null,
};
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions
): ToolCallTimelineItem {
): CodexNormalizedToolCallEnvelope {
const changes = item.changes ?? [];
const files = changes
@@ -587,16 +647,13 @@ function mapFileChangeItem(
diff: pickFirstPatchLikeString([
change.diff,
change.patch,
change.unified_diff,
change.unifiedDiff,
change.content,
change.newString,
]),
};
})
.filter((change) => change.path !== undefined);
const input = toNullableObject({
const inputBase = {
...(files.length > 0
? {
files: files.map((file) => ({
@@ -605,7 +662,7 @@ function mapFileChangeItem(
})),
}
: {}),
});
};
const output = toNullableObject({
...(files.length > 0
@@ -619,112 +676,97 @@ function mapFileChangeItem(
: {}),
});
const name = "apply_patch";
const error = item.error ?? null;
const status = resolveStatus(item.status, error, output);
const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const detail = firstFile?.path
? hasFirstTextFields
? {
type: "edit" as const,
filePath: firstFile.path,
...firstTextFields,
}
: {
type: "unknown" as const,
input,
output,
}
: {
type: "unknown" as const,
input,
output,
};
const input = toNullableObject({
...inputBase,
...(firstFile?.path && hasFirstTextFields ? { path: firstFile.path } : {}),
...(hasFirstTextFields && firstTextFields.unifiedDiff
? { patch: firstTextFields.unifiedDiff }
: {}),
...(hasFirstTextFields && firstTextFields.newString
? { content: firstTextFields.newString }
: {}),
});
const name = "apply_patch";
const callId = coerceCallId(item.id, name, input);
const error = item.error ?? null;
const status = resolveStatus(item.status, error, output);
return buildToolCall({
callId,
return {
callId: item.id,
name,
input,
output,
status,
error,
detail,
});
cwd: options?.cwd ?? null,
};
}
function mapMcpToolCallItem(
item: z.infer<typeof CodexMcpToolCallItemSchema>,
options?: CodexMapperOptions
): ToolCallTimelineItem {
const tool = item.tool?.trim() || "tool";
): CodexNormalizedToolCallEnvelope | null {
const tool = item.tool.trim();
if (!tool) {
return null;
}
const name = buildMcpToolName(item.server, tool);
const input = item.arguments ?? null;
const output = item.result ?? null;
const error = item.error ?? null;
const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input);
const status = resolveStatus(item.status, error, output);
const detail = deriveCodexToolDetail({
name: tool,
return {
callId: item.id,
name,
input,
output,
cwd: options?.cwd ?? null,
});
return buildToolCall({
callId,
name,
status,
error,
detail,
});
cwd: options?.cwd ?? null,
};
}
function mapWebSearchItem(item: z.infer<typeof CodexWebSearchItemSchema>): ToolCallTimelineItem {
function mapWebSearchItem(
item: z.infer<typeof CodexWebSearchItemSchema>
): CodexNormalizedToolCallEnvelope {
const input = item.query !== undefined ? { query: item.query } : null;
const output = item.action ?? null;
const name = "web_search";
const callId = coerceCallId(item.id, name, input);
const error = item.error ?? null;
const status = resolveStatus(item.status ?? "completed", error, output);
const detail = item.query
? {
type: "search" as const,
query: item.query,
}
: {
type: "unknown" as const,
input,
output,
};
return buildToolCall({
callId,
return {
callId: item.id,
name,
input,
output,
status,
error,
detail,
});
cwd: null,
};
}
function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) {
return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => {
switch (item.type) {
case "commandExecution":
return mapCommandExecutionItem(item);
case "fileChange":
return mapFileChangeItem(item, options);
case "mcpToolCall":
return mapMcpToolCallItem(item, options);
case "webSearch":
return mapWebSearchItem(item);
default: {
const exhaustiveCheck: never = item;
throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
}
function mapThreadItemToNormalizedEnvelope(
item: z.infer<typeof CodexThreadItemSchema>,
options?: CodexMapperOptions
): CodexNormalizedToolCallEnvelope | null {
switch (item.type) {
case "commandExecution":
return mapCommandExecutionItem(item);
case "fileChange":
return mapFileChangeItem(item, options);
case "mcpToolCall":
return mapMcpToolCallItem(item, options);
case "webSearch":
return mapWebSearchItem(item);
default: {
const exhaustiveCheck: never = item;
throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
}
});
}
}
// ---------------------------------------------------------------------------
@@ -735,11 +777,15 @@ export function mapCodexToolCallFromThreadItem(
item: unknown,
options?: CodexMapperOptions
): ToolCallTimelineItem | null {
const parsed = createCodexThreadItemToTimelineSchema(options).safeParse(item);
const parsed = CodexThreadItemSchema.safeParse(item);
if (!parsed.success) {
return null;
}
return parsed.data;
const envelope = mapThreadItemToNormalizedEnvelope(parsed.data, options);
if (!envelope) {
return null;
}
return toToolCallFromNormalizedEnvelope(envelope);
}
export function mapCodexRolloutToolCall(params: {
@@ -749,43 +795,36 @@ export function mapCodexRolloutToolCall(params: {
output?: unknown;
error?: unknown;
cwd?: string | null;
}): ToolCallTimelineItem {
const parsed = CodexRolloutToolCallParamsSchema.parse(params);
const rawInput = parsed.input ?? null;
const normalizedName = parsed.name.trim().toLowerCase();
const input =
normalizedName === "apply_patch" || normalizedName === "apply_diff"
? normalizeApplyPatchInput(rawInput)
: rawInput;
const output = parsed.output ?? null;
const error = parsed.error ?? null;
const status = resolveStatus("completed", error, output);
const callId = coerceCallId(parsed.callId, parsed.name, input);
let detail = deriveCodexToolDetail({
name: parsed.name,
input,
output,
cwd: params.cwd ?? null,
});
if (detail.type === "unknown" && (normalizedName === "apply_patch" || normalizedName === "apply_diff")) {
const fallbackDetail = deriveApplyPatchDetailFromInput(input, params.cwd ?? null);
if (fallbackDetail) {
detail = fallbackDetail;
}
}
if (detail.type === "edit" && !hasRenderableEditContent(detail)) {
detail = {
type: "unknown",
input,
output,
};
}): ToolCallTimelineItem | null {
const parsed = CodexRolloutToolCallParamsSchema.safeParse(params);
if (!parsed.success) {
return null;
}
return buildToolCall({
callId,
name: parsed.name,
status,
error,
detail,
const normalizedName = parsed.data.name.trim();
const normalizedInput =
normalizedName === "apply_patch" || normalizedName === "apply_diff"
? normalizeRolloutEditInput(parsed.data.input ?? null)
: parsed.data.input ?? null;
const pass1 = CodexNormalizedToolCallPass1Schema.safeParse({
callId:
typeof parsed.data.callId === "string" ? parsed.data.callId.trim() : "",
name: normalizedName,
input: normalizedInput,
output: parsed.data.output ?? null,
error: parsed.data.error ?? null,
status: resolveStatus("completed", parsed.data.error ?? null, parsed.data.output ?? null),
cwd: params.cwd ?? null,
});
if (!pass1.success) {
return null;
}
const mapped = toToolCallFromNormalizedEnvelope(pass1.data);
if (!mapped) {
return null;
}
return mapped;
}

View File

@@ -2,6 +2,7 @@ import { execSync, spawn, type ChildProcess } from "node:child_process";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
import { z } from "zod";
import type {
AgentCapabilityFlags,
@@ -69,6 +70,84 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OpencodeToolStateSchema = z
.object({
status: z.string().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
})
.passthrough();
const OpencodeToolPartBaseSchema = z
.object({
tool: z.string().trim().min(1),
state: OpencodeToolStateSchema.optional(),
})
.passthrough();
const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({
callID: z.string().trim().min(1),
id: z.string().optional(),
}).transform((part) => ({
toolName: part.tool,
callId: part.callID,
status: part.state?.status,
input: part.state?.input,
output: part.state?.output,
error: part.state?.error,
}));
const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
id: z.string().trim().min(1),
callID: z.string().optional(),
}).transform((part) => ({
toolName: part.tool,
callId: part.id,
status: part.state?.status,
input: part.state?.input,
output: part.state?.output,
error: part.state?.error,
}));
const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
id: z.string().optional(),
callID: z.string().optional(),
}).transform((part) => ({
toolName: part.tool,
callId: undefined,
status: part.state?.status,
input: part.state?.input,
output: part.state?.output,
error: part.state?.error,
}));
const OpencodeToolPartSchema = z.union([
OpencodeToolPartWithCallIdSchema,
OpencodeToolPartWithIdSchema,
OpencodeToolPartWithoutIdSchema,
]);
const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform((part) => ({
toolName: part.toolName,
callId: part.callId,
status: part.status,
input: part.input,
output: part.output,
error: part.error,
}));
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform((part) =>
mapOpencodeToolCall({
toolName: part.toolName,
callId: part.callId,
status: part.status,
input: part.input,
output: part.output,
error: part.error,
})
);
function resolveOpenCodeBinary(): string {
try {
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
@@ -650,33 +729,15 @@ class OpenCodeAgentSession implements AgentSession {
};
}
} else if (partType === "tool") {
const toolPart = part as {
id?: string;
tool?: string;
callID?: string;
state?: {
status?: string;
input?: AgentMetadata;
output?: string;
error?: string;
};
};
const toolName = toolPart.tool;
const state = toolPart.state;
if (toolName) {
yield {
type: "timeline",
provider: "opencode",
item: mapOpencodeToolCall({
toolName,
callId: toolPart.callID ?? toolPart.id,
status: state?.status,
input: state?.input,
output: state?.output,
error: state?.error,
}),
};
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success) {
if (parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
}
}
}
}
@@ -931,26 +992,15 @@ class OpenCodeAgentSession implements AgentSession {
}
} else if (partType === "tool") {
// Tool parts: { tool: string, state: { status, input, output?, error? } }
const toolName = part.tool as string | undefined;
const state = part.state as AgentMetadata | undefined;
const status = state?.status as string | undefined;
const input = state?.input as AgentMetadata | undefined;
const output = state?.output as string | undefined;
const error = state?.error as string | undefined;
if (toolName) {
events.push({
type: "timeline",
provider: "opencode",
item: mapOpencodeToolCall({
toolName,
callId: (part.callID as string | undefined) ?? (part.id as string | undefined),
status,
input,
output,
error,
}),
});
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success) {
if (parsedToolPart.data) {
events.push({
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
});
}
}
} else if (partType === "step-finish") {
// Extract usage from step-finish parts

View File

@@ -23,8 +23,14 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("shell", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("bash", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("exec_command", ToolShellInputSchema, ToolShellOutputSchema, toShellToolDetail),
toolDetailBranchByToolName("read", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByToolName("read_file", ToolReadInputSchema, ToolReadOutputSchema, toReadToolDetail),
toolDetailBranchByToolName("read", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByToolName("read_file", ToolReadInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolReadOutputSchema.safeParse(output);
return toReadToolDetail(input, parsedOutput.success ? parsedOutput.data : null);
}),
toolDetailBranchByToolName("write", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("write_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),
toolDetailBranchByToolName("create_file", ToolWriteInputSchema, ToolWriteOutputSchema, toWriteToolDetail),

View File

@@ -2,15 +2,25 @@ import { describe, expect, it } from "vitest";
import { mapOpencodeToolCall } from "./tool-call-mapper.js";
function expectMapped<T>(item: T | null): T {
expect(item).toBeTruthy();
if (!item) {
throw new Error("Expected mapped tool call");
}
return item;
}
describe("opencode tool-call mapper", () => {
it("maps running shell calls", () => {
const item = mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-1",
status: "running",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-1",
status: "running",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
})
);
expect(item.status).toBe("running");
expect(item.error).toBeNull();
@@ -22,49 +32,57 @@ describe("opencode tool-call mapper", () => {
});
it("maps running known tool variants with detail for early summaries", () => {
const readItem = mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-running-read",
status: "running",
input: { file_path: "README.md" },
output: null,
});
const readItem = expectMapped(
mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-running-read",
status: "running",
input: { file_path: "README.md" },
output: null,
})
);
expect(readItem.detail).toEqual({
type: "read",
filePath: "README.md",
});
const writeItem = mapOpencodeToolCall({
toolName: "write_file",
callId: "opencode-running-write",
status: "running",
input: { file_path: "src/new.ts" },
output: null,
});
const writeItem = expectMapped(
mapOpencodeToolCall({
toolName: "write_file",
callId: "opencode-running-write",
status: "running",
input: { file_path: "src/new.ts" },
output: null,
})
);
expect(writeItem.detail).toEqual({
type: "write",
filePath: "src/new.ts",
});
const editItem = mapOpencodeToolCall({
toolName: "apply_patch",
callId: "opencode-running-edit",
status: "running",
input: { file_path: "src/index.ts" },
output: null,
});
const editItem = expectMapped(
mapOpencodeToolCall({
toolName: "apply_patch",
callId: "opencode-running-edit",
status: "running",
input: { file_path: "src/index.ts" },
output: null,
})
);
expect(editItem.detail).toEqual({
type: "edit",
filePath: "src/index.ts",
});
const searchItem = mapOpencodeToolCall({
toolName: "web_search",
callId: "opencode-running-search",
status: "running",
input: { query: "opencode mapper" },
output: null,
});
const searchItem = expectMapped(
mapOpencodeToolCall({
toolName: "web_search",
callId: "opencode-running-search",
status: "running",
input: { query: "opencode mapper" },
output: null,
})
);
expect(searchItem.detail).toEqual({
type: "search",
query: "opencode mapper",
@@ -72,13 +90,15 @@ describe("opencode tool-call mapper", () => {
});
it("maps completed read calls", () => {
const item = mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-call-2",
status: "complete",
input: { file_path: "README.md" },
output: { content: "hello" },
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-call-2",
status: "complete",
input: { file_path: "README.md" },
output: { content: "hello" },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -91,35 +111,39 @@ describe("opencode tool-call mapper", () => {
});
it("preserves read content from array/object output variants", () => {
const arrayContent = mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-read-array",
status: "completed",
input: { file_path: "README.md" },
output: {
content: [
{ type: "output_text", text: "alpha" },
{ type: "output_text", output: "beta" },
],
},
});
const arrayContent = expectMapped(
mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-read-array",
status: "completed",
input: { file_path: "README.md" },
output: {
content: [
{ type: "output_text", text: "alpha" },
{ type: "output_text", output: "beta" },
],
},
})
);
expect(arrayContent.detail?.type).toBe("read");
if (arrayContent.detail?.type === "read") {
expect(arrayContent.detail.content).toBe("alpha\nbeta");
}
const objectContent = mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-read-object",
status: "completed",
input: { file_path: "README.md" },
output: {
data: {
content: { type: "output_text", text: "gamma" },
const objectContent = expectMapped(
mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-read-object",
status: "completed",
input: { file_path: "README.md" },
output: {
data: {
content: { type: "output_text", text: "gamma" },
},
},
},
});
})
);
expect(objectContent.detail?.type).toBe("read");
if (objectContent.detail?.type === "read") {
@@ -128,14 +152,16 @@ describe("opencode tool-call mapper", () => {
});
it("maps failed calls with required error", () => {
const item = mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-3",
status: "error",
input: { command: "false" },
output: null,
error: "command failed",
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-3",
status: "error",
input: { command: "false" },
output: null,
error: "command failed",
})
);
expect(item.status).toBe("failed");
expect(item.error).toBe("command failed");
@@ -143,38 +169,44 @@ describe("opencode tool-call mapper", () => {
});
it("maps write/edit/search known variants into canonical detail", () => {
const writeItem = mapOpencodeToolCall({
toolName: "write_file",
callId: "opencode-write-1",
status: "completed",
input: { file_path: "src/new.ts", content: "const x = 1;" },
output: null,
});
const writeItem = expectMapped(
mapOpencodeToolCall({
toolName: "write_file",
callId: "opencode-write-1",
status: "completed",
input: { file_path: "src/new.ts", content: "const x = 1;" },
output: null,
})
);
expect(writeItem.detail?.type).toBe("write");
if (writeItem.detail?.type === "write") {
expect(writeItem.detail.filePath).toBe("src/new.ts");
}
const editItem = mapOpencodeToolCall({
toolName: "apply_patch",
callId: "opencode-edit-1",
status: "completed",
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
output: null,
});
const editItem = expectMapped(
mapOpencodeToolCall({
toolName: "apply_patch",
callId: "opencode-edit-1",
status: "completed",
input: { file_path: "src/index.ts", diff: "@@\\n-old\\n+new\\n" },
output: null,
})
);
expect(editItem.detail?.type).toBe("edit");
if (editItem.detail?.type === "edit") {
expect(editItem.detail.filePath).toBe("src/index.ts");
expect(editItem.detail.unifiedDiff).toContain("@@");
}
const searchItem = mapOpencodeToolCall({
toolName: "web_search",
callId: "opencode-search-1",
status: "completed",
input: { query: "opencode mapper" },
output: null,
});
const searchItem = expectMapped(
mapOpencodeToolCall({
toolName: "web_search",
callId: "opencode-search-1",
status: "completed",
input: { query: "opencode mapper" },
output: null,
})
);
expect(searchItem.detail).toEqual({
type: "search",
query: "opencode mapper",
@@ -182,13 +214,15 @@ describe("opencode tool-call mapper", () => {
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = mapOpencodeToolCall({
toolName: "my_custom_tool",
callId: "opencode-call-4",
status: "completed",
input: { foo: "bar" },
output: { ok: true },
});
const item = expectMapped(
mapOpencodeToolCall({
toolName: "my_custom_tool",
callId: "opencode-call-4",
status: "completed",
input: { foo: "bar" },
output: { ok: true },
})
);
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
@@ -198,4 +232,35 @@ describe("opencode tool-call mapper", () => {
output: { ok: true },
});
});
it("does not apply cross-provider speak normalization in opencode mapper", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "paseo_voice.speak",
callId: "opencode-call-voice-1",
status: "completed",
input: { text: "Voice response from OpenCode." },
output: { ok: true },
})
);
expect(item.name).toBe("paseo_voice.speak");
expect(item.detail).toEqual({
type: "unknown",
input: { text: "Voice response from OpenCode." },
output: { ok: true },
});
});
it("drops tool calls when callId is missing", () => {
const item = mapOpencodeToolCall({
toolName: "read_file",
callId: null,
status: "completed",
input: { file_path: "README.md" },
output: { content: "hello" },
});
expect(item).toBeNull();
});
});

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveOpencodeToolDetail } from "./tool-call-detail-parser.js";
type OpencodeToolCallParams = {
@@ -18,7 +17,14 @@ const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
const OpencodeToolCallParamsSchema = z
const OpencodeToolCallStatusSchema = z.enum([
"running",
"completed",
"failed",
"canceled",
]);
const OpencodeRawToolCallSchema = z
.object({
toolName: z.string().min(1),
callId: z.string().optional().nullable(),
@@ -26,74 +32,145 @@ const OpencodeToolCallParamsSchema = z
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.passthrough();
function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
return coerceToolCallId({
providerPrefix: "opencode",
rawCallId: callId,
toolName,
input,
});
}
const OpencodeNormalizedToolCallPass1Schema = OpencodeRawToolCallSchema.transform((raw) => {
const input = raw.input ?? null;
const output = raw.output ?? null;
const error = raw.error ?? null;
const callId =
typeof raw.callId === "string" && raw.callId.trim().length > 0
? raw.callId.trim()
: null;
let status: z.infer<typeof OpencodeToolCallStatusSchema>;
function resolveStatus(
rawStatus: unknown,
error: unknown,
output: unknown
): ToolCallTimelineItem["status"] {
if (error !== null && error !== undefined) {
return "failed";
}
if (typeof rawStatus === "string") {
const normalized = rawStatus.trim().toLowerCase();
if (normalized.length > 0) {
if (FAILED_STATUSES.has(normalized)) {
return "failed";
}
if (CANCELED_STATUSES.has(normalized)) {
return "canceled";
}
if (COMPLETED_STATUSES.has(normalized)) {
return "completed";
}
return "running";
if (error !== null) {
status = "failed";
} else if (typeof raw.status === "string") {
const normalized = raw.status.trim().toLowerCase();
if (FAILED_STATUSES.has(normalized)) {
status = "failed";
} else if (CANCELED_STATUSES.has(normalized)) {
status = "canceled";
} else if (COMPLETED_STATUSES.has(normalized)) {
status = "completed";
} else {
status = "running";
}
}
return output !== null && output !== undefined ? "completed" : "running";
}
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem {
const parsedParams = OpencodeToolCallParamsSchema.parse(params);
const input = parsedParams.input ?? null;
const output = parsedParams.output ?? null;
const status = resolveStatus(parsedParams.status, parsedParams.error, output);
const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input);
const detail = deriveOpencodeToolDetail(parsedParams.toolName, input, output);
if (status === "failed") {
return {
type: "tool_call",
callId,
name: parsedParams.toolName,
status: "failed",
detail,
error: parsedParams.error ?? { message: "Tool call failed" },
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
} else {
status = output !== null ? "completed" : "running";
}
return {
type: "tool_call",
callId,
name: parsedParams.toolName,
name: raw.toolName.trim(),
input,
output,
error,
metadata: raw.metadata,
status,
};
});
const OpencodeKnownToolNameSchema = z.union([
z.literal("shell"),
z.literal("bash"),
z.literal("exec_command"),
z.literal("read"),
z.literal("read_file"),
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
z.literal("edit"),
z.literal("apply_patch"),
z.literal("apply_diff"),
z.literal("search"),
z.literal("web_search"),
]);
const OpencodeToolCallPass2BaseSchema = z.object({
callId: z.string().min(1),
name: z.string().min(1),
input: z.unknown().nullable(),
output: z.unknown().nullable(),
error: z.unknown().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
status: OpencodeToolCallStatusSchema,
toolKind: z.enum(["known", "other"]),
});
const OpencodeToolCallPass2InputSchema = OpencodeToolCallPass2BaseSchema.omit({
toolKind: true,
});
const OpencodeToolCallPass2EnvelopeSchema = z.union([
OpencodeToolCallPass2InputSchema.extend({
name: OpencodeKnownToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "known" as const,
})),
OpencodeToolCallPass2InputSchema.transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "other" as const,
})),
]);
const OpencodeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
OpencodeToolCallPass2BaseSchema.extend({
toolKind: z.literal("known"),
name: OpencodeKnownToolNameSchema,
}),
OpencodeToolCallPass2BaseSchema.extend({
toolKind: z.literal("other"),
}),
]);
type OpencodeToolCallPass2 = z.infer<typeof OpencodeToolCallPass2Schema>;
function toToolCallTimelineItem(normalized: OpencodeToolCallPass2): ToolCallTimelineItem {
const detail = deriveOpencodeToolDetail(normalized.name, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
status: "failed",
detail,
error: normalized.error ?? { message: "Tool call failed" },
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
return {
type: "tool_call",
callId: normalized.callId,
name: normalized.name,
status: normalized.status,
detail,
error: null,
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
};
}
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem | null {
const pass1 = OpencodeNormalizedToolCallPass1Schema.safeParse(params);
if (!pass1.success) {
return null;
}
const pass2Envelope = OpencodeToolCallPass2EnvelopeSchema.safeParse(pass1.data);
if (!pass2Envelope.success) {
return null;
}
const pass2 = OpencodeToolCallPass2Schema.safeParse(pass2Envelope.data);
if (!pass2.success) {
return null;
}
return toToolCallTimelineItem(pass2.data);
}

View File

@@ -2,7 +2,6 @@ import { z } from "zod";
import type { ToolCallDetail } from "../agent-sdk-types.js";
import {
commandFromValue,
extractCodexShellOutput,
flattenReadContent as flattenToolReadContent,
nonEmptyString,
@@ -29,9 +28,19 @@ export const ToolShellInputSchema = z
.passthrough(),
])
.transform((value) => {
const commandValue = "command" in value ? value.command : value.cmd;
const parsedCommand = CommandValueSchema.safeParse(
"command" in value ? value.command : value.cmd
);
const command = parsedCommand.success
? typeof parsedCommand.data === "string"
? nonEmptyString(parsedCommand.data)
: parsedCommand.data
.map((token) => token.trim())
.filter((token) => token.length > 0)
.join(" ") || undefined
: undefined;
return {
command: commandFromValue(commandValue),
command,
cwd: nonEmptyString(value.cwd) ?? nonEmptyString(value.directory),
};
});
@@ -314,11 +323,8 @@ type ToolReadOutputValue = {
content?: string;
};
export const ToolReadOutputSchema: z.ZodType<
ToolReadOutputValue,
z.ZodTypeDef,
unknown
> = ToolReadOutputContentSchema;
export const ToolReadOutputSchema: z.ZodType<ToolReadOutputValue, z.ZodTypeDef, unknown> =
ToolReadOutputContentSchema;
export const ToolReadOutputWithPathSchema: z.ZodType<
ToolReadOutputValue,
@@ -508,8 +514,8 @@ export const ToolSearchInputSchema = z.union([
export type ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>;
export type ParsedToolReadOutput = z.infer<typeof ToolReadOutputSchema>;
export type ParsedToolReadOutputWithPath = z.infer<typeof ToolReadOutputWithPathSchema>;
export type ParsedToolReadOutput = ToolReadOutputValue;
export type ParsedToolReadOutputWithPath = ToolReadOutputValue;
export type ParsedToolWriteInput = z.infer<typeof ToolWriteInputSchema>;
export type ParsedToolWriteOutput = z.infer<typeof ToolWriteOutputSchema>;
export type ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
@@ -649,7 +655,13 @@ export function toolDetailBranchByName<
input: inputSchema.nullable(),
output: outputSchema.nullable(),
})
.transform(({ input, output }) => mapper(input, output));
.transform((value) => {
const parsed = value as unknown as {
input: z.infer<InputSchema> | null;
output: z.infer<OutputSchema> | null;
};
return mapper(parsed.input, parsed.output);
});
}
export function toolDetailBranchByToolName<
@@ -671,7 +683,13 @@ export function toolDetailBranchByToolName<
input: inputSchema.nullable(),
output: outputSchema.nullable(),
})
.transform(({ input, output }) => mapper(input, output));
.transform((value) => {
const parsed = value as unknown as {
input: z.infer<InputSchema> | null;
output: z.infer<OutputSchema> | null;
};
return mapper(parsed.input, parsed.output);
});
}
export function toolDetailBranchByNameWithCwd<
@@ -695,5 +713,12 @@ export function toolDetailBranchByNameWithCwd<
output: outputSchema.nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input, output, cwd }) => mapper(input, output, cwd ?? null));
.transform((value) => {
const parsed = value as unknown as {
input: z.infer<InputSchema> | null;
output: z.infer<OutputSchema> | null;
cwd?: string | null;
};
return mapper(parsed.input, parsed.output, parsed.cwd ?? null);
});
}

View File

@@ -8,19 +8,6 @@ export function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
export function commandFromValue(value: unknown): string | undefined {
if (typeof value === "string") {
return nonEmptyString(value);
}
if (!Array.isArray(value)) {
return undefined;
}
const tokens = value.filter(
(token): token is string => typeof token === "string" && token.length > 0
);
return tokens.length > 0 ? tokens.join(" ") : undefined;
}
const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([
"chunk id:",
"wall time:",

View File

@@ -229,17 +229,6 @@ export class TTSManager {
isVoiceMode,
},
});
this.logger.info(
{
audioId,
chunkId,
chunkIndex,
isLastChunk: next.done,
bytes: chunkBuffer.length,
isVoiceMode,
},
"Emitted audio_output chunk to client"
);
chunkIndex += 1;
@@ -298,15 +287,6 @@ export class TTSManager {
}
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1);
this.logger.info(
{
chunkId,
audioId,
remainingPendingChunks: pending.pendingChunks,
streamEnded: pending.streamEnded,
},
"Received audio playback confirmation from client"
);
if (pending.pendingChunks === 0 && pending.streamEnded) {
pending.resolve();

View File

@@ -57,7 +57,6 @@ import {
encodeOfferToFragmentUrl,
} from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { printPairingQrIfEnabled } from "./pairing-qr.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js";
import type {
@@ -548,7 +547,6 @@ export async function createPaseoDaemon(
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
logger.info({ url }, "pairing_offer");
void printPairingQrIfEnabled({ url, logger }).catch(() => undefined);
} else {
logger.info("relay_disabled");
}

View File

@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
expect(checkoutStatus.isGit).toBe(true);
expect(checkoutStatus.repoRoot).toContain(cwd);
const diffRequestId = `diff-${Date.now()}`;
const diffMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => {
if (message.type !== "git_diff_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== diffRequestId) {
return;
}
resolve(message);
});
return unsubscribeDiff;
});
const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId);
const diffMessage = await diffMessagePromise;
const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(diffResult.error).toBeNull();
expect(diffResult.diff).toContain("test.txt");
expect(diffResult.diff).toContain("-original content");
expect(diffResult.diff).toContain("+modified content");
expect(diffResult.requestId).toBe(diffRequestId);
expect(diffMessage.payload.agentId).toBe(agent.id);
expect(diffMessage.payload.requestId).toBe(diffRequestId);
const highlightRequestId = `highlight-${Date.now()}`;
const highlightMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeHighlight = ctx.client.on(
"highlighted_diff_response",
(message) => {
if (message.type !== "highlighted_diff_response") {
return;
}
if (message.payload.agentId !== agent.id) {
return;
}
if (message.payload.requestId !== highlightRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeHighlight;
});
const highlightResult = await ctx.client.getHighlightedDiff(
agent.id,
highlightRequestId
);
const highlightMessage = await highlightMessagePromise;
expect(highlightResult.error).toBeNull();
expect(Array.isArray(highlightResult.files)).toBe(true);
expect(highlightResult.requestId).toBe(highlightRequestId);
expect(highlightMessage.payload.agentId).toBe(agent.id);
expect(highlightMessage.payload.requestId).toBe(highlightRequestId);
expect(Array.isArray(diffResult.files)).toBe(true);
expect(diffResult.files.length).toBeGreaterThan(0);
expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
const listRequestId = `list-${Date.now()}`;
const listMessagePromise = waitForSignal(15000, (resolve) => {

View File

@@ -0,0 +1,101 @@
import { describe, test, expect } from "vitest";
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { createDaemonTestContext } from "../test-utils/index.js";
const RUN = process.env.PASEO_GIT_DIFF_BOTTLENECK_E2E === "1";
const LARGE_CHANGESET_SIZE = Number.parseInt(
process.env.PASEO_GIT_DIFF_BOTTLENECK_FILE_COUNT ?? "1200",
10
);
function tmpRepo(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-git-diff-bottleneck-"));
}
function initGitRepo(cwd: string): void {
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
}
function seedLargeDirtyRepo(cwd: string, fileCount: number): void {
mkdirSync(path.join(cwd, "files"), { recursive: true });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i}\n`);
}
execSync("git add .", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'init'", {
cwd,
stdio: "pipe",
});
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i} changed\n`);
}
// Explicit binary artifact to verify we do not diff binary contents.
writeFileSync(path.join(cwd, "blob.bin"), Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f]));
}
const runDescribe = RUN ? describe : describe.skip;
runDescribe("daemon E2E git diff bottleneck profiling", () => {
test(
"shows per-file git diff subprocess fanout and timeout pressure",
async () => {
const cwd = tmpRepo();
try {
initGitRepo(cwd);
seedLargeDirtyRepo(cwd, LARGE_CHANGESET_SIZE);
const cliStart = performance.now();
const cliDiff = execSync("git diff HEAD", { cwd, stdio: "pipe" }).toString();
const cliMs = performance.now() - cliStart;
const ctx = await createDaemonTestContext();
try {
const checkoutStart = performance.now();
const checkoutPayload = await ctx.client.getCheckoutDiff(cwd, {
mode: "uncommitted",
});
const checkoutMs = performance.now() - checkoutStart;
expect(checkoutPayload.error).toBeNull();
expect(checkoutPayload.files.length).toBeGreaterThanOrEqual(LARGE_CHANGESET_SIZE);
const binaryEntry = checkoutPayload.files.find((file) => file.path === "blob.bin");
expect(binaryEntry).toBeTruthy();
expect(binaryEntry?.status).toBe("binary");
// Keep this visible in test output for local bottleneck analysis.
console.info(
"[git-diff-bottleneck]",
JSON.stringify(
{
fileCount: LARGE_CHANGESET_SIZE,
cliMs: Math.round(cliMs),
cliDiffBytes: cliDiff.length,
checkoutMs: Math.round(checkoutMs),
checkoutFiles: checkoutPayload.files.length,
speedRatio: Number((checkoutMs / Math.max(cliMs, 1)).toFixed(2)),
},
null,
2
)
);
expect(checkoutMs).toBeLessThan(cliMs * 10);
} finally {
await ctx.cleanup();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
240000
);
});

View File

@@ -110,7 +110,7 @@ describe("daemon E2E", () => {
await ctx.cleanup();
}, 60000);
describe("getGitDiff", () => {
describe("getCheckoutDiff", () => {
test(
"returns diff for modified file in git repo",
async () => {
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
// Modify the file (creates unstaged changes)
writeFileSync(testFile, "modified content\n");
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get git diff
const result = await ctx.client.getGitDiff(agent.id);
// Verify diff returned without error
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.error).toBeNull();
expect(result.diff).toBeTruthy();
expect(result.diff).toContain("test.txt");
expect(result.diff).toContain("-original content");
expect(result.diff).toContain("+modified content");
// Cleanup
await ctx.client.deleteAgent(agent.id);
expect(result.files.length).toBeGreaterThan(0);
const file = result.files.find((entry) => entry.path === "test.txt");
expect(file).toBeTruthy();
expect(file?.hunks.length).toBeGreaterThan(0);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
// Create agent in the git repo (no modifications)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Clean Test",
});
expect(agent.id).toBeTruthy();
// Get git diff - should be empty
const result = await ctx.client.getGitDiff(agent.id);
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.error).toBeNull();
expect(result.diff).toBe("");
expect(result.files).toEqual([]);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Non-Git Test",
});
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(agent.id).toBeTruthy();
// Get git diff - should return error
const result = await ctx.client.getGitDiff(agent.id);
expect(result.diff).toBe("");
expect(result.files).toEqual([]);
expect(result.error).toBeTruthy();
expect(result.error).toContain("git");
expect(result.error?.code).toBe("NOT_GIT_REPO");
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000 // 1 minute timeout

View File

@@ -315,7 +315,7 @@ export class DictationStreamManager {
state.bytesSinceCommit += resampled.length;
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
try {
this.maybeAutoCommitDictationSegment(params.dictationId, state);
this.maybeAutoCommitDictationSegment(state);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
void this.failAndCleanupDictationStream(params.dictationId, message, true);
@@ -482,7 +482,7 @@ export class DictationStreamManager {
this.streams.delete(dictationId);
}
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void {
private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
if (state.finishRequested) {
return;
}
@@ -490,29 +490,12 @@ export class DictationStreamManager {
return;
}
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) {
this.logger.debug(
{
dictationId,
autoCommitBytes: state.autoCommitBytes,
bytesSinceCommit: state.bytesSinceCommit,
peakSinceCommit: state.peakSinceCommit,
},
"Dictation auto-segment: clearing silence-only segment"
);
state.stt.clear();
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
return;
}
this.logger.debug(
{
dictationId,
autoCommitBytes: state.autoCommitBytes,
bytesSinceCommit: state.bytesSinceCommit,
},
"Dictation auto-segment: committing buffered audio"
);
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.stt.commit();

View File

@@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
export {
ensureLocalSpeechModels,
listLocalSpeechModels,
type LocalSpeechModelId,
type LocalSttModelId,
type LocalTtsModelId,
} from "./speech/providers/local/models.js";
// Agent SDK types for CLI commands
export type {

View File

@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
const FeatureDictationSchema = z
.object({
enabled: z.boolean().optional(),
stt: z
.object({
provider: SpeechProviderIdSchema.optional(),
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
const FeatureVoiceModeSchema = z
.object({
enabled: z.boolean().optional(),
llm: z
.object({
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),

View File

@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const wsMock = vi.hoisted(() => {
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly url: string;
readonly options: unknown;
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
terminateCalls = 0;
private listeners = new Map<string, Array<(...args: any[]) => void>>();
constructor(url: string, options?: unknown) {
this.url = url;
this.options = options;
MockWebSocket.instances.push(this);
}
static reset() {
MockWebSocket.instances = [];
}
on(event: string, listener: (...args: any[]) => void) {
const handlers = this.listeners.get(event) ?? [];
handlers.push(listener);
this.listeners.set(event, handlers);
return this;
}
once(event: string, listener: (...args: any[]) => void) {
const wrapped = (...args: any[]) => {
this.off(event, wrapped);
listener(...args);
};
return this.on(event, wrapped);
}
close(code?: number, reason?: string) {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code ?? 1000, reason ?? "");
}
terminate() {
this.terminateCalls += 1;
this.readyState = MockWebSocket.CLOSED;
this.emit("close", 1006, "");
}
send(data: string) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`);
}
this.sent.push(data);
}
open() {
this.readyState = MockWebSocket.OPEN;
this.emit("open");
}
message(data: unknown) {
this.emit("message", data);
}
error(err: unknown) {
this.emit("error", err);
}
private off(event: string, listener: (...args: any[]) => void) {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(
event,
handlers.filter((handler) => handler !== listener)
);
}
private emit(event: string, ...args: any[]) {
const handlers = this.listeners.get(event) ?? [];
for (const handler of [...handlers]) {
handler(...args);
}
}
}
return { MockWebSocket };
});
vi.mock("ws", () => ({ default: wsMock.MockWebSocket }));
import { startRelayTransport } from "./relay-transport";
function createMockLogger() {
const logger = {
child: vi.fn(() => logger),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return logger;
}
function hasLogMessage(mockFn: ReturnType<typeof vi.fn>, message: string): boolean {
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
}
describe("relay-transport control lifecycle", () => {
const controllers: Array<{ stop: () => Promise<void> }> = [];
const MockWebSocket = wsMock.MockWebSocket;
beforeEach(() => {
MockWebSocket.reset();
});
afterEach(async () => {
for (const controller of controllers) {
await controller.stop();
}
controllers.length = 0;
vi.useRealTimers();
});
test("logs relay_control_connected only after first valid control message", () => {
const logger = createMockLogger();
const controller = startRelayTransport({
logger: logger as any,
attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443",
serverId: "srv_test",
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
expect(control).toBeDefined();
control.open();
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false);
expect(control.sent.length).toBeGreaterThan(0);
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true);
});
test("terminates and reconnects when control socket opens but never becomes ready", () => {
vi.useFakeTimers();
const logger = createMockLogger();
const controller = startRelayTransport({
logger: logger as any,
attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443",
serverId: "srv_test",
});
controllers.push(controller);
const firstControl = MockWebSocket.instances[0];
firstControl.open();
vi.advanceTimersByTime(8_000);
expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true);
expect(firstControl.terminateCalls).toBe(1);
vi.advanceTimersByTime(1_000);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
});
test("terminates stale control sockets in under one minute", () => {
vi.useFakeTimers();
const logger = createMockLogger();
const controller = startRelayTransport({
logger: logger as any,
attachSocket: async () => {},
relayEndpoint: "relay.paseo.sh:443",
serverId: "srv_test",
});
controllers.push(controller);
const control = MockWebSocket.instances[0];
control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
logger.warn.mockClear();
vi.advanceTimersByTime(40_000);
expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true);
expect(control.terminateCalls).toBe(1);
});
});

View File

@@ -37,6 +37,10 @@ type ControlMessage =
| { type: "ping" }
| { type: "pong" };
const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000;
function tryParseControlMessage(raw: unknown): ControlMessage | null {
try {
const text =
@@ -76,7 +80,9 @@ export function startRelayTransport({
let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0;
let controlConnectionSeq = 0;
const stop = async (): Promise<void> => {
stopped = true;
@@ -88,6 +94,10 @@ export function startRelayTransport({
clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null;
}
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
if (controlWs) {
try {
controlWs.close();
@@ -109,6 +119,7 @@ export function startRelayTransport({
const connectControl = (): void => {
if (stopped) return;
const connectionId = ++controlConnectionSeq;
const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint,
serverId,
@@ -116,14 +127,46 @@ export function startRelayTransport({
});
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
controlWs = socket;
let controlConnected = false;
const markControlReady = () => {
if (controlWs !== socket) return;
if (controlConnected) return;
controlConnected = true;
reconnectAttempt = 0;
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
relayLogger.info({ url, connectionId }, "relay_control_connected");
};
socket.on("open", () => {
reconnectAttempt = 0;
if (controlWs !== socket) return;
controlLastSeenAt = Date.now();
if (controlKeepaliveInterval) {
clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null;
}
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
controlReadyTimeout = setTimeout(() => {
if (stopped) return;
if (controlWs !== socket) return;
if (controlConnected) return;
relayLogger.warn(
{ url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS },
"relay_control_ready_timeout_terminating"
);
try {
socket.terminate();
} catch {
// ignore
}
}, CONTROL_READY_TIMEOUT_MS);
controlKeepaliveInterval = setInterval(() => {
if (stopped) return;
if (controlWs !== socket) return;
@@ -133,8 +176,11 @@ export function startRelayTransport({
const staleForMs = now - controlLastSeenAt;
// If the control socket is half-open or silently dropped, ws may never emit "close".
// Use app-level ping/pong to detect staleness and force a reconnect.
if (staleForMs > 90_000) {
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating");
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
relayLogger.warn(
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
"relay_control_stale_terminating"
);
try {
socket.terminate();
} catch {
@@ -146,40 +192,58 @@ export function startRelayTransport({
try {
socket.send(JSON.stringify({ type: "ping", ts: now }));
} catch (error) {
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed");
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
// ignore
}
}
}, 20_000);
relayLogger.info({ url }, "relay_control_connected");
}, CONTROL_PING_INTERVAL_MS);
try {
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
} catch (error) {
relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
try {
socket.terminate();
} catch {
// ignore
}
}
relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready");
});
socket.on("close", (code, reason) => {
if (controlWs !== socket) return;
relayLogger.warn(
{ code, reason: reason?.toString?.(), url },
{ code, reason: reason?.toString?.(), url, connectionId },
"relay_control_disconnected"
);
if (controlWs === socket) {
controlWs = null;
}
controlWs = null;
if (controlKeepaliveInterval) {
clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null;
}
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
scheduleReconnect();
});
socket.on("error", (err) => {
relayLogger.warn({ err, url }, "relay_error");
if (controlWs !== socket) return;
relayLogger.warn({ err, url, connectionId }, "relay_error");
// close event will schedule reconnect
});
socket.on("message", (data) => {
if (controlWs !== socket) return;
controlLastSeenAt = Date.now();
const msg = tryParseControlMessage(data);
if (msg) {
markControlReady();
}
if (!msg) return;
if (msg.type === "ping") {
try {

View File

@@ -26,7 +26,6 @@ import {
type ProjectPlacementPayload,
} from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
import { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
@@ -64,6 +63,7 @@ import type {
AgentPermissionResponse,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
McpServerConfig,
AgentSessionConfig,
AgentStreamEvent,
@@ -581,7 +581,7 @@ export class Session {
void this.initializeAgentMcp();
this.subscribeToAgentEvents();
this.sessionLogger.info("Session created");
this.sessionLogger.trace("Session created");
}
/**
@@ -684,7 +684,8 @@ export class Session {
*/
private startAgentStream(
agentId: string,
prompt: AgentPromptInput
prompt: AgentPromptInput,
runOptions?: AgentRunOptions
): { ok: true } | { ok: false; error: string } {
this.sessionLogger.info(
{ agentId },
@@ -693,7 +694,7 @@ export class Session {
let iterator: AsyncGenerator<AgentStreamEvent>;
try {
iterator = this.agentManager.streamAgent(agentId, prompt);
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to start agent run");
const message =
@@ -750,7 +751,7 @@ export class Session {
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
this.sessionLogger.info(
this.sessionLogger.trace(
{ agentToolCount },
`Agent MCP initialized with ${agentToolCount} tools`
);
@@ -941,9 +942,17 @@ export class Session {
agentId,
extractTimestamps(record)
);
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent resumed from persistence"
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent created from stored config"
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
@@ -1125,6 +1134,15 @@ export class Session {
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
break;
case "update_agent_request":
await this.handleUpdateAgentRequest(
msg.agentId,
msg.name,
msg.labels,
msg.requestId
);
break;
case "set_voice_mode":
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
break;
@@ -1206,10 +1224,6 @@ export class Session {
);
break;
case "git_diff_request":
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
break;
case "checkout_status_request":
await this.handleCheckoutStatusRequest(msg);
break;
@@ -1258,10 +1272,6 @@ export class Session {
await this.handlePaseoWorktreeArchiveRequest(msg);
break;
case "highlighted_diff_request":
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
@@ -1278,6 +1288,10 @@ export class Session {
await this.handleListProviderModelsRequest(msg);
break;
case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg);
break;
case "speech_models_list_request":
await this.handleSpeechModelsListRequest(msg);
break;
@@ -1506,6 +1520,88 @@ export class Session {
});
}
private async handleUpdateAgentRequest(
agentId: string,
name: string | undefined,
labels: Record<string, string> | undefined,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId, requestId, hasName: typeof name === "string", labelCount: labels ? Object.keys(labels).length : 0 },
"session: update_agent_request"
);
const normalizedName = name?.trim();
const normalizedLabels =
labels && Object.keys(labels).length > 0 ? labels : undefined;
if (!normalizedName && !normalizedLabels) {
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: "Nothing to update (provide name and/or labels)",
},
});
return;
}
try {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
if (normalizedName) {
await this.agentManager.setTitle(agentId, normalizedName);
}
if (normalizedLabels) {
await this.agentManager.setLabels(agentId, normalizedLabels);
}
} else {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
await this.agentStorage.upsert({
...existing,
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels
? { labels: { ...existing.labels, ...normalizedLabels } }
: {}),
});
}
this.emit({
type: "update_agent_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
"session: update_agent_request error"
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to update agent: ${error.message}`,
},
});
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "Failed to update agent",
},
});
}
}
/**
* Handle voice mode toggle
*/
@@ -1946,7 +2042,8 @@ export class Session {
agentId: string,
text: string,
messageId?: string,
images?: Array<{ data: string; mimeType: string }>
images?: Array<{ data: string; mimeType: string }>,
runOptions?: AgentRunOptions
): Promise<void> {
this.sessionLogger.info(
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
@@ -1986,7 +2083,7 @@ export class Session {
);
}
this.startAgentStream(agentId, prompt);
this.startAgentStream(agentId, prompt, runOptions);
}
/**
@@ -1996,11 +2093,6 @@ export class Session {
agentId: string,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId },
`Initializing agent ${agentId} on demand`
);
try {
const snapshot = await this.ensureAgentLoaded(agentId);
await this.forwardAgentUpdate(snapshot);
@@ -2017,11 +2109,6 @@ export class Session {
requestId,
},
});
this.sessionLogger.info(
{ agentId, timelineSize, status: snapshot.lifecycle },
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
@@ -2044,7 +2131,7 @@ export class Session {
private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>
): Promise<void> {
const { config, worktreeName, requestId, initialPrompt, git, images, labels } = msg;
const { config, worktreeName, requestId, initialPrompt, outputSchema, git, images, labels } = msg;
this.sessionLogger.info(
{ cwd: config.cwd, provider: config.provider, worktreeName },
`Creating agent in ${config.cwd} (${config.provider})${
@@ -2083,7 +2170,8 @@ export class Session {
snapshot.id,
trimmedPrompt,
uuidv4(),
images
images,
outputSchema ? { outputSchema } : undefined
);
} catch (promptError) {
this.sessionLogger.error(
@@ -2514,6 +2602,38 @@ export class Session {
}
}
private async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const providers = await this.agentManager.listProviderAvailability();
this.emit({
type: "list_available_providers_response",
payload: {
providers,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error },
"Failed to list provider availability"
);
this.emit({
type: "list_available_providers_response",
payload: {
providers: [],
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private async handleSpeechModelsListRequest(
msg: Extract<SessionInboundMessage, { type: "speech_models_list_request" }>
): Promise<void> {
@@ -3302,66 +3422,6 @@ export class Session {
}
}
/**
* Handle git diff request for an agent
*/
private async handleGitDiffRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.debug(
{ agentId },
`Handling git diff request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "git_diff_response",
payload: {
agentId,
diff: "",
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome });
const combinedDiff = diffResult.diff;
this.emit({
type: "git_diff_response",
payload: {
agentId,
diff: combinedDiff,
error: null,
requestId,
},
});
this.sessionLogger.debug(
{ agentId, diffBytes: combinedDiff.length },
`Git diff for agent ${agentId} completed (${combinedDiff.length} bytes)`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to get git diff for agent ${agentId}`
);
this.emit({
type: "git_diff_response",
payload: {
agentId,
diff: "",
error: error.message,
requestId,
},
});
}
}
private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
): Promise<void> {
@@ -4099,12 +4159,13 @@ export class Session {
const { cwd, requestId } = msg;
try {
const status = await getPullRequestStatus(cwd);
const prStatus = await getPullRequestStatus(cwd);
this.emit({
type: "checkout_pr_status_response",
payload: {
cwd,
status,
status: prStatus.status,
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
error: null,
requestId,
},
@@ -4115,6 +4176,7 @@ export class Session {
payload: {
cwd,
status: null,
githubFeaturesEnabled: true,
error: this.toCheckoutError(error),
requestId,
},
@@ -4282,237 +4344,6 @@ export class Session {
}
}
/**
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
*/
private async handleHighlightedDiffRequest(
agentId: string,
requestId: string
): Promise<void> {
this.sessionLogger.debug(
{ agentId },
`Handling highlighted diff request for agent ${agentId}`
);
// Maximum lines changed before we skip showing the diff content
const MAX_DIFF_LINES = 5000;
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: [],
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
// Step 1: Get the list of changed files with their stats (numstat gives additions/deletions per file)
const { stdout: numstatOutput } = await execAsync(
"git diff --numstat HEAD",
{ cwd: agent.cwd }
);
// Get file statuses (A=added, D=deleted, M=modified) to detect deleted files
const { stdout: nameStatusOutput } = await execAsync(
"git diff --name-status HEAD",
{ cwd: agent.cwd }
);
const deletedFiles = new Set<string>();
const addedFiles = new Set<string>();
for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) {
const [status, ...pathParts] = line.split("\t");
const path = pathParts.join("\t");
if (status === "D") {
deletedFiles.add(path);
} else if (status === "A") {
addedFiles.add(path);
}
}
// Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
interface FileStats {
path: string;
additions: number;
deletions: number;
isBinary: boolean;
isTracked: boolean;
isDeleted: boolean;
isNew: boolean;
}
const fileStats: FileStats[] = [];
for (const line of numstatOutput.trim().split("\n").filter(Boolean)) {
const parts = line.split("\t");
if (parts.length >= 3) {
const [addStr, delStr, ...pathParts] = parts;
const path = pathParts.join("\t"); // Handle paths with tabs
const isBinary = addStr === "-" && delStr === "-";
fileStats.push({
path,
additions: isBinary ? 0 : parseInt(addStr, 10),
deletions: isBinary ? 0 : parseInt(delStr, 10),
isBinary,
isTracked: true,
isDeleted: deletedFiles.has(path),
isNew: addedFiles.has(path),
});
}
}
// Step 2: Get untracked files
try {
const { stdout: untrackedFiles } = await execAsync(
"git ls-files --others --exclude-standard",
{ cwd: agent.cwd }
);
for (const filePath of untrackedFiles.trim().split("\n").filter(Boolean)) {
// Use git's numstat with --no-index to detect binary files (cross-platform)
// Binary files show as "-\t-\tfilepath", text files show line counts
try {
const { stdout: numstatLine } = await execAsync(
`git diff --numstat --no-index /dev/null "${filePath}" || true`,
{ cwd: agent.cwd }
);
const parts = numstatLine.trim().split("\t");
const isBinary = parts[0] === "-" && parts[1] === "-";
const additions = isBinary ? 0 : (parseInt(parts[0], 10) || 0);
fileStats.push({
path: filePath,
additions,
deletions: 0,
isBinary,
isTracked: false,
isDeleted: false,
isNew: true,
});
} catch {
// If we can't determine, assume text and try to get it
fileStats.push({
path: filePath,
additions: 0,
deletions: 0,
isBinary: false,
isTracked: false,
isDeleted: false,
isNew: true,
});
}
}
} catch {
// Ignore errors getting untracked files
}
// Step 3: Fetch diffs per-file, respecting limits
const allFiles: ParsedDiffFile[] = [];
for (const stats of fileStats) {
const totalLines = stats.additions + stats.deletions;
// Handle binary files
if (stats.isBinary) {
allFiles.push({
path: stats.path,
isNew: stats.isNew,
isDeleted: stats.isDeleted,
additions: 0,
deletions: 0,
hunks: [],
status: "binary",
});
continue;
}
// Handle files that are too large
if (totalLines > MAX_DIFF_LINES) {
allFiles.push({
path: stats.path,
isNew: stats.isNew,
isDeleted: stats.isDeleted,
additions: stats.additions,
deletions: stats.deletions,
hunks: [],
status: "too_large",
});
continue;
}
// Fetch the actual diff for this file
try {
let fileDiff: string;
if (stats.isTracked) {
const { stdout } = await execAsync(
`git diff HEAD -- "${stats.path}"`,
{ cwd: agent.cwd }
);
fileDiff = stdout;
} else {
const { stdout } = await execAsync(
`git diff --no-index /dev/null "${stats.path}" || true`,
{ cwd: agent.cwd }
);
fileDiff = stdout;
}
if (fileDiff) {
const parsedFiles = await parseAndHighlightDiff(fileDiff, agent.cwd);
for (const file of parsedFiles) {
allFiles.push({ ...file, status: "ok" });
}
}
} catch {
// If diff fails for this file, add it with empty hunks
allFiles.push({
path: stats.path,
isNew: stats.isNew,
isDeleted: stats.isDeleted,
additions: stats.additions,
deletions: stats.deletions,
hunks: [],
status: "ok",
});
}
}
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: allFiles,
error: null,
requestId,
},
});
this.sessionLogger.debug(
{ agentId, fileCount: allFiles.length },
`Highlighted diff for agent ${agentId} completed (${allFiles.length} files)`
);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to get highlighted diff for agent ${agentId}`
);
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: [],
error: error.message,
requestId,
},
});
}
}
/**
* Handle read-only file explorer requests scoped to an agent's cwd
*/
@@ -5041,7 +4872,7 @@ export class Session {
if (!resolved.ok) {
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status: "error", final: null, error: resolved.error },
payload: { requestId, status: "error", final: null, error: resolved.error, lastMessage: null },
});
return;
}
@@ -5058,6 +4889,7 @@ export class Session {
status: "error",
final: null,
error: `Agent not found: ${agentId}`,
lastMessage: null,
},
});
return;
@@ -5071,7 +4903,7 @@ export class Session {
: "idle";
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null },
payload: { requestId, status, final, error: null, lastMessage: null },
});
return;
}
@@ -5101,7 +4933,7 @@ export class Session {
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error: null },
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
});
} catch (error) {
const isAbort =
@@ -5119,6 +4951,7 @@ export class Session {
status: "error",
final,
error: message,
lastMessage: null,
},
});
return;
@@ -5130,7 +4963,7 @@ export class Session {
}
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status: "timeout", final, error: null },
payload: { requestId, status: "timeout", final, error: null, lastMessage: null },
});
} finally {
clearTimeout(timeoutHandle);
@@ -5780,7 +5613,7 @@ export class Session {
* Clean up session resources
*/
public async cleanup(): Promise<void> {
this.sessionLogger.info("Cleaning up");
this.sessionLogger.trace("Cleaning up");
if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents();

View File

@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
function persistedLocalFeatureModel(
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
enabled: boolean | undefined,
model: string | undefined
): string | undefined {
if (provider !== "local") {
if (provider !== "local" || enabled === false) {
return undefined;
}
return model;
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
persisted: PersistedConfig;
}): boolean {
const localRequestedByFeature =
params.providers.dictationStt.provider === "local" ||
params.providers.voiceStt.provider === "local" ||
params.providers.voiceTts.provider === "local";
(params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "local") ||
(params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local") ||
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local");
return (
localRequestedByFeature ||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
persistedLocalFeatureModel(
params.providers.dictationStt.provider,
params.providers.dictationStt.enabled,
params.persisted.features?.dictation?.stt?.model
) ??
DEFAULT_LOCAL_STT_MODEL,
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
persistedLocalFeatureModel(
params.providers.voiceStt.provider,
params.providers.voiceStt.enabled,
params.persisted.features?.voiceMode?.stt?.model
) ??
DEFAULT_LOCAL_STT_MODEL,
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
persistedLocalFeatureModel(
params.providers.voiceTts.provider,
params.providers.voiceTts.enabled,
params.persisted.features?.voiceMode?.tts?.model
) ??
DEFAULT_LOCAL_TTS_MODEL,

View File

@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
models: ResolvedLocalModels;
}): LocalSpeechModelId[] {
const ids = new Set<LocalSpeechModelId>();
if (params.providers.dictationStt.provider === "local") {
if (
params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "local"
) {
ids.add(params.models.dictationLocalSttModel);
}
if (params.providers.voiceStt.provider === "local") {
if (
params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local"
) {
ids.add(params.models.voiceLocalSttModel);
}
if (params.providers.voiceTts.provider === "local") {
if (
params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local"
) {
ids.add(params.models.voiceLocalTtsModel);
}
return Array.from(ids);
@@ -258,7 +267,7 @@ export async function initializeLocalSpeechServices(params: {
}
};
if (providers.voiceStt.provider === "local") {
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
if (!localConfig) {
logger.warn(
{ configured: false },
@@ -274,7 +283,7 @@ export async function initializeLocalSpeechServices(params: {
}
}
if (providers.dictationStt.provider === "local") {
if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") {
if (!localConfig) {
logger.warn(
{ configured: false },
@@ -297,7 +306,7 @@ export async function initializeLocalSpeechServices(params: {
}
}
if (providers.voiceTts.provider === "local") {
if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") {
if (!localConfig) {
logger.warn(
{ configured: false },

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
})
).rejects.toThrow(/auto-download/i);
});
test("ensureSherpaOnnxModel logs artifact download progress", async () => {
const modelsDir = makeTmpDir();
const progressLogs: Array<Record<string, unknown>> = [];
const loggerWithSpy = {
child: () => loggerWithSpy,
info: (obj?: unknown, msg?: string) => {
if (msg === "Downloading model artifact" && obj && typeof obj === "object") {
progressLogs.push(obj as Record<string, unknown>);
}
},
error: () => undefined,
} as unknown as pino.Logger;
const originalFetch = globalThis.fetch;
const payload = Buffer.alloc(128 * 1024, 7);
const fetchMock = vi.fn(async () => {
return new Response(payload, {
status: 200,
headers: { "content-length": String(payload.length) },
});
});
globalThis.fetch = fetchMock as typeof fetch;
try {
await ensureSherpaOnnxModel({
modelsDir,
modelId: "pocket-tts-onnx-int8",
autoDownload: true,
logger: loggerWithSpy,
});
} finally {
globalThis.fetch = originalFetch;
}
expect(fetchMock).toHaveBeenCalled();
expect(progressLogs.length).toBeGreaterThan(0);
const final = progressLogs.at(-1);
expect(final?.modelId).toBe("pocket-tts-onnx-int8");
expect(final?.pct).toBe(100);
});
});

View File

@@ -1,7 +1,7 @@
import { createWriteStream } from "node:fs";
import { mkdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { spawn } from "node:child_process";
import type pino from "pino";
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
return true;
}
async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise<void> {
const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024;
const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000;
type DownloadToFileOptions = {
url: string;
outputPath: string;
logger: pino.Logger;
modelId: SherpaOnnxModelId;
artifact: string;
};
function parseContentLength(res: Response): number | null {
const raw = res.headers.get("content-length");
if (!raw) {
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath, logger, modelId, artifact } = options;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
@@ -51,24 +75,68 @@ async function downloadToFile(url: string, outputPath: string, logger: pino.Logg
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
await mkdir(path.dirname(outputPath), { recursive: true });
const total = Number(res.headers.get("content-length") ?? "0");
let downloaded = 0;
let lastLoggedBucket = -1;
const nodeStream = Readable.fromWeb(res.body as any);
const totalBytes = parseContentLength(res);
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => {
downloaded += chunk.length;
if (total > 0) {
const pct = Math.floor((downloaded / total) * 100);
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) {
lastLoggedBucket = bucket;
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact");
let downloadedBytes = 0;
let lastLoggedPct = -1;
let lastLoggedBytes = 0;
let lastLoggedAt = 0;
const logProgress = (force: boolean): void => {
const pct =
totalBytes && totalBytes > 0
? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes))
: null;
if (!force) {
if (pct !== null) {
if (pct <= lastLoggedPct) {
return;
}
} else {
const now = Date.now();
const advancedBytes = downloadedBytes - lastLoggedBytes;
if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) {
return;
}
}
}
logger.info(
{
modelId,
artifact,
url,
downloadedBytes,
totalBytes,
pct,
},
"Downloading model artifact"
);
lastLoggedPct = pct ?? -1;
lastLoggedBytes = downloadedBytes;
lastLoggedAt = Date.now();
};
logProgress(false);
const progressStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
downloadedBytes += chunk.length;
logProgress(false);
callback(null, chunk);
},
});
await pipeline(nodeStream, createWriteStream(tmpPath));
await rename(tmpPath, outputPath);
try {
await pipeline(nodeStream, progressStream, createWriteStream(tmpPath));
logProgress(true);
await rename(tmpPath, outputPath);
} catch (error) {
await rm(tmpPath, { force: true }).catch(() => undefined);
throw error;
}
}
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
);
}
if (spec.archiveUrl) {
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
const downloadsDir = path.join(options.modelsDir, ".downloads");
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
const archivePath = path.join(downloadsDir, archiveFilename);
try {
if (spec.archiveUrl) {
const downloadsDir = path.join(options.modelsDir, ".downloads");
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname);
const archivePath = path.join(downloadsDir, archiveFilename);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile(spec.archiveUrl, archivePath, logger);
} else {
logger.info({ archivePath }, "Using cached archive");
}
await extractTarArchive(archivePath, options.modelsDir);
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
);
}
try {
await rm(archivePath, { force: true });
} catch {
// ignore
}
logger.info({ modelDir }, "Model ready");
return modelDir;
}
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
logger.info({ modelsDir: options.modelsDir, fileCount: spec.downloadFiles.length }, "Model files missing; downloading");
await mkdir(modelDir, { recursive: true });
for (const file of spec.downloadFiles) {
const dst = path.join(modelDir, file.relPath);
if (await isNonEmptyFile(dst)) {
continue;
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: spec.archiveUrl,
outputPath: archivePath,
logger,
modelId: options.modelId,
artifact: archiveFilename,
});
}
await downloadToFile(file.url, dst, logger);
}
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
logger.info(
{
modelId: options.modelId,
archivePath,
modelDir,
},
"Extracting model archive"
);
await extractTarArchive(archivePath, options.modelsDir);
logger.info(
{
modelId: options.modelId,
modelDir,
},
"Verifying downloaded model files"
);
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`
);
}
logger.info(
{
modelId: options.modelId,
archivePath,
},
"Finalizing model artifacts"
);
try {
await rm(archivePath, { force: true });
} catch {
// ignore
}
logger.info({ modelDir }, "Model download completed");
return modelDir;
}
logger.info({ modelDir }, "Model ready");
return modelDir;
}
if (spec.downloadFiles && spec.downloadFiles.length > 0) {
await mkdir(modelDir, { recursive: true });
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
for (const file of spec.downloadFiles) {
const dst = path.join(modelDir, file.relPath);
if (await isNonEmptyFile(dst)) {
continue;
}
await downloadToFile({
url: file.url,
outputPath: dst,
logger,
modelId: options.modelId,
artifact: file.relPath,
});
}
logger.info(
{
modelId: options.modelId,
modelDir,
},
"Verifying downloaded model files"
);
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) {
throw new Error(
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.`
);
}
logger.info({ modelDir }, "Model download completed");
return modelDir;
}
throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`);
} catch (error) {
logger.error({ err: error }, "Model download failed");
throw error;
}
}
export async function ensureSherpaOnnxModels(options: {

View File

@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
import type { SpeechStreamResult, TextToSpeechProvider } from "../../../speech-provider.js";
import { chunkBuffer, float32ToPcm16le } from "../../../audio.js";
import { loadSherpaOnnx } from "./sherpa-onnx-loader.js";
import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js";
export type SherpaTtsPreset = "kokoro-en-v0_19" | "kitten-nano-en-v0_1-fp16";
@@ -37,7 +37,10 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
this.speakerId = config.speakerId ?? 0;
this.speed = config.speed ?? 1.0;
const sherpa = loadSherpaOnnx();
const sherpa = loadSherpaOnnxNode();
if (typeof sherpa.OfflineTts !== "function") {
throw new Error("sherpa-onnx-node OfflineTts is unavailable");
}
const modelFile = config.preset === "kokoro-en-v0_19" ? "model.onnx" : "model.fp16.onnx";
const modelPath = `${config.modelDir}/${modelFile}`;
@@ -50,30 +53,35 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
assertFileExists(tokensPath, "TTS tokens");
assertFileExists(dataDir, "TTS espeak-ng dataDir");
const modelConfigKey =
const modelConfig =
config.preset === "kokoro-en-v0_19"
? "offlineTtsKokoroModelConfig"
: "offlineTtsKittenModelConfig";
const modelConfig = {
[modelConfigKey]: {
model: modelPath,
voices: voicesPath,
tokens: tokensPath,
dataDir,
lengthScale: config.lengthScale ?? 1.0,
},
numThreads: config.numThreads ?? 2,
debug: 0,
provider: "cpu",
};
? {
kokoro: {
model: modelPath,
voices: voicesPath,
tokens: tokensPath,
dataDir,
lengthScale: config.lengthScale ?? 1.0,
},
}
: {
kitten: {
model: modelPath,
voices: voicesPath,
tokens: tokensPath,
dataDir,
lengthScale: config.lengthScale ?? 1.0,
},
};
const offlineTtsConfig = {
offlineTtsModelConfig: modelConfig,
model: modelConfig,
numThreads: config.numThreads ?? 2,
provider: "cpu",
maxNumSentences: 1,
};
this.tts = sherpa.createOfflineTts(offlineTtsConfig);
this.tts = new sherpa.OfflineTts(offlineTtsConfig);
this.logger.info({ preset: config.preset, modelDir: config.modelDir }, "Sherpa offline TTS initialized");
}

View File

@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
params.persisted.features?.dictation?.stt?.confidenceThreshold,
sttModel:
params.env.STT_MODEL ??
(params.providers.voiceStt.provider === "openai"
(params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "openai"
? params.persisted.features?.voiceMode?.stt?.model
: undefined) ??
(params.providers.dictationStt.provider === "openai"
(params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model
: undefined),
ttsVoice:
params.env.TTS_VOICE ??
(params.providers.voiceTts.provider === "openai"
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "openai"
? params.persisted.features?.voiceMode?.tts?.voice
: undefined) ??
"alloy",
ttsModel:
params.env.TTS_MODEL ??
(params.providers.voiceTts.provider === "openai"
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "openai"
? params.persisted.features?.voiceMode?.tts?.model
: undefined) ??
DEFAULT_OPENAI_TTS_MODEL,
realtimeTranscriptionModel:
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ??
(params.providers.dictationStt.provider === "openai"
(params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model
: undefined) ??
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,

View File

@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
const missingOpenAiCredentialsFor: string[] = [];
if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) {
if (
providers.voiceStt.enabled !== false &&
providers.voiceStt.provider === "openai" &&
!openAiCredentials.openaiSttApiKey
) {
missingOpenAiCredentialsFor.push("voice.stt");
}
if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) {
if (
providers.voiceTts.enabled !== false &&
providers.voiceTts.provider === "openai" &&
!openAiCredentials.openaiTtsApiKey
) {
missingOpenAiCredentialsFor.push("voice.tts");
}
if (
providers.dictationStt.enabled !== false &&
providers.dictationStt.provider === "openai" &&
!openAiCredentials.openaiDictationApiKey
) {
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
let ttsService = existing.ttsService;
let dictationSttService = existing.dictationSttService;
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai";
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai";
const needsOpenAiStt =
!sttService &&
providers.voiceStt.enabled !== false &&
providers.voiceStt.provider === "openai";
const needsOpenAiTts =
!ttsService &&
providers.voiceTts.enabled !== false &&
providers.voiceTts.provider === "openai";
const needsOpenAiDictation =
!dictationSttService && providers.dictationStt.provider === "openai";
!dictationSttService &&
providers.dictationStt.enabled !== false &&
providers.dictationStt.provider === "openai";
if (
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&

View File

@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.local).toEqual({
modelsDir: path.join(paseoHome, "models", "local-speech"),
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: true,
enabled: true,
});
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20");
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8");
@@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
});
test("respects disabled dictation and voice mode feature flags", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: { enabled: false },
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
});
});

View File

@@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z
.pipe(SpeechProviderIdSchema)
.optional();
const OptionalBooleanFlagSchema = z
.union([z.boolean(), z.string().trim().toLowerCase()])
.optional()
.transform((value) => {
if (typeof value === "boolean") {
return value;
}
if (value === undefined) {
return undefined;
}
if (["1", "true", "yes", "y", "on"].includes(value)) {
return true;
}
if (["0", "false", "no", "n", "off"].includes(value)) {
return false;
}
return undefined;
});
const RequestedSpeechProvidersSchema = z.object({
dictationStt: OptionalSpeechProviderSchema.default("local"),
voiceStt: OptionalSpeechProviderSchema.default("local"),
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
}): RequestedSpeechProviders {
const resolveFeatureProvider = (
configuredValue: string | undefined,
parsedValue: z.infer<typeof SpeechProviderIdSchema>
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
enabled: boolean
): RequestedSpeechProvider => ({
provider: parsedValue,
explicit: configuredValue !== undefined,
enabled,
});
const dictationSttProviderFromConfig =
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
const voiceTtsProviderFromConfig =
params.env.PASEO_VOICE_TTS_PROVIDER ??
params.persisted.features?.voiceMode?.tts?.provider;
const dictationEnabled =
OptionalBooleanFlagSchema.parse(
params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled
) ?? true;
const voiceModeEnabled =
OptionalBooleanFlagSchema.parse(
params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled
) ?? true;
const parsed = RequestedSpeechProvidersSchema.parse({
dictationStt: dictationSttProviderFromConfig ?? "local",
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
return {
dictationStt: resolveFeatureProvider(
dictationSttProviderFromConfig,
parsed.dictationStt
parsed.dictationStt,
dictationEnabled
),
voiceStt: resolveFeatureProvider(
voiceSttProviderFromConfig,
parsed.voiceStt
parsed.voiceStt,
voiceModeEnabled
),
voiceTts: resolveFeatureProvider(
voiceTtsProviderFromConfig,
parsed.voiceTts
parsed.voiceTts,
voiceModeEnabled
),
};
}

View File

@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
}
return {
dictationStt: { provider: "local", explicit: false },
voiceStt: { provider: "local", explicit: false },
voiceTts: { provider: "local", explicit: false },
dictationStt: { provider: "local", explicit: false, enabled: true },
voiceStt: { provider: "local", explicit: false, enabled: true },
voiceTts: { provider: "local", explicit: false, enabled: true },
};
}
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
logger.info(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
dictationStt: {
provider: providers.dictationStt.provider,
enabled: providers.dictationStt.enabled !== false,
},
voiceStt: {
provider: providers.voiceStt.provider,
enabled: providers.voiceStt.enabled !== false,
},
voiceTts: {
provider: providers.voiceTts.provider,
enabled: providers.voiceTts.enabled !== false,
},
},
availability: {
openai: getOpenAiSpeechAvailability(openaiConfig),
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
: "openai",
};
const unavailableFeatures = [
!openAiSpeech.dictationSttService ? "dictation.stt" : null,
!openAiSpeech.sttService ? "voice.stt" : null,
!openAiSpeech.ttsService ? "voice.tts" : null,
providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
? "dictation.stt"
: null,
providers.voiceStt.enabled !== false && !openAiSpeech.sttService ? "voice.stt" : null,
providers.voiceTts.enabled !== false && !openAiSpeech.ttsService ? "voice.tts" : null,
].filter((feature): feature is string => feature !== null);
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
if (feature === "dictation.stt") {
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
logger.error(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
dictationStt: {
provider: providers.dictationStt.provider,
enabled: providers.dictationStt.enabled !== false,
},
voiceStt: {
provider: providers.voiceStt.provider,
enabled: providers.voiceStt.enabled !== false,
},
voiceTts: {
provider: providers.voiceTts.provider,
enabled: providers.voiceTts.enabled !== false,
},
},
explicitProviders: {
dictationStt: providers.dictationStt.explicit,
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
logger.warn(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
voiceStt: providers.voiceStt.provider,
voiceTts: providers.voiceTts.provider,
dictationStt: {
provider: providers.dictationStt.provider,
enabled: providers.dictationStt.enabled !== false,
},
voiceStt: {
provider: providers.voiceStt.provider,
enabled: providers.voiceStt.enabled !== false,
},
voiceTts: {
provider: providers.voiceTts.provider,
enabled: providers.voiceTts.enabled !== false,
},
},
explicitProviders: {
dictationStt: providers.dictationStt.explicit,

View File

@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
export const RequestedSpeechProviderSchema = z.object({
provider: SpeechProviderIdSchema,
explicit: z.boolean(),
enabled: z.boolean().optional(),
});
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;

View File

@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
})
);
connectionLogger.info(
connectionLogger.trace(
{ clientId, totalSessions: this.sessions.size },
"Client connected"
);
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
const session = this.sessions.get(ws);
if (!session) return;
connectionLogger.info(
connectionLogger.trace(
{ clientId, totalSessions: this.sessions.size - 1 },
"Client disconnected"
);
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
const message = parsedMessage.data;
const messageSummary = {
type: message.type,
...(message.type === "session" && message.message
? { sessionMessageType: message.message.type }
: {}),
};
const isSessionNoise =
message.type === "session" &&
(message.message.type === "client_heartbeat" ||
message.message.type === "voice_audio_chunk" ||
message.message.type === "dictation_stream_chunk");
if (!isSessionNoise) {
this.logger.debug(messageSummary, "Received message");
}
if (message.type === "ping") {
this.sendToClient(ws, { type: "pong" });
return;
}
if (message.type === "recording_state") {
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
return;
}
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
}
if (message.type === "session") {
if (message.message.type === "create_agent_request") {
this.logger.debug(
{
cwd: message.message.config.cwd,
initialMode: message.message.config.modeId,
worktreeName: message.message.worktreeName,
requestId: message.message.requestId,
},
"create_agent_request details"
);
}
await session.handleMessage(message.message);
}
} catch (error) {
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
} {
const activity = session.getClientActivity();
if (!activity) {
this.logger.debug("getClientActivityState: no activity for session");
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
}
const now = Date.now();
const ageMs = now - activity.lastActivityAt.getTime();
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS;
this.logger.debug(
{
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
lastActivityAt: activity.lastActivityAt.toISOString(),
ageMs,
isStale,
appVisible: activity.appVisible,
},
"getClientActivityState"
);
return {
deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId,
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
this.logger.debug(
{
agentId: params.agentId,
reason: params.reason,
clientCount: clientEntries.length,
allStates,
},
"broadcastAgentAttention"
);
const hasActiveWebClient = allStates.some(
(state) => state.deviceType === "web" && !state.isStale
);
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
!hasActiveWebClient &&
!hasActiveMobileForegroundClient;
this.logger.debug(
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
"Push gating check"
);
if (shouldSendPush) {
const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification");

View File

@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
AgentStreamSnapshotMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WSOutboundMessageSchema,
} from "./messages.js";
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
});
expect(wrapped.success).toBe(false);
});
it("rejects removed legacy git diff request messages", () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request",
agentId: "agent-1",
requestId: "req-1",
});
expect(gitDiffParsed.success).toBe(false);
const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request",
agentId: "agent-1",
requestId: "req-2",
});
expect(highlightedParsed.success).toBe(false);
});
it("rejects removed legacy git diff response messages", () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response",
payload: {
agentId: "agent-1",
diff: "",
error: null,
requestId: "req-1",
},
});
expect(gitDiffParsed.success).toBe(false);
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response",
payload: {
agentId: "agent-1",
files: [],
error: null,
requestId: "req-2",
},
});
expect(highlightedParsed.success).toBe(false);
});
});

View File

@@ -422,6 +422,14 @@ export const ArchiveAgentRequestMessageSchema = z.object({
requestId: z.string(),
});
export const UpdateAgentRequestMessageSchema = z.object({
type: z.literal("update_agent_request"),
agentId: z.string(),
name: z.string().optional(),
labels: z.record(z.string()).optional(),
requestId: z.string(),
});
export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"),
enabled: z.boolean(),
@@ -540,6 +548,7 @@ export const CreateAgentRequestMessageSchema = z.object({
config: AgentSessionConfigSchema,
worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
outputSchema: z.record(z.unknown()).optional(),
images: z.array(z.object({
data: z.string(), // base64 encoded image
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
@@ -556,6 +565,11 @@ export const ListProviderModelsRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ListAvailableProvidersRequestMessageSchema = z.object({
type: z.literal("list_available_providers_request"),
requestId: z.string(),
});
export const SpeechModelsListRequestSchema = z.object({
type: z.literal("speech_models_list_request"),
requestId: z.string(),
@@ -660,6 +674,16 @@ export const SetAgentThinkingResponseMessageSchema = z.object({
}),
});
export const UpdateAgentResponseMessageSchema = z.object({
type: z.literal("update_agent_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"),
payload: z.object({
@@ -678,12 +702,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
response: AgentPermissionResponseSchema,
});
export const GitDiffRequestSchema = z.object({
type: z.literal("git_diff_request"),
agentId: z.string(),
requestId: z.string(),
});
const CheckoutErrorCodeSchema = z.enum([
"NOT_GIT_REPO",
"NOT_ALLOWED",
@@ -819,12 +837,6 @@ const ParsedDiffFileSchema = z.object({
status: z.enum(["ok", "too_large", "binary"]).optional(),
});
export const HighlightedDiffRequestSchema = z.object({
type: z.literal("highlighted_diff_request"),
agentId: z.string(),
requestId: z.string(),
});
const FileExplorerEntrySchema = z.object({
name: z.string(),
path: z.string(),
@@ -971,6 +983,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeAgentUpdatesMessageSchema,
DeleteAgentRequestMessageSchema,
ArchiveAgentRequestMessageSchema,
UpdateAgentRequestMessageSchema,
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
@@ -980,6 +993,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
DictationStreamCancelMessageSchema,
CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
SpeechModelsListRequestSchema,
SpeechModelsDownloadRequestSchema,
ResumeAgentRequestMessageSchema,
@@ -991,7 +1005,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
@@ -1004,7 +1017,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ValidateBranchRequestSchema,
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
HighlightedDiffRequestSchema,
FileExplorerRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
@@ -1355,6 +1367,7 @@ export const WaitForFinishResponseMessageSchema = z.object({
status: z.enum(["idle", "error", "permission", "timeout"]),
final: AgentSnapshotPayloadSchema.nullable(),
error: z.string().nullable(),
lastMessage: z.string().nullable(),
}),
});
@@ -1392,16 +1405,6 @@ export const AgentArchivedMessageSchema = z.object({
}),
});
export const GitDiffResponseSchema = z.object({
type: z.literal("git_diff_response"),
payload: z.object({
agentId: z.string(),
diff: z.string(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
const AheadBehindSchema = z.object({
ahead: z.number(),
behind: z.number(),
@@ -1545,6 +1548,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
payload: z.object({
cwd: z.string(),
status: CheckoutPrStatusSchema.nullable(),
githubFeaturesEnabled: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
@@ -1586,16 +1590,6 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
}),
});
export const HighlightedDiffResponseSchema = z.object({
type: z.literal("highlighted_diff_response"),
payload: z.object({
agentId: z.string(),
files: z.array(ParsedDiffFileSchema),
error: z.string().nullable(),
requestId: z.string(),
}),
});
export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"),
payload: z.object({
@@ -1649,6 +1643,22 @@ export const ListProviderModelsResponseMessageSchema = z.object({
}),
});
const ProviderAvailabilitySchema = z.object({
provider: AgentProviderSchema,
available: z.boolean(),
error: z.string().nullable().optional(),
});
export const ListAvailableProvidersResponseSchema = z.object({
type: z.literal("list_available_providers_response"),
payload: z.object({
providers: z.array(ProviderAvailabilitySchema),
error: z.string().nullable().optional(),
fetchedAt: z.string(),
requestId: z.string(),
}),
});
export const SpeechModelsListResponseSchema = z.object({
type: z.literal("speech_models_list_response"),
payload: z.object({
@@ -1809,12 +1819,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
UpdateAgentResponseMessageSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
AgentDeletedMessageSchema,
AgentArchivedMessageSchema,
GitDiffResponseSchema,
CheckoutStatusResponseSchema,
SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema,
@@ -1827,11 +1837,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ValidateBranchResponseSchema,
PaseoWorktreeListResponseSchema,
PaseoWorktreeArchiveResponseSchema,
HighlightedDiffResponseSchema,
FileExplorerResponseSchema,
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
ListProviderModelsResponseMessageSchema,
ListAvailableProvidersResponseSchema,
SpeechModelsListResponseSchema,
SpeechModelsDownloadResponseSchema,
ListCommandsResponseSchema,
@@ -1878,6 +1888,7 @@ export type SendAgentMessageResponseMessage = z.infer<
export type SetVoiceModeResponseMessage = z.infer<
typeof SetVoiceModeResponseMessageSchema
>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type WaitForFinishResponseMessage = z.infer<
typeof WaitForFinishResponseMessageSchema
>;
@@ -1887,6 +1898,9 @@ export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
export type ListProviderModelsResponseMessage = z.infer<
typeof ListProviderModelsResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<
typeof ListAvailableProvidersResponseSchema
>;
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
export type InitializeAgentResponseMessage = z.infer<typeof InitializeAgentResponseMessageSchema>;
@@ -1911,19 +1925,21 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema
>;
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
export type SpeechModelsDownloadRequestMessage = z.infer<
typeof SpeechModelsDownloadRequestSchema
>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type SubscribeCheckoutDiffRequest = z.infer<
@@ -1954,8 +1970,6 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;

View File

@@ -0,0 +1,82 @@
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const spawnCounters = vi.hoisted(() => ({
trackedTextDiffCalls: 0,
}));
vi.mock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) => {
const [command, commandArgs] = args;
if (command === "git" && Array.isArray(commandArgs)) {
const normalizedArgs = commandArgs.map((arg) => String(arg));
const isTrackedTextDiff =
normalizedArgs[0] === "diff" &&
normalizedArgs.includes("HEAD") &&
!normalizedArgs.includes("--numstat") &&
!normalizedArgs.includes("--no-index");
if (isTrackedTextDiff) {
spawnCounters.trackedTextDiffCalls += 1;
}
}
return actual.spawn(...args);
},
};
});
import { getCheckoutDiff } from "./checkout-git.js";
function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
const repoDir = join(tempDir, "repo");
execSync(`mkdir -p ${repoDir}`);
execSync("git init -b main", { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
}
execSync("git add .", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
}
return { tempDir, repoDir };
}
describe("checkout git diff batching", () => {
let tempDir: string;
let repoDir: string;
beforeEach(() => {
const setup = initRepoWithTrackedChanges(20);
tempDir = setup.tempDir;
repoDir = setup.repoDir;
spawnCounters.trackedTextDiffCalls = 0;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("uses a single tracked git diff command for tracked file diffs", async () => {
const result = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: false,
});
expect(result.diff).toContain("file-0.txt");
expect(result.diff).toContain("file-19.txt");
expect(spawnCounters.trackedTextDiffCalls).toBe(1);
});
});

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync, symlinkSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
commitAll,
getCheckoutDiff,
getPullRequestStatus,
getCheckoutStatus,
getCheckoutStatusLite,
mergeToBase,
@@ -141,6 +142,27 @@ describe("checkout git utilities", () => {
);
});
it("short-circuits tracked binary files", async () => {
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
execSync("git add tracked-blob.bin", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", {
cwd: repoDir,
});
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00]));
const diff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: true,
});
const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin");
expect(entry).toBeTruthy();
expect(entry?.status).toBe("binary");
expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted");
});
it("short-circuits untracked binary files", async () => {
const binaryPath = join(repoDir, "blob.bin");
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00]));
@@ -380,6 +402,29 @@ describe("checkout git utilities", () => {
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
});
it("disables GitHub features when gh is unavailable", async () => {
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
const originalPath = process.env.PATH;
process.env.PATH = fakeBinDir;
try {
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
}
});
it("returns typed MergeConflictError on merge conflicts", async () => {
const conflictFile = join(repoDir, "conflict.txt");
writeFileSync(conflictFile, "base\n");

View File

@@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
async function tryGetNumstat(
cwd: string,
args: string[]
): Promise<FileStat> {
try {
const { text } = await spawnLimitedText({
cmd: "git",
args,
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: 64 * 1024,
acceptExitCodes: [0],
});
const line = text.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
if (!line) return null;
const [aRaw, dRaw] = line.split(/\s+/);
if (!aRaw || !dRaw) return null;
if (aRaw === "-" || dRaw === "-") {
return { additions: 0, deletions: 0, isBinary: true };
}
const additions = Number.parseInt(aRaw, 10);
const deletions = Number.parseInt(dRaw, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
return null;
}
return { additions, deletions, isBinary: false };
} catch {
return null;
function normalizeNumstatPath(pathField: string): string {
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceRenameMatch) {
const [, prefix, , renamed, suffix] = braceRenameMatch;
return `${prefix}${renamed}${suffix}`;
}
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
if (inlineRenameMatch) {
return inlineRenameMatch[2] ?? pathField;
}
return pathField;
}
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath(
cwd: string,
ref: string
): Promise<Map<string, FileStat>> {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--numstat", ref],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
acceptExitCodes: [0],
});
const stats = new Map<string, FileStat>();
const lines = result.text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const parts = line.split("\t");
if (parts.length < 3) {
continue;
}
const additionsField = parts[0] ?? "";
const deletionsField = parts[1] ?? "";
const rawPath = parts.slice(2).join("\t");
const path = normalizeNumstatPath(rawPath);
if (!path) {
continue;
}
if (additionsField === "-" || deletionsField === "-") {
stats.set(path, { additions: 0, deletions: 0, isBinary: true });
continue;
}
const additions = Number.parseInt(additionsField, 10);
const deletions = Number.parseInt(deletionsField, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
stats.set(path, null);
continue;
}
stats.set(path, { additions, deletions, isBinary: false });
}
return stats;
}
function isTrackedDiffTooLarge(stat: FileStat): boolean {
if (!stat || stat.isBinary) {
return false;
}
return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES;
}
export class NotGitRepoError extends Error {
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
};
}
async function getPerFileDiffText(
async function getUntrackedDiffText(
cwd: string,
ref: string,
change: CheckoutFileChange
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
if (change.isUntracked) {
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
if (inspected.stat?.isBinary || inspected.truncated) {
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
}
} catch {
// Fall through to git diff path if metadata probing fails.
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
if (inspected.stat?.isBinary || inspected.truncated) {
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
}
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
});
return {
text: result.text,
truncated: result.truncated,
stat: { additions: 0, deletions: 0, isBinary: false },
};
}
const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
if (stat?.isBinary) {
return { text: "", truncated: false, stat };
} catch {
// Fall through to git diff path if metadata probing fails.
}
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", ref, "--", change.path],
args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
});
return { text: result.text, truncated: result.truncated, stat };
return {
text: result.text,
truncated: result.truncated,
stat: { additions: 0, deletions: 0, isBinary: false },
};
}
export async function getCheckoutStatus(
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
}
};
for (const change of changes) {
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
const trackedChanges = changes.filter((change) => !change.isUntracked);
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
const trackedNumstatByPath =
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map<
string,
{ status: "binary" | "too_large"; stat: FileStat }
>();
for (const change of trackedChanges) {
const stat = trackedNumstatByPath.get(change.path) ?? null;
if (stat?.isBinary) {
trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
continue;
}
if (isTrackedDiffTooLarge(stat)) {
trackedPlaceholderByPath.set(change.path, { status: "too_large", stat });
continue;
}
trackedDiffPaths.push(change.path);
}
let trackedDiffText = "";
let trackedDiffTruncated = false;
if (trackedDiffPaths.length > 0) {
const trackedDiffResult = await spawnLimitedText({
cmd: "git",
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: TOTAL_DIFF_MAX_BYTES,
});
trackedDiffText = trackedDiffResult.text;
trackedDiffTruncated = trackedDiffResult.truncated;
appendDiff(trackedDiffText);
if (trackedDiffTruncated) {
appendDiff("# tracked diff truncated\n");
}
}
const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => {
if (status === "binary") {
appendDiff(`# ${change.path}: binary diff omitted\n`);
return;
}
appendDiff(`# ${change.path}: diff too large omitted\n`);
};
if (compare.includeStructured) {
const parsedTrackedFiles =
trackedDiffText.length > 0 ? await parseAndHighlightDiff(trackedDiffText, cwd) : [];
const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file]));
for (const change of trackedChanges) {
const placeholder = trackedPlaceholderByPath.get(change.path);
if (placeholder) {
structured.push(
buildPlaceholderParsedDiffFile(change, {
status: placeholder.status,
stat: placeholder.stat,
})
);
appendTrackedPlaceholderComment(change, placeholder.status);
continue;
}
const stat = trackedNumstatByPath.get(change.path) ?? null;
const parsedFile = parsedTrackedByPath.get(change.path);
if (parsedFile) {
structured.push({
...parsedFile,
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
status: "ok",
});
continue;
}
structured.push({
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
hunks: [],
status: trackedDiffTruncated ? "too_large" : "ok",
});
}
} else {
for (const change of trackedChanges) {
const placeholder = trackedPlaceholderByPath.get(change.path);
if (placeholder) {
appendTrackedPlaceholderComment(change, placeholder.status);
}
}
}
for (const change of untrackedChanges) {
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
if (!compare.includeStructured) {
if (stat?.isBinary) {
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
} else {
appendDiff(text);
}
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
continue;
}
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
headRefName: string;
}
export interface PullRequestStatusResult {
status: PullRequestStatus | null;
githubFeaturesEnabled: boolean;
}
async function ensureGhAvailable(cwd: string): Promise<void> {
try {
await execAsync("gh --version", { cwd });
@@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise<void> {
}
}
function getCommandErrorText(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
}
function isGhAuthError(error: unknown): boolean {
const text = getCommandErrorText(error);
return (
text.includes("gh auth login") ||
text.includes("not logged into any github hosts") ||
text.includes("authentication failed") ||
text.includes("authentication required") ||
text.includes("bad credentials") ||
text.includes("http 401")
);
}
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git config --get remote.origin.url", {
@@ -1356,39 +1511,66 @@ export async function createPullRequest(
return { url: parsed.url, number: parsed.number };
}
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> {
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd);
await ensureGhAvailable(cwd);
const repo = await resolveGitHubRepo(cwd);
const head = await getCurrentBranch(cwd);
if (!repo || !head) {
return null;
return {
status: null,
githubFeaturesEnabled: false,
};
}
try {
await ensureGhAvailable(cwd);
} catch {
return {
status: null,
githubFeaturesEnabled: false,
};
}
const owner = repo.split("/")[0];
const { stdout } = await execFileAsync(
"gh",
[
"api",
`repos/${repo}/pulls`,
"-X",
"GET",
"-F",
`head=${owner}:${head}`,
"-F",
"state=open",
],
{ cwd }
);
let stdout: string;
try {
({ stdout } = await execFileAsync(
"gh",
[
"api",
`repos/${repo}/pulls`,
"-X",
"GET",
"-F",
`head=${owner}:${head}`,
"-F",
"state=open",
],
{ cwd }
));
} catch (error) {
if (isGhAuthError(error)) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
throw error;
}
const parsed = JSON.parse(stdout.trim());
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
if (!current) {
return null;
return {
status: null,
githubFeaturesEnabled: true,
};
}
return {
url: current.html_url ?? current.url,
title: current.title,
state: current.state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? head,
status: {
url: current.html_url ?? current.url,
title: current.title,
state: current.state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? head,
},
githubFeaturesEnabled: true,
};
}

View File

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

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as DocsRouteImport } from './routes/docs'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
@@ -19,6 +20,11 @@ import { Route as DocsConfigurationRouteImport } from './routes/docs/configurati
import { Route as DocsCliRouteImport } from './routes/docs/cli'
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
const DocsRoute = DocsRouteImport.update({
id: '/docs',
path: '/docs',
@@ -68,6 +74,7 @@ const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/docs': typeof DocsRouteWithChildren
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
@@ -78,6 +85,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
@@ -90,6 +98,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/docs': typeof DocsRouteWithChildren
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
@@ -103,6 +112,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/docs'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
@@ -113,6 +123,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/docs'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
@@ -136,10 +148,18 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DocsRoute: typeof DocsRouteWithChildren
PrivacyRoute: typeof PrivacyRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/privacy': {
id: '/privacy'
path: '/privacy'
fullPath: '/privacy'
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/docs': {
id: '/docs'
path: '/docs'
@@ -231,6 +251,7 @@ const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DocsRoute: DocsRouteWithChildren,
PrivacyRoute: PrivacyRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -64,12 +64,23 @@ paseo stop <id> # Stop an agent`}</pre>
<pre className="text-white/80">{`paseo run "implement user authentication"
paseo run --provider codex "refactor the API layer"
paseo run --detach "run the full test suite" # background
paseo run --worktree feature-x "implement feature X"`}</pre>
paseo run --worktree feature-x "implement feature X"
paseo run --output-schema schema.json "extract release notes"
paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes"`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
The <code className="font-mono">--worktree</code> flag creates the agent in an isolated git
worktree, useful for parallel feature development.
</p>
<p className="text-white/60 leading-relaxed">
Use <code className="font-mono">--output-schema</code> to return only matching JSON output.
You can pass a schema file path or an inline JSON schema object.
This mode cannot be used with <code className="font-mono">--detach</code>.
</p>
<p className="text-white/60 leading-relaxed">
By default, <code className="font-mono">paseo run</code> waits for completion. Use{' '}
<code className="font-mono">--detach</code> to run in the background.
</p>
</section>
{/* Listing agents */}
@@ -187,6 +198,21 @@ paseo daemon stop # Stop the daemon`}</pre>
paseo run --detach "implement the API" --name api-agent
paseo wait api-agent
paseo logs api-agent --tail 5`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
Simple implement + verify loop:
</p>
<Code>
<pre className="text-white/80">{`# Requires jq
while true; do
paseo run --provider codex "make the tests pass" >/dev/null
verdict=$(paseo run --provider claude --output-schema '{"type":"object","properties":{"criteria_met":{"type":"boolean"}},"required":["criteria_met"],"additionalProperties":false}' "ensure tests all pass")
if echo "$verdict" | jq -e '.criteria_met == true' >/dev/null; then
echo "criteria met"
break
fi
done`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
This pattern enables hierarchical task decomposition — a lead agent can break down work,

View File

@@ -53,6 +53,16 @@ function Home() {
<Story />
<FAQ />
</main>
<footer className="p-6 md:p-20 md:pt-0 max-w-3xl mx-auto">
<div className="border-t border-white/10 pt-6">
<a
href="/privacy"
className="text-xs text-white/40 hover:text-white/60 transition-colors"
>
Privacy
</a>
</div>
</footer>
</div>
</CursorFieldProvider>
)
@@ -180,18 +190,26 @@ function Feature({
function GetStarted() {
return (
<div className="pt-10">
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="pt-10 space-y-4">
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
<div className="flex flex-col sm:flex-row gap-3">
<a
href="https://github.com/getpaseo/paseo/releases/latest"
href="https://github.com/getpaseo/paseo/releases/download/v0.1.2/Paseo_0.1.2_aarch64.dmg"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
Download for Mac
</a>
<a
href="https://app.paseo.sh"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
Launch Web App
</a>
</div>
<CodeBlock>npm install -g @getpaseo/cli && paseo</CodeBlock>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/privacy')({
head: () => ({
meta: [
{ title: 'Privacy Policy - Paseo' },
{
name: 'description',
content:
'Privacy policy for Paseo - a self-hosted agent manager with no tracking or analytics.',
},
],
}),
component: Privacy,
})
function Privacy() {
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto p-6 md:p-12">
<h1 className="text-3xl font-medium mb-8">Privacy Policy</h1>
<div className="space-y-6 text-white/70 leading-relaxed">
<p>
Paseo is a self-hosted tool for managing coding agents. Your code and
data stay on your machine.
</p>
<section className="space-y-3">
<h2 className="text-xl font-medium text-white">What we collect</h2>
<p>Nothing. Paseo runs on your machine and doesn't send us any data.</p>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium text-white">The relay server</h2>
<p>
If you use the optional encrypted relay to connect your phone to your
daemon, the relay sees:
</p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>IP addresses and connection timing</li>
<li>Message sizes</li>
<li>Session IDs</li>
</ul>
<p>
All messages between your phone and daemon are end-to-end encrypted
with AES-256-GCM. The relay cannot read your messages, see your code,
or decrypt your traffic.
</p>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium text-white">Analytics and tracking</h2>
<p>
We don't use analytics, tracking pixels, cookies, or ads. The app
doesn't phone home.
</p>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium text-white">Third-party services</h2>
<p>
Paseo wraps agent providers like Claude Code, Codex, and OpenCode.
Those tools communicate with their own APIs (Anthropic, OpenAI, etc.)
using your credentials. Paseo doesn't manage or intercept those API
calls.
</p>
<p>
If you use voice features with cloud providers (OpenAI speech), your
voice data is sent to those services according to their privacy
policies.
</p>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium text-white">
We don't sell your data
</h2>
<p>
We don't have your data to sell. Paseo is self-hosted and
local-first.
</p>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium text-white">Questions</h2>
<p>
If you have questions about privacy, open an issue on{' '}
<a
href="https://github.com/getpaseo/paseo"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/90"
>
GitHub
</a>
.
</p>
</section>
<p className="text-sm text-white/50 pt-6">Last updated: February 2025</p>
</div>
</div>
</div>
)
}