Merge branch 'main' into fix/token-display-exclude-cache-read

This commit is contained in:
Can Bölük
2026-05-14 04:45:05 +02:00
committed by GitHub
177 changed files with 5309 additions and 6229 deletions
+29
View File
@@ -0,0 +1,29 @@
{
// Fallow configuration — see `bunx fallow config-schema` for the full schema.
//
// `entry` is critical: fallow's dead-code analyzer treats unlisted test files
// as unreachable, which means any helper imported only from other tests looks
// orphaned. Without this, `fallow fix` strips real `export` keywords from
// shared test utilities (e.g. `e2eApiKey`, `withEnv`, `waitForDelayOrAbort`),
// breaking the test build with `noUnusedVariables` lint errors.
"entry": [
"packages/*/test/**/*.{ts,tsx}",
"packages/*/test/**/*.test.{ts,tsx}",
"packages/*/bench/**/*.{ts,tsx}",
"packages/*/scripts/**/*.ts"
],
"duplicates": {
"ignore": [
// Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts.
"packages/natives/native/index.js",
// Generated by `packages/coding-agent/scripts/generate-docs-index.ts`.
"packages/coding-agent/src/internal-urls/docs-index.generated.ts",
// Embedded HTML asset shipped as a static template, not a code module.
"packages/coding-agent/src/export/html/template.js",
// Generated/owned upstream — see packages/ai/scripts/generate-models.ts.
"packages/ai/src/models.json"
],
"ignoreImports": true
},
"rules": {}
}
+6
View File
@@ -7,10 +7,16 @@
},
"linter": {
"enabled": true,
"includes": ["**"],
"rules": {
"recommended": true,
"a11y": "off",
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": {
"level": "warn",
"fix": "none"
},
"noVoidTypeReturn": "off"
},
"style": {
+12 -12
View File
@@ -402,33 +402,33 @@
"@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="],
"@inquirer/checkbox": ["@inquirer/checkbox@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ=="],
"@inquirer/checkbox": ["@inquirer/checkbox@5.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ=="],
"@inquirer/confirm": ["@inquirer/confirm@6.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og=="],
"@inquirer/confirm": ["@inquirer/confirm@6.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw=="],
"@inquirer/core": ["@inquirer/core@11.1.9", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg=="],
"@inquirer/core": ["@inquirer/core@11.1.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A=="],
"@inquirer/editor": ["@inquirer/editor@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA=="],
"@inquirer/editor": ["@inquirer/editor@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg=="],
"@inquirer/expand": ["@inquirer/expand@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g=="],
"@inquirer/expand": ["@inquirer/expand@5.0.14", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ=="],
"@inquirer/external-editor": ["@inquirer/external-editor@3.0.0", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg=="],
"@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="],
"@inquirer/input": ["@inquirer/input@5.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q=="],
"@inquirer/input": ["@inquirer/input@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw=="],
"@inquirer/number": ["@inquirer/number@4.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg=="],
"@inquirer/number": ["@inquirer/number@4.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg=="],
"@inquirer/password": ["@inquirer/password@5.0.12", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ=="],
"@inquirer/password": ["@inquirer/password@5.0.13", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg=="],
"@inquirer/prompts": ["@inquirer/prompts@8.4.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.4", "@inquirer/confirm": "^6.0.12", "@inquirer/editor": "^5.1.1", "@inquirer/expand": "^5.0.13", "@inquirer/input": "^5.0.12", "@inquirer/number": "^4.0.12", "@inquirer/password": "^5.0.12", "@inquirer/rawlist": "^5.2.8", "@inquirer/search": "^4.1.8", "@inquirer/select": "^5.1.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q=="],
"@inquirer/prompts": ["@inquirer/prompts@8.4.3", "", { "dependencies": { "@inquirer/checkbox": "^5.1.5", "@inquirer/confirm": "^6.0.13", "@inquirer/editor": "^5.1.2", "@inquirer/expand": "^5.0.14", "@inquirer/input": "^5.0.13", "@inquirer/number": "^4.0.13", "@inquirer/password": "^5.0.13", "@inquirer/rawlist": "^5.2.9", "@inquirer/search": "^4.1.9", "@inquirer/select": "^5.1.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw=="],
"@inquirer/rawlist": ["@inquirer/rawlist@5.2.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg=="],
"@inquirer/rawlist": ["@inquirer/rawlist@5.2.9", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw=="],
"@inquirer/search": ["@inquirer/search@4.1.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw=="],
"@inquirer/search": ["@inquirer/search@4.1.9", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA=="],
"@inquirer/select": ["@inquirer/select@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q=="],
"@inquirer/select": ["@inquirer/select@5.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg=="],
"@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="],
+1 -1
View File
@@ -2,7 +2,7 @@ telemetry = false
[install]
minimumReleaseAge = 259200 # 3 days in seconds
minimumReleaseAgeExcludes = ["@types/bun"]
minimumReleaseAgeExcludes = ["@types/bun", "bun-types"]
linker = "hoisted"
exact = true
saveTextLockfile = true
+1 -1
View File
@@ -227,7 +227,7 @@ Watch flow:
- `gh` interactive editor fallback is suppressed for `pr_create` by forcing either `--body-file` or `--body ""`.
- `gh-renderer` provides compact headers for all ops and a custom live watch view for `run_watch`.
- Background work / cancellation
- `run_watch` loops until success/failure and uses `abortableSleep()` between polls.
- `run_watch` loops until success/failure and uses `scheduler.wait()` between polls.
- `GithubTool.execute()` is wrapped in `untilAborted()`; `git.github.run()` forwards the abort signal into `Bun.spawn()`.
## Limits & Caps
+141 -142
View File
@@ -1,144 +1,143 @@
{
"name": "omp-monorepo",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"workspaces": {
"packages": [
"packages/*"
],
"catalog": {
"@agentclientprotocol/sdk": "0.21.0",
"@anthropic-ai/sdk": "^0.94.0",
"@aws-sdk/client-bedrock-runtime": "^3.1043.0",
"@aws-sdk/credential-provider-node": "^3.972.39",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.3",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@biomejs/biome": "^2.4.14",
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0",
"@google/genai": "^1.52.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.0.0",
"@oh-my-pi/pi-agent-core": "15.0.0",
"@oh-my-pi/pi-ai": "15.0.0",
"@oh-my-pi/pi-coding-agent": "15.0.0",
"@oh-my-pi/pi-natives": "15.0.0",
"@oh-my-pi/pi-tui": "15.0.0",
"@oh-my-pi/pi-utils": "15.0.0",
"@puppeteer/browsers": "^2.13.0",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.6.1",
"@tailwindcss/node": "^4.2.4",
"@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/turndown": "5.0.6",
"@typescript/native-preview": "7.0.0-dev.20260505.1",
"@xterm/headless": "^6.0.0",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"beautiful-mermaid": "^1.1.3",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"fflate": "0.8.2",
"handlebars": "^4.7.9",
"linkedom": "^0.18.12",
"lint-staged": "^16.4.0",
"lru-cache": "11.3.6",
"lucide-react": "^1.14.0",
"marked": "^18.0.3",
"markit-ai": "0.5.3",
"openai": "^6.36.0",
"partial-json": "^0.1.7",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"proxy-agent": "^8.0.1",
"puppeteer-core": "^24.42.0",
"react": "19.2.5",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.5",
"regexp-tree": "^0.1.27",
"tailwindcss": "^4.2.4",
"turndown": "7.2.4",
"turndown-plugin-gfm": "1.0.2",
"typescript": "^6.0.3",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "4.4.3"
}
},
"overrides": {
"ajv": "8.20.0"
},
"scripts": {
"install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link",
"dev": "bun --cwd=packages/coding-agent src/cli.ts",
"stats": "bun --cwd=packages/coding-agent src/cli.ts stats",
"build": "bun run --workspaces --if-present build",
"build:native": "bun --cwd=packages/natives run build",
"test": "bun run --parallel test:ts test:rs",
"test:ts": "bun run --workspaces --if-present test -- --only-failures",
"test:rs": "bun scripts/run-rs-task.ts test:rs",
"check": "bun run --parallel check:ts check:rs",
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
"check:tools": "biome check . --no-errors-on-unmatched",
"check:rs": "bun scripts/run-rs-task.ts check:rs",
"lint": "bun run --parallel lint:ts lint:rs",
"lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint",
"lint:tools": "biome lint . --no-errors-on-unmatched",
"lint:rs": "bun scripts/run-rs-task.ts lint:rs",
"fmt": "bun run --parallel fmt:ts fmt:rs",
"fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt",
"fmt:tools": "biome format --write . --no-errors-on-unmatched",
"fmt:rs": "bun scripts/run-rs-task.ts fmt:rs",
"fix": "bun run --parallel fix:ts fix:rs",
"fix:all": "bun run --parallel fix:ts:all fix:rs",
"fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix",
"fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix",
"fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .",
"fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .",
"fix:rs": "bun scripts/run-rs-task.ts fix:rs",
"ci:check:full": "bun run check:ts",
"ci:build:native": "bun scripts/ci-build-native.ts",
"ci:test:full": "bun run test",
"ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test",
"ci:test:install-methods": "bash scripts/install-tests/run-ci.sh",
"ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts",
"ci:release:publish": "bun scripts/ci-release-publish.ts",
"bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8",
"bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start",
"stats:sync": "python3 scripts/session-stats/sync.py",
"stats:tools": "python3 scripts/session-stats/analyze.py tools",
"stats:edits": "python3 scripts/session-stats/analyze.py edits",
"stats:followups": "python3 scripts/session-stats/analyze.py followups",
"prepublishOnly": "bun run check",
"prepare": "bun --cwd=packages/coding-agent run generate-docs-index",
"publish": "bun run prepublishOnly && npm publish -ws --access public",
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
"release": "bun scripts/release.ts",
"generate-models": "bun --cwd=packages/ai run generate-models",
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
"generate-template": "bun --cwd=packages/coding-agent run generate-template",
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"@bufbuild/protoc-gen-es": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"lint-staged": "catalog:",
"prettier": "catalog:"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
}
"name": "omp-monorepo",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"workspaces": {
"packages": [
"packages/*"
],
"catalog": {
"@agentclientprotocol/sdk": "0.21.0",
"@anthropic-ai/sdk": "^0.94.0",
"@aws-sdk/client-bedrock-runtime": "^3.1043.0",
"@aws-sdk/credential-provider-node": "^3.972.39",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.3",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@biomejs/biome": "^2.4.14",
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0",
"@google/genai": "^1.52.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.0.0",
"@oh-my-pi/pi-agent-core": "15.0.0",
"@oh-my-pi/pi-ai": "15.0.0",
"@oh-my-pi/pi-coding-agent": "15.0.0",
"@oh-my-pi/pi-natives": "15.0.0",
"@oh-my-pi/pi-tui": "15.0.0",
"@oh-my-pi/pi-utils": "15.0.0",
"@puppeteer/browsers": "^2.13.0",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.6.1",
"@tailwindcss/node": "^4.2.4",
"@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/turndown": "5.0.6",
"@typescript/native-preview": "7.0.0-dev.20260505.1",
"@xterm/headless": "^6.0.0",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"beautiful-mermaid": "^1.1.3",
"chalk": "^5.6.2",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"fflate": "0.8.2",
"handlebars": "^4.7.9",
"linkedom": "^0.18.12",
"lint-staged": "^16.4.0",
"lru-cache": "11.3.6",
"lucide-react": "^1.14.0",
"marked": "^18.0.3",
"markit-ai": "0.5.3",
"openai": "^6.36.0",
"partial-json": "^0.1.7",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"proxy-agent": "^8.0.1",
"puppeteer-core": "^24.42.0",
"react": "19.2.5",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.5",
"regexp-tree": "^0.1.27",
"tailwindcss": "^4.2.4",
"turndown": "7.2.4",
"turndown-plugin-gfm": "1.0.2",
"typescript": "^6.0.3",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "4.4.3"
}
},
"overrides": {
"ajv": "8.20.0"
},
"scripts": {
"install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link",
"dev": "bun --cwd=packages/coding-agent src/cli.ts",
"stats": "bun --cwd=packages/coding-agent src/cli.ts stats",
"build": "bun run --workspaces --if-present build",
"build:native": "bun --cwd=packages/natives run build",
"test": "bun run --parallel test:ts test:rs",
"test:ts": "bun run --workspaces --if-present test -- --only-failures",
"test:rs": "bun scripts/run-rs-task.ts test:rs",
"check": "bun run --parallel check:ts check:rs",
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
"check:tools": "biome check . --no-errors-on-unmatched",
"check:rs": "bun scripts/run-rs-task.ts check:rs",
"lint": "bun run --parallel lint:ts lint:rs",
"lint:ts": "bun run --parallel lint:tools && bun run --workspaces --if-present lint",
"lint:tools": "biome lint . --no-errors-on-unmatched",
"lint:rs": "bun scripts/run-rs-task.ts lint:rs",
"fmt": "bun run --parallel fmt:ts fmt:rs",
"fmt:ts": "bun run fmt:tools && bun run --workspaces --if-present fmt",
"fmt:tools": "biome format --write . --no-errors-on-unmatched",
"fmt:rs": "bun scripts/run-rs-task.ts fmt:rs",
"fix": "bun run --parallel fix:ts fix:rs",
"fix:all": "bun run --parallel fix:ts:all fix:rs",
"fix:ts": "bun run fix:tools && bun run --workspaces --if-present fix",
"fix:ts:all": "bun run fix:tools:all && bun run --workspaces --if-present fix",
"fix:tools": "biome check --write --unsafe --changed --no-errors-on-unmatched .",
"fix:tools:all": "biome check --write --unsafe --no-errors-on-unmatched .",
"fix:rs": "bun scripts/run-rs-task.ts fix:rs",
"ci:check:full": "bun run check:ts",
"ci:build:native": "bun scripts/ci-build-native.ts",
"ci:test:full": "bun run test",
"ci:test:smoke": "bun packages/coding-agent/src/cli.ts --version && bun packages/coding-agent/src/cli.ts --help && bun packages/coding-agent/src/cli.ts stats --help && bun packages/coding-agent/src/cli.ts --smoke-test",
"ci:test:install-methods": "bash scripts/install-tests/run-ci.sh",
"ci:release:build-binaries": "bun scripts/ci-release-build-binaries.ts",
"ci:release:publish": "bun scripts/ci-release-publish.ts",
"bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8",
"bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start",
"stats:sync": "python3 scripts/session-stats/sync.py",
"stats:tools": "python3 scripts/session-stats/analyze.py tools",
"stats:edits": "python3 scripts/session-stats/analyze.py edits",
"stats:followups": "python3 scripts/session-stats/analyze.py followups",
"prepublishOnly": "bun run check",
"prepare": "bun --cwd=packages/coding-agent run generate-docs-index",
"publish": "bun run prepublishOnly && npm publish -ws --access public",
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
"release": "bun scripts/release.ts",
"generate-models": "bun --cwd=packages/ai run generate-models",
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
"generate-template": "bun --cwd=packages/coding-agent run generate-template",
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"prettier": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"lint-staged": "catalog:"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
}
}
-22
View File
@@ -14,11 +14,9 @@ import {
import { sanitizeText } from "@oh-my-pi/pi-natives";
import {
createHarmonyAuditEvent,
extractHarmonyRemoved,
type HarmonyDetection,
type HarmonyRecoveredToolCall,
isHarmonyLeakMitigationTarget,
recoverHarmonyToolCall,
signalListLabel,
} from "./harmony-leak";
import type {
@@ -502,26 +500,6 @@ async function streamAssistantResponse(
const responseIterator = response[Symbol.asyncIterator]();
const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => {
const recovered = recoverHarmonyToolCall(message, detection);
const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection);
harmonyAbortController?.abort();
responseIterator.return?.()?.catch(() => {});
if (recovered) {
if (addedPartial) {
context.messages[context.messages.length - 1] = recovered.message;
} else {
context.messages.push(recovered.message);
stream.push({ type: "message_start", message: { ...recovered.message } });
}
stream.push({ type: "message_end", message: recovered.message });
throw new HarmonyLeakInterruption(detection, removed, recovered);
}
if (addedPartial) {
context.messages.pop();
}
throw new HarmonyLeakInterruption(detection, removed);
};
// Set up a single abort race: register the abort listener once for the whole
// stream and reuse the same race promise for every iterator.next() instead of
// allocating Promise.withResolvers and add/removeEventListener per event.
-1
View File
@@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/;
const SCRIPT_CLASS =
"\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F";
const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u");
// Recovery registry. Each entry's parser must recognize the configured
// sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
+1 -1
View File
@@ -779,7 +779,7 @@ it("refreshes tools and system prompt between same-turn model calls", async () =
};
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
for await (const _event of stream) {
for await (const _ of stream) {
// consume
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function pushAlphaThenDoneEvent(
}
}
export function createUsage(): Usage {
function createUsage(): Usage {
return {
input: 0,
output: 0,
+3 -11
View File
@@ -1,4 +1,4 @@
import { abortableSleep } from "@oh-my-pi/pi-utils";
import { fetchWithRetry } from "@oh-my-pi/pi-utils";
import type { ModelManagerOptions } from "../model-manager";
import { Effort } from "../model-thinking";
import type { ThinkingConfig } from "../types";
@@ -19,16 +19,7 @@ type OllamaShowResponse = {
model_info?: Record<string, unknown>;
};
const MODEL_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < MODEL_RETRY_DELAYS_MS.length; attempt++) {
const response = await fetch(url, init);
if (response.ok || response.status < 500) return response;
await abortableSleep(MODEL_RETRY_DELAYS_MS[attempt]!);
}
return fetch(url, init);
}
const OLLAMA_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
function trimTrailingSlash(value: string): string {
return value.endsWith("/") ? value.slice(0, -1) : value;
@@ -109,6 +100,7 @@ export function ollamaCloudModelManagerOptions(
const response = await fetchWithRetry(`${baseUrl}/api/tags`, {
method: "GET",
headers: createCloudHeaders(apiKey),
defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${baseUrl}/api/tags`);
+12 -9
View File
@@ -1,5 +1,6 @@
import * as nodeCrypto from "node:crypto";
import * as fs from "node:fs";
import { scheduler } from "node:timers/promises";
import * as tls from "node:tls";
import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk";
import type {
@@ -8,7 +9,14 @@ import type {
MessageParam,
RawMessageStreamEvent,
} from "@anthropic-ai/sdk/resources/messages";
import { $env, abortableSleep, isEnoent, readSseEvents } from "@oh-my-pi/pi-utils";
import {
$env,
extractHttpStatusFromError,
isEnoent,
isRetryableError,
isUnexpectedSocketCloseMessage,
readSseEvents,
} from "@oh-my-pi/pi-utils";
import { hasOpus47ApiRestrictions, mapEffortToAnthropicAdaptiveEffort } from "../model-thinking";
import { calculateCost } from "../models";
import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
@@ -48,12 +56,7 @@ import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTi
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { notifyProviderResponse } from "../utils/provider-response";
import {
extractHttpStatusFromError,
isCopilotRetryableError,
isRetryableError,
isUnexpectedSocketCloseMessage,
} from "../utils/retry";
import { isCopilotTransientModelError } from "../utils/retry";
import { COMBINATOR_KEYS, NO_STRICT } from "../utils/schema";
import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
import {
@@ -844,7 +847,7 @@ function isProviderRetryableStreamEnvelopeError(error: unknown): boolean {
export function isProviderRetryableError(error: unknown, provider?: string): boolean {
if (!(error instanceof Error)) return false;
if (provider === "github-copilot" && isCopilotRetryableError(error)) return true;
if (provider === "github-copilot" && isCopilotTransientModelError(error)) return true;
const msg = error.message.toLowerCase();
if (
isUnexpectedSocketCloseMessage(msg) ||
@@ -1287,7 +1290,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
}
providerRetryAttempt++;
const delayMs = PROVIDER_BASE_DELAY_MS * 2 ** (providerRetryAttempt - 1);
await abortableSleep(delayMs, options?.signal);
await scheduler.wait(delayMs, { signal: options?.signal });
output.content.length = 0;
output.responseId = undefined;
output.errorMessage = strictFallbackErrorMessage;
@@ -6,17 +6,15 @@ import type {
ResponseInput,
} from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream";
import {
type Api,
type AssistantMessage,
type Context,
type Model,
type ServiceTier,
type StreamFunction,
type StreamOptions,
shouldSendServiceTier,
type Tool,
type ToolChoice,
import type {
AssistantMessage,
Context,
Model,
ServiceTier,
StreamFunction,
StreamOptions,
Tool,
ToolChoice,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { createAbortSourceTracker } from "../utils/abort";
@@ -33,8 +31,11 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
import {
appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
applyResponsesReasoningParams,
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
} from "./openai-responses-shared";
@@ -101,23 +102,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
let firstTokenTime: number | undefined;
const deploymentName = resolveDeploymentName(model, options);
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "azure-openai-responses" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const output: AssistantMessage = createInitialResponsesAssistantMessage(
"azure-openai-responses",
model.provider,
model.id,
);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -279,31 +268,7 @@ function buildParams(
prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
};
if (options?.maxTokens) {
params.max_output_tokens = options?.maxTokens;
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (options?.topP !== undefined) {
params.top_p = options.topP;
}
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.minP !== undefined) {
params.min_p = options.minP;
}
if (options?.presencePenalty !== undefined) {
params.presence_penalty = options.presencePenalty;
}
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
params.service_tier = options.serviceTier;
}
applyCommonResponsesSamplingParams(params, options, model.provider);
if (context.tools) {
params.tools = convertTools(context.tools);
@@ -312,36 +277,7 @@ function buildParams(
}
}
if (model.reasoning) {
// Always request encrypted reasoning content so reasoning items can be
// replayed in multi-turn conversations when store is false (items aren't
// persisted server-side, so we must include the full content).
// See: https://github.com/can1357/oh-my-pi/issues/41
params.include = ["reasoning.encrypted_content"];
if (options?.reasoning || options?.reasoningSummary !== undefined) {
const reasoningParams: NonNullable<typeof params.reasoning> = {
effort: options?.reasoning || "medium",
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams;
} else {
if (model.name.toLowerCase().startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [
{
type: "input_text",
text: "# Juice: 0 !important",
},
],
});
}
}
}
applyResponsesReasoningParams(params, model, options, messages);
return params;
}
+37 -204
View File
@@ -4,8 +4,9 @@
* Uses the Cloud Code Assist API endpoint to access Gemini and Claude models.
*/
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { scheduler } from "node:timers/promises";
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "@google/genai";
import { abortableSleep, readSseJson } from "@oh-my-pi/pi-utils";
import { fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models";
import type {
Api,
@@ -23,28 +24,27 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
import { refreshAntigravityToken } from "../utils/oauth/google-antigravity";
import { refreshGoogleCloudToken } from "../utils/oauth/google-gemini-cli";
import { extractHttpStatusFromError } from "../utils/retry";
import { sanitizeSchemaForCCA } from "../utils/schema";
import {
ANTIGRAVITY_SYSTEM_INSTRUCTION,
extractRetryDelay,
getAntigravityUserAgent,
getGeminiCliHeaders,
} from "./google-gemini-headers";
import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers";
import {
convertMessages,
convertTools,
type GoogleThinkingLevel,
isThinkingPart,
mapStopReasonString,
mapToolChoice,
nextToolCallId,
pushBlockEndEvent,
pushToolCallEvents,
retainThoughtSignature,
startTextOrThinkingBlock,
} from "./google-shared";
/**
* Thinking level for Gemini 3 models.
* Mirrors Google's ThinkingLevel enum values.
* Thinking level for Gemini 3 models. Re-exported from `google-shared` so existing
* `import { GoogleThinkingLevel } from "./google-gemini-cli"` callers keep working.
*/
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
export type { GoogleThinkingLevel };
export interface GoogleGeminiCliOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
@@ -72,15 +72,11 @@ const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_
export {
ANTIGRAVITY_SYSTEM_INSTRUCTION,
extractRetryDelay,
getAntigravityUserAgent,
getGeminiCliHeaders,
getGeminiCliUserAgent,
} from "./google-gemini-headers";
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
// Retry configuration
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
@@ -104,16 +100,6 @@ function shouldInjectAntigravitySystemInstruction(modelId: string): boolean {
return normalized.includes("claude") || normalized.includes("gemini-3-pro-high");
}
/**
* Check if an error is retryable (rate limit, server error, network error, etc.)
*/
function isRetryableError(status: number, errorText: string): boolean {
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
return true;
}
return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable|other.?side.?closed/i.test(errorText);
}
/**
* Extract a clean, user-friendly error message from Google API error response.
* Parses JSON error responses and returns just the message field.
@@ -366,109 +352,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
headers: requestHeaders,
};
// Fetch with retry logic for rate limits and transient errors
let response: Response | undefined;
let lastError: Error | undefined;
let requestUrl: string | undefined;
let rateLimitTimeSpent = 0;
for (let attempt = 0; ; attempt++) {
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
try {
const endpoint = endpoints[Math.min(attempt, endpoints.length - 1)];
requestUrl = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
response = await fetch(requestUrl, {
method: "POST",
headers: requestHeaders,
body: requestBodyJson,
signal: options?.signal,
});
if (response.ok) {
break; // Success, exit retry loop
}
const errorText = await response.text();
// Handle 429 rate limits with time budget
if (response.status === 429) {
if (/quota|exhausted/i.test(errorText)) {
throw withHttpStatus(
new Error(`Cloud Code Assist API error (429): ${extractErrorMessage(errorText)}`),
429,
);
}
const serverDelay = extractRetryDelay(errorText, response);
if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) {
rateLimitTimeSpent += serverDelay;
await abortableSleep(serverDelay, options?.signal);
continue;
}
// Fallback: use exponential backoff if no server delay, up to MAX_RETRIES
if (!serverDelay && attempt < MAX_RETRIES) {
await abortableSleep(BASE_DELAY_MS * 2 ** attempt, options?.signal);
continue;
}
} else if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Non-429 retryable errors use standard attempt cap
const serverDelay = extractRetryDelay(errorText, response);
const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt;
// Check if server delay exceeds max allowed (default: 60s) for non-429 errors
const maxDelayMs = options?.maxRetryDelayMs ?? 60000;
if (maxDelayMs > 0 && serverDelay && serverDelay > maxDelayMs) {
const delaySeconds = Math.ceil(serverDelay / 1000);
throw withHttpStatus(
new Error(
`Server requested ${delaySeconds}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${extractErrorMessage(errorText)}`,
),
response.status,
);
}
await abortableSleep(delayMs, options?.signal);
continue;
}
// Not retryable or budget exceeded
throw withHttpStatus(
new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`),
response.status,
);
} catch (error) {
// Check for abort - fetch throws AbortError, our code throws "Request was aborted"
if (error instanceof Error) {
if (error.name === "AbortError" || error.message === "Request was aborted") {
throw new Error("Request was aborted");
}
}
// HTTP responses are handled inside the try block.
// If we intentionally throw with status metadata, don't convert it into a network retry.
if (extractHttpStatusFromError(error) !== undefined) {
throw error;
}
// Extract detailed error message from fetch errors (Node includes cause)
lastError = error instanceof Error ? error : new Error(String(error));
if (lastError.message === "fetch failed" && lastError.cause instanceof Error) {
lastError = new Error(`Network error: ${lastError.cause.message}`);
}
// Network errors are retryable
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * 2 ** attempt;
await abortableSleep(delayMs, options?.signal);
continue;
}
throw lastError;
}
}
if (!response?.ok) {
throw lastError ?? new Error("Failed to get response after retries");
const response = await fetchWithRetry(
attempt => `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`,
{
method: "POST",
headers: requestHeaders,
body: requestBodyJson,
signal: options?.signal,
maxAttempts: MAX_RETRIES + 1,
defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS,
},
);
if (!response.ok) {
const errorText = await response.text();
throw withHttpStatus(
new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`),
response.status,
);
}
const requestUrl = response.url;
let started = false;
const ensureStarted = () => {
@@ -525,37 +428,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
ensureStarted();
stream.push({
type: "thinking_start",
contentIndex: blockIndex(),
partial: output,
});
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
ensureStarted();
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
}
currentBlock = startTextOrThinkingBlock(isThinking, output, stream, ensureStarted);
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
@@ -587,30 +462,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
if (part.functionCall) {
hasContent = true;
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
currentBlock = null;
}
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
const toolCall: ToolCall = {
type: "toolCall",
@@ -622,19 +481,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
output.content.push(toolCall);
ensureStarted();
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({
type: "toolcall_end",
contentIndex: blockIndex(),
toolCall,
partial: output,
});
pushToolCallEvents(toolCall, blockIndex(), output, stream);
}
}
}
@@ -671,21 +518,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
}
return hasContent;
@@ -702,7 +535,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
if (emptyAttempt > 0) {
const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1);
try {
await abortableSleep(backoffMs, options?.signal);
await scheduler.wait(backoffMs, { signal: options?.signal });
} catch {
// Normalize AbortError to expected message for consistent error handling
throw new Error("Request was aborted");
@@ -39,103 +39,3 @@ export let getAntigravityUserAgent = () => {
getAntigravityUserAgent = () => userAgent;
return userAgent;
};
/**
* Extract retry delay from Gemini error response (in milliseconds).
* Checks headers first (Retry-After, x-ratelimit-reset, x-ratelimit-reset-after),
* then parses body patterns like:
* - "Your quota will reset after 39s"
* - "Your quota will reset after 18h31m10s"
* - "Please retry in Xs" or "Please retry in Xms"
* - "retryDelay": "34.074824224s" (JSON field)
*/
export function extractRetryDelay(errorText: string, response?: Response | Headers): number | undefined {
const normalizeDelay = (ms: number): number | undefined => (ms > 0 ? Math.ceil(ms + 1000) : undefined);
const headers = response instanceof Headers ? response : response?.headers;
if (headers) {
const retryAfter = headers.get("retry-after");
if (retryAfter) {
const retryAfterSeconds = Number(retryAfter);
if (Number.isFinite(retryAfterSeconds)) {
const delay = normalizeDelay(retryAfterSeconds * 1000);
if (delay !== undefined) {
return delay;
}
}
const retryAfterDate = new Date(retryAfter);
const retryAfterMs = retryAfterDate.getTime();
if (!Number.isNaN(retryAfterMs)) {
const delay = normalizeDelay(retryAfterMs - Date.now());
if (delay !== undefined) {
return delay;
}
}
}
const rateLimitReset = headers.get("x-ratelimit-reset");
if (rateLimitReset) {
const resetSeconds = Number.parseInt(rateLimitReset, 10);
if (!Number.isNaN(resetSeconds)) {
const delay = normalizeDelay(resetSeconds * 1000 - Date.now());
if (delay !== undefined) {
return delay;
}
}
}
const rateLimitResetAfter = headers.get("x-ratelimit-reset-after");
if (rateLimitResetAfter) {
const resetAfterSeconds = Number(rateLimitResetAfter);
if (Number.isFinite(resetAfterSeconds)) {
const delay = normalizeDelay(resetAfterSeconds * 1000);
if (delay !== undefined) {
return delay;
}
}
}
}
// Pattern 1: "Your quota will reset after ..." (formats: "18h31m10s", "10m15s", "6s", "39s")
const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i);
if (durationMatch) {
const hours = durationMatch[1] ? parseInt(durationMatch[1], 10) : 0;
const minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0;
const seconds = parseFloat(durationMatch[3]);
if (!Number.isNaN(seconds)) {
const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
const delay = normalizeDelay(totalMs);
if (delay !== undefined) {
return delay;
}
}
}
// Pattern 2: "Please retry in X[ms|s]"
const retryInMatch = errorText.match(/Please retry in ([0-9.]+)(ms|s)/i);
if (retryInMatch?.[1]) {
const value = parseFloat(retryInMatch[1]);
if (!Number.isNaN(value) && value > 0) {
const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000;
const delay = normalizeDelay(ms);
if (delay !== undefined) {
return delay;
}
}
}
// Pattern 3: "retryDelay": "34.074824224s" (JSON field in error details)
const retryDelayMatch = errorText.match(/"retryDelay":\s*"([0-9.]+)(ms|s)"/i);
if (retryDelayMatch?.[1]) {
const value = parseFloat(retryDelayMatch[1]);
if (!Number.isNaN(value) && value > 0) {
const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000;
const delay = normalizeDelay(ms);
if (delay !== undefined) {
return delay;
}
}
}
return undefined;
}
+446 -4
View File
@@ -1,8 +1,35 @@
/**
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
*/
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types";
import {
type Content,
FinishReason,
FunctionCallingConfigMode,
type GenerateContentConfig,
type GenerateContentParameters,
type GenerateContentResponse,
type GoogleGenAI,
type Part,
type ThinkingConfig,
type ThinkingLevel,
} from "@google/genai";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
Context,
ImageContent,
Model,
StopReason,
StreamOptions,
TextContent,
ThinkingContent,
Tool,
ToolCall,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema";
import { transformMessages } from "./transform-messages";
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -11,6 +38,26 @@ export { sanitizeSchemaForGoogle };
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
/**
* Thinking level for Gemini 3 models. Mirrors Google's `ThinkingLevel` enum values.
* Defined here (not in any specific provider) so all Google providers can reference it
* without inducing a circular dependency.
*/
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
/**
* Sampling/thinking options shared by `streamGoogle` and `streamGoogleVertex`.
* `google-gemini-cli` uses a different transport and request shape — do not extend this for it.
*/
export interface GoogleSharedStreamOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number;
level?: GoogleThinkingLevel;
};
}
/**
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
*
@@ -342,8 +389,7 @@ export function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.NO_IMAGE:
return "error";
default: {
const _exhaustive: never = reason;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
throw new Error(`Unhandled stop reason: ${reason satisfies never}`);
}
}
}
@@ -361,3 +407,399 @@ export function mapStopReasonString(reason: string): StopReason {
return "error";
}
}
/**
* Module-local counter for generating unique tool call IDs across Google providers.
* Shared so that a single monotonically-increasing sequence is used regardless of which
* Google API surface produced the stream — purely for uniqueness, not ordering semantics.
*/
let toolCallCounter = 0;
export function nextToolCallId(name: string): string {
return `${name}_${Date.now()}_${++toolCallCounter}`;
}
/**
* Push the appropriate `text_end` / `thinking_end` event for the given block.
* Shared between the SDK-backed stream consumer and the gemini-cli SSE consumer so
* the end-of-block event shape stays in lockstep.
*/
export function pushBlockEndEvent(
block: TextContent | ThinkingContent,
contentIndex: number,
output: AssistantMessage,
stream: AssistantMessageEventStream,
): void {
if (block.type === "text") {
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
} else {
stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
}
}
/**
* Push the three lifecycle events (`toolcall_start` / `toolcall_delta` / `toolcall_end`) for a
* fully-assembled `ToolCall`. Caller is responsible for appending the toolCall to `output.content`
* before invoking — this helper does not mutate `output.content`.
*/
export function pushToolCallEvents(
toolCall: ToolCall,
contentIndex: number,
output: AssistantMessage,
stream: AssistantMessageEventStream,
): void {
stream.push({ type: "toolcall_start", contentIndex, partial: output });
stream.push({
type: "toolcall_delta",
contentIndex,
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
}
/**
* Append a new text- or thinking-block to `output.content` and push the matching
* `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer
* inject its `ensureStarted()` first-token side effect into the canonical event order.
*/
export function startTextOrThinkingBlock(
isThinking: boolean,
output: AssistantMessage,
stream: AssistantMessageEventStream,
onBeforeStartEvent?: () => void,
): TextContent | ThinkingContent {
const block: TextContent | ThinkingContent = isThinking
? { type: "thinking", thinking: "", thinkingSignature: undefined }
: { type: "text", text: "" };
output.content.push(block);
onBeforeStartEvent?.();
const contentIndex = output.content.length - 1;
if (isThinking) {
stream.push({ type: "thinking_start", contentIndex, partial: output });
} else {
stream.push({ type: "text_start", contentIndex, partial: output });
}
return block;
}
/**
* Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
* the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
* `streamGoogleVertex` — every observable event order and stop-reason rule is preserved.
*
* The caller still owns: `output` construction, timing fields (`duration`/`ttft`),
* `rawRequestDump`, the `client.models.generateContentStream(params)` call itself,
* pushing `start`/`done`/`error` events, and the surrounding try/catch that translates
* thrown errors into `output.stopReason`/`errorMessage`.
*
* This helper handles: the chunk loop, currentBlock flush transitions, usage metadata
* decoding (`calculateCost` included), tool-call id collision avoidance, finish-reason
* mapping, and the abort/stop-reason post-checks that re-throw to bubble into the
* caller's catch.
*/
export async function consumeGoogleStream<T extends GoogleApiType>(args: {
googleStream: AsyncIterable<GenerateContentResponse>;
output: AssistantMessage;
stream: AssistantMessageEventStream;
model: Model<T>;
options: { signal?: AbortSignal } | undefined;
/** Vertex preserves `textSignature` on streamed text deltas; google-generative-ai does not. */
retainTextSignature?: boolean;
onFirstToken?: () => void;
}): Promise<void> {
const { googleStream, output, stream, model, options, retainTextSignature, onFirstToken } = args;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
let currentBlock: TextContent | ThinkingContent | null = null;
let firstTokenSeen = false;
const flushCurrent = () => {
if (!currentBlock) return;
pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
};
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
if (!firstTokenSeen) {
firstTokenSeen = true;
onFirstToken?.();
}
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
flushCurrent();
currentBlock = startTextOrThinkingBlock(isThinking, output, stream);
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
if (retainTextSignature) {
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
}
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
flushCurrent();
currentBlock = null;
}
// Generate unique ID if not provided or if it's a duplicate
const providedId = part.functionCall.id;
const needsNewId = !providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId ? nextToolCallId(part.functionCall.name || "tool") : providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args ?? {}) as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
pushToolCallEvents(toolCall, blockIndex(), output, stream);
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some(b => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
// promptTokenCount includes cachedContentTokenCount when cached content is used.
// Subtract to get non-cached input, matching the OpenAI convention where
// input = uncached prompt tokens and cacheRead = cached tokens so that
// input + cacheRead = total prompt tokens (no double-counting).
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
output.usage = {
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
flushCurrent();
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
}
/**
* Generation/sampling fields that map directly onto Gemini's `GenerateContentConfig`.
* Excludes any provider-specific extensions (`topP`/`topK`/etc are all forwarded as-is).
*/
interface GoogleGenerationConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
/**
* Build the `GenerateContentParameters` payload for the public Gemini API and Vertex AI.
* Both surfaces accept the same `GenerateContentConfig` shape — every numeric/string knob,
* tool-config, thinking-config, and system-instruction conversion is identical.
*
* `google-gemini-cli` is NOT routed through here: its `CloudCodeAssistRequest` body has a
* distinct top-level shape (project/request/requestType) and a different thinking-config
* placement on `generationConfig`.
*/
export function buildGoogleGenerateContentParams<T extends "google-generative-ai" | "google-vertex">(
model: Model<T>,
context: Context,
options: GoogleSharedStreamOptions,
): GenerateContentParameters {
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const contents = convertMessages(model, context);
const generationConfig: GoogleGenerationConfig = {};
if (options.temperature !== undefined) generationConfig.temperature = options.temperature;
if (options.maxTokens !== undefined) generationConfig.maxOutputTokens = options.maxTokens;
if (options.topP !== undefined) generationConfig.topP = options.topP;
if (options.topK !== undefined) generationConfig.topK = options.topK;
if (options.minP !== undefined) generationConfig.minP = options.minP;
if (options.presencePenalty !== undefined) generationConfig.presencePenalty = options.presencePenalty;
if (options.repetitionPenalty !== undefined) generationConfig.repetitionPenalty = options.repetitionPenalty;
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// GoogleThinkingLevel mirrors the SDK's `ThinkingLevel` string enum values 1:1.
cfg.thinkingLevel = options.thinking.level as ThinkingLevel;
} else if (options.thinking.budgetTokens !== undefined) {
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = cfg;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
return {
model: model.id,
contents,
config,
};
}
/**
* Drive the `streamGoogle` / `streamGoogleVertex` event flow: build the assistant message,
* push start/done/error events, run `consumeGoogleStream`, and translate thrown errors into
* the canonical `error` event shape.
*
* Caller-supplied `prepare()` runs inside the try-block so any failure (missing project,
* bad auth, etc.) is funneled through the same error path as a streaming failure.
*/
export function streamGoogleGenAI<T extends "google-generative-ai" | "google-vertex">(args: {
model: Model<T>;
options: GoogleSharedStreamOptions | undefined;
api: T;
retainTextSignature?: boolean;
prepare: () => { client: GoogleGenAI; params: GenerateContentParameters; url: string | undefined };
}): AssistantMessageEventStream {
const { model, options, api, retainTextSignature, prepare } = args;
const stream = new AssistantMessageEventStream();
(async () => {
const startTime = Date.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: api as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const { client, params, url } = prepare();
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
await consumeGoogleStream({
googleStream,
output,
stream,
model,
options,
retainTextSignature,
onFirstToken: () => {
firstTokenTime = Date.now();
},
});
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason as "length" | "stop" | "toolUse", message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
}
+19 -371
View File
@@ -1,317 +1,38 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { GoogleGenAI } from "@google/genai";
import { $env } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
Context,
Model,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
ToolCall,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import type { GoogleThinkingLevel } from "./google-gemini-cli";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared";
import type { Context, Model, StreamFunction } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
export interface GoogleVertexOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
project?: string;
location?: string;
}
interface GoogleVertexSamplingConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
const API_VERSION = "v1";
const THINKING_LEVEL_MAP: Record<GoogleThinkingLevel, ThinkingLevel> = {
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
MINIMAL: ThinkingLevel.MINIMAL,
LOW: ThinkingLevel.LOW,
MEDIUM: ThinkingLevel.MEDIUM,
HIGH: ThinkingLevel.HIGH,
};
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
model: Model<"google-vertex">,
context: Context,
options?: GoogleVertexOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const startTime = Date.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-vertex" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined;
try {
): AssistantMessageEventStream =>
streamGoogleGenAI({
model,
options,
api: "google-vertex",
retainTextSignature: true,
prepare: () => {
const apiKey = resolveApiKey(options);
const project = apiKey ? undefined : resolveProject(options);
const location = apiKey ? undefined : resolveLocation(options);
const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
const params = buildParams(model, context, options);
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url: apiKey
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
if (!firstTokenTime) firstTokenTime = Date.now();
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: part.functionCall.args as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some(b => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
// promptTokenCount includes cachedContentTokenCount when cached content is used.
// Subtract to get non-cached input, matching the OpenAI convention where
// input = uncached prompt tokens and cacheRead = cached tokens so that
// input + cacheRead = total prompt tokens (no double-counting).
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
output.usage = {
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
const url = apiKey
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`;
return { client, params, url };
},
});
function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined {
if (!model.headers) {
@@ -364,76 +85,3 @@ function resolveLocation(options?: GoogleVertexOptions): string {
}
return location;
}
function buildParams(
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions = {},
): GenerateContentParameters {
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const contents = convertMessages(model, context);
const generationConfig: GoogleVertexSamplingConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
if (options.topP !== undefined) {
generationConfig.topP = options.topP;
}
if (options.topK !== undefined) {
generationConfig.topK = options.topK;
}
if (options.minP !== undefined) {
generationConfig.minP = options.minP;
}
if (options.presencePenalty !== undefined) {
generationConfig.presencePenalty = options.presencePenalty;
}
if (options.repetitionPenalty !== undefined) {
generationConfig.repetitionPenalty = options.repetitionPenalty;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
cfg.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
} else if (options.thinking.budgetTokens !== undefined) {
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = cfg;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
+16 -359
View File
@@ -1,297 +1,28 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { calculateCost } from "../models";
import { GoogleGenAI } from "@google/genai";
import { getEnvApiKey } from "../stream";
import type {
Api,
AssistantMessage,
Context,
Model,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
ToolCall,
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import type { GoogleThinkingLevel } from "./google-gemini-cli";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared";
import type { Context, Model, StreamFunction } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
export interface GoogleOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
}
interface GoogleSamplingConfig extends GenerateContentConfig {
topP?: number;
topK?: number;
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
}
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export type GoogleOptions = GoogleSharedStreamOptions;
export const streamGoogle: StreamFunction<"google-generative-ai"> = (
model: Model<"google-generative-ai">,
context: Context,
options?: GoogleOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const startTime = Date.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-generative-ai" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
let rawRequestDump: RawHttpRequestDump | undefined;
try {
): AssistantMessageEventStream =>
streamGoogleGenAI({
model,
options,
api: "google-generative-ai",
prepare: () => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const client = createClient(model, apiKey);
const params = buildParams(model, context, options);
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url: model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = isThinkingPart(part);
if (!firstTokenTime) firstTokenTime = Date.now();
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
// Generate unique ID if not provided or if it's a duplicate
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some(b => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args ?? {}) as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some(b => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
// promptTokenCount includes cachedContentTokenCount when cached content is used.
// Subtract to get non-cached input, matching the OpenAI convention where
// input = uncached prompt tokens and cacheRead = cached tokens so that
// input + cacheRead = total prompt tokens (no double-counting).
// Ref: https://ai.google.dev/api/generate-content#v1beta.GenerateContentResponse.UsageMetadata
const cachedTokens = chunk.usageMetadata.cachedContentTokenCount || 0;
const thinkingTokens = chunk.usageMetadata.thoughtsTokenCount || 0;
output.usage = {
input: (chunk.usageMetadata.promptTokenCount || 0) - cachedTokens,
output: (chunk.usageMetadata.candidatesTokenCount || 0) + thinkingTokens,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
...(thinkingTokens > 0 ? { reasoningTokens: thinkingTokens } : {}),
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
output.duration = Date.now() - startTime;
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined;
return { client, params, url };
},
});
function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
@@ -308,77 +39,3 @@ function createClient(model: Model<"google-generative-ai">, apiKey?: string): Go
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});
}
function buildParams(
model: Model<"google-generative-ai">,
context: Context,
options: GoogleOptions = {},
): GenerateContentParameters {
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const contents = convertMessages(model, context);
const generationConfig: GoogleSamplingConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
if (options.topP !== undefined) {
generationConfig.topP = options.topP;
}
if (options.topK !== undefined) {
generationConfig.topK = options.topK;
}
if (options.minP !== undefined) {
generationConfig.minP = options.minP;
}
if (options.presencePenalty !== undefined) {
generationConfig.presencePenalty = options.presencePenalty;
}
if (options.repetitionPenalty !== undefined) {
generationConfig.repetitionPenalty = options.repetitionPenalty;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(systemPrompts.length > 0 && { systemInstruction: { parts: systemPrompts.map(text => ({ text })) } }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
cfg.thinkingLevel = options.thinking.level as any;
} else if (options.thinking.budgetTokens !== undefined) {
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = cfg;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
+15 -96
View File
@@ -9,19 +9,21 @@
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
*/
import { ANTHROPIC_THINKING } from "../stream";
import type { Api, Context, Model, SimpleStreamOptions } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import type { Api, Context, Model } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
import { createProviderErrorMessage } from "./shared/error-message";
import {
type OpenAIAnthropicApiFormat,
type OpenAIAnthropicShimOptions,
streamOpenAIAnthropicShim,
} from "./openai-anthropic-shim";
export type KimiApiFormat = "openai" | "anthropic";
export type KimiApiFormat = OpenAIAnthropicApiFormat;
// Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
export interface KimiOptions extends SimpleStreamOptions {
export interface KimiOptions extends OpenAIAnthropicShimOptions {
/** API format: "openai" or "anthropic". Default: "anthropic" */
format?: KimiApiFormat;
}
@@ -35,96 +37,13 @@ export function streamKimi(
context: Context,
options?: KimiOptions,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const format = options?.format ?? "anthropic";
// Async IIFE to handle header fetching and stream piping
(async () => {
try {
const mergedHeaders = { ...getKimiCommonHeaders(), ...options?.headers };
if (format === "anthropic") {
// Create a synthetic Anthropic model pointing to Kimi's endpoint
const anthropicModel: Model<"anthropic-messages"> = {
id: model.id,
name: model.name,
api: "anthropic-messages",
provider: model.provider,
baseUrl: KIMI_ANTHROPIC_BASE_URL,
headers: mergedHeaders,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
};
// Calculate thinking budget from reasoning level
const reasoning = options?.reasoning;
const reasoningEffort = reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
: undefined;
const innerStream = streamAnthropic(anthropicModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
});
for await (const event of innerStream) {
stream.push(event);
}
} else {
// OpenAI format - use original model with Kimi headers
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(model, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? model.maxTokens,
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
reasoning: reasoningEffort,
});
for await (const event of innerStream) {
stream.push(event);
}
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: createProviderErrorMessage(model, err),
});
}
})();
return stream;
return streamOpenAIAnthropicShim(model, context, options, {
anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
defaultFormat: "anthropic",
extraHeaders: getKimiCommonHeaders,
});
}
/**
* Check if a model is a Kimi Code model.
*/
+3 -12
View File
@@ -1,4 +1,4 @@
import { abortableSleep } from "@oh-my-pi/pi-utils";
import { fetchWithRetry } from "@oh-my-pi/pi-utils";
import type { TSchema } from "@sinclair/typebox";
import { getEnvApiKey } from "../stream";
import type {
@@ -334,16 +334,6 @@ function mapDoneReason(doneReason: string | undefined, output: AssistantMessage)
const OLLAMA_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
async function fetchChatWithRetry(url: string, init: RequestInit): Promise<Response> {
const signal = init.signal as AbortSignal | undefined;
for (let attempt = 0; attempt < OLLAMA_RETRY_DELAYS_MS.length; attempt++) {
const response = await fetch(url, init);
if (response.ok || response.status < 500) return response;
await abortableSleep(OLLAMA_RETRY_DELAYS_MS[attempt]!, signal);
}
return fetch(url, init);
}
export const streamOllama: StreamFunction<"ollama-chat"> = (
model: Model<"ollama-chat">,
context: Context,
@@ -377,7 +367,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
url: `${baseUrl}/api/chat`,
body,
};
const response = await fetchChatWithRetry(`${baseUrl}/api/chat`, {
const response = await fetchWithRetry(`${baseUrl}/api/chat`, {
method: "POST",
headers: {
...model.headers,
@@ -387,6 +377,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
},
body: JSON.stringify(body),
signal: options.signal,
defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${baseUrl}/api/chat`);
@@ -0,0 +1,136 @@
/**
* Shared implementation for providers that expose BOTH an OpenAI-compatible
* and an Anthropic-compatible API surface against the same model catalog
* (currently Kimi Code and Synthetic).
*
* Each call site supplies the provider-specific bits (base URLs, default
* format, optional extra headers); the streaming/forwarding plumbing lives
* here once.
*/
import { ANTHROPIC_THINKING } from "../stream";
import type { Context, Model, SimpleStreamOptions } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
import { createProviderErrorMessage } from "./shared/error-message";
export type OpenAIAnthropicApiFormat = "openai" | "anthropic";
export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions {
/** API format: "openai" or "anthropic". */
format?: OpenAIAnthropicApiFormat;
}
export interface OpenAIAnthropicShimConfig {
/** Base URL for the Anthropic-compatible endpoint (without trailing /v1/messages). */
anthropicBaseUrl: string;
/** Optional override for the OpenAI-compatible base URL. If omitted, `model.baseUrl` is used as-is. */
openaiBaseUrl?: string;
/** Default API format when caller does not specify one. */
defaultFormat: OpenAIAnthropicApiFormat;
/** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
extraHeaders?: () => Record<string, string>;
}
/**
* Stream from an OpenAI-or-Anthropic compatible provider. Returns synchronously;
* async header fetching and stream piping happen internally.
*/
export function streamOpenAIAnthropicShim(
model: Model<"openai-completions">,
context: Context,
options: OpenAIAnthropicShimOptions | undefined,
config: OpenAIAnthropicShimConfig,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const format = options?.format ?? config.defaultFormat;
(async () => {
try {
const mergedHeaders = {
...(config.extraHeaders?.() ?? {}),
...options?.headers,
};
if (format === "anthropic") {
const anthropicModel: Model<"anthropic-messages"> = {
id: model.id,
name: model.name,
api: "anthropic-messages",
provider: model.provider,
baseUrl: config.anthropicBaseUrl,
headers: mergedHeaders,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
};
const reasoningEffort = options?.reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
: undefined;
const innerStream = streamAnthropic(anthropicModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
});
for await (const event of innerStream) {
stream.push(event);
}
} else {
const openaiModel: Model<"openai-completions"> = config.openaiBaseUrl
? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders }
: model;
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(openaiModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? model.maxTokens,
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
reasoning: reasoningEffort,
});
for await (const event of innerStream) {
stream.push(event);
}
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: createProviderErrorMessage(model, err),
});
}
})();
return stream;
}
@@ -1,19 +1,17 @@
import * as os from "node:os";
import { $env, $flag, abortableSleep, asRecord, logger, readSseJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import { scheduler } from "node:timers/promises";
import { $env, $flag, asRecord, fetchWithRetry, logger, readSseJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import type OpenAI from "openai";
import type {
ResponseCustomToolCall,
ResponseFunctionToolCall,
ResponseInput,
ResponseInputContent,
ResponseInputImage,
ResponseInputText,
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import packageJson from "../../package.json" with { type: "json" };
import { calculateCost } from "../models";
import { isUsageLimitError } from "../rate-limit-utils";
import { getEnvApiKey } from "../stream";
import {
type Api,
@@ -35,7 +33,6 @@ import {
createOpenAIResponsesHistoryPayload,
getOpenAIResponsesHistoryItems,
getOpenAIResponsesHistoryPayload,
normalizeResponsesToolCallId,
normalizeSystemPrompts,
} from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
@@ -54,14 +51,15 @@ import {
import { parseCodexError } from "./openai-codex/response-handler";
import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses";
import {
appendResponsesToolResultMessages,
convertResponsesAssistantMessage,
convertResponsesInputContent,
encodeResponsesToolCallId,
encodeTextSignatureV1,
mapOpenAIResponsesStopReason,
parseTextSignature,
populateResponsesUsageFromResponse,
} from "./openai-responses-shared";
import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder } from "./vision-guard";
export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -76,7 +74,6 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
const CODEX_DEBUG = $flag("PI_CODEX_DEBUG");
const CODEX_MAX_RETRIES = 5;
const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const CODEX_RETRY_DELAY_MS = 500;
const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300000;
@@ -661,7 +658,9 @@ async function openInitialCodexEventStream(
});
if (!activateFallback) {
websocketRetries += 1;
await abortableSleep(getCodexWebSocketRetryDelayMs(websocketRetries), requestSetup.requestSignal);
await scheduler.wait(getCodexWebSocketRetryDelayMs(websocketRetries), {
signal: requestSetup.requestSignal,
});
continue;
}
break;
@@ -1232,19 +1231,7 @@ function handleResponseCompleted(
}
).response;
if (response?.usage) {
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0;
output.usage = {
input: (response.usage.input_tokens || 0) - cachedTokens,
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: response.usage.total_tokens || 0,
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
populateResponsesUsageFromResponse(output, response?.usage);
if (typeof response?.id === "string" && response.id.length > 0) {
output.responseId = response.id;
}
@@ -1407,10 +1394,9 @@ async function tryReplayWebsocketFailureOverSse(
if (!activateFallback) {
runtime.websocketStreamRetries += 1;
await abortableSleep(
getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries),
context.requestSetup.requestSignal,
);
await scheduler.wait(getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), {
signal: context.requestSetup.requestSignal,
});
await reopenCodexWebSocketRuntimeStream(context, runtime, state);
return true;
}
@@ -1461,7 +1447,9 @@ async function tryRetryCodexProviderError(
runtime.sawTerminalEvent = false;
resetOutputState(context.output);
context.firstTokenTime = undefined;
await abortableSleep(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, context.requestSetup.requestSignal);
await scheduler.wait(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, {
signal: context.requestSetup.requestSignal,
});
if (runtime.transport === "websocket" && websocketState) {
await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
@@ -2194,15 +2182,15 @@ async function openCodexSseEventStream(
sentTurnStateHeader: headers.has(X_CODEX_TURN_STATE_HEADER),
sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER),
});
const response = await fetchWithRetry(
url,
{
method: "POST",
headers,
body: JSON.stringify(body),
},
const response = await fetchWithRetry(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
);
maxAttempts: CODEX_MAX_RETRIES + 1,
defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1),
maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS,
});
logCodexDebug("codex response", {
url: response.url,
status: response.status,
@@ -2292,75 +2280,6 @@ function logCodexDebug(message: string, details?: Record<string, unknown>): void
logger.debug(`[codex] ${message}`, details ?? {});
}
function getRetryDelayMs(
response: Response | null,
attempt: number,
errorBody?: string,
): { delay: number; serverProvided: boolean } {
const retryAfter = response?.headers?.get("retry-after") || null;
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return { delay: Math.max(0, seconds * 1000), serverProvided: true };
}
const parsedDate = Date.parse(retryAfter);
if (!Number.isNaN(parsedDate)) {
return { delay: Math.max(0, parsedDate - Date.now()), serverProvided: true };
}
}
if (errorBody) {
const msMatch = /try again in\s+(\d+(?:\.\d+)?)\s*ms/i.exec(errorBody);
if (msMatch) {
const ms = Number(msMatch[1]);
if (Number.isFinite(ms)) return { delay: Math.max(ms, 100), serverProvided: true };
}
const sMatch = /try again in\s+(\d+(?:\.\d+)?)\s*s(?:ec)?/i.exec(errorBody);
if (sMatch) {
const seconds = Number(sMatch[1]);
if (Number.isFinite(seconds)) return { delay: Math.max(seconds * 1000, 100), serverProvided: true };
}
}
return { delay: CODEX_RETRY_DELAY_MS * (attempt + 1), serverProvided: false };
}
async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
let attempt = 0;
let rateLimitTimeSpent = 0;
while (true) {
try {
const response = await fetch(url, { ...init, signal: signal ?? init.signal });
if (!CODEX_RETRYABLE_STATUS.has(response.status)) {
return response;
}
if (signal?.aborted) return response;
const errorBody = await response.clone().text();
// Usage-limit errors are persistent (account allocation exhausted) — retrying with the
// same credential is futile. Bail out immediately so the error propagates to the agent
// session layer where credential switching happens.
if (response.status === 429 && isUsageLimitError(errorBody)) {
return response;
}
const { delay, serverProvided } = getRetryDelayMs(response, attempt, errorBody);
if (response.status === 429 && serverProvided) {
if (rateLimitTimeSpent + delay > CODEX_RATE_LIMIT_BUDGET_MS) {
return response;
}
rateLimitTimeSpent += delay;
} else if (attempt >= CODEX_MAX_RETRIES) {
return response;
}
await abortableSleep(delay, signal);
} catch (error) {
if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
throw error;
}
const delay = CODEX_RETRY_DELAY_MS * (attempt + 1);
await abortableSleep(delay, signal);
}
attempt += 1;
}
}
function redactHeaders(headers: Headers): Record<string, string> {
const redacted: Record<string, string> = {};
for (const [key, value] of headers.entries()) {
@@ -2423,6 +2342,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
// messages can be replayed as `custom_tool_call_output` rather than
// `function_call_output` (OpenAI rejects mismatched pairs).
const customCallIds = new Set<string>();
const knownCallIds = new Set<string>();
for (const msg of transformedMessages) {
if (msg.role === "user" || msg.role === "developer") {
@@ -2474,57 +2394,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
continue;
}
const outputItems: ResponseInput = [];
for (const block of msg.content) {
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
}
continue;
}
if (block.type === "text") {
const textBlock = block as TextContent;
const parsedSignature = parseTextSignature(textBlock.textSignature);
let msgId = parsedSignature?.id;
if (!msgId) {
msgId = `msg_${msgIndex}`;
} else if (msgId.length > 64) {
msgId = `msg_${Bun.hash(msgId).toString(36)}`;
}
outputItems.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: textBlock.text.toWellFormed(), annotations: [] }],
status: "completed",
id: msgId,
phase: parsedSignature?.phase,
} satisfies ResponseOutputMessage);
continue;
}
if (block.type === "toolCall") {
const toolCall = block as ToolCall;
const normalized = normalizeResponsesToolCallId(toolCall.id, toolCall.customWireName ? "ctc" : "fc");
if (toolCall.customWireName) {
const rawInput = typeof toolCall.arguments?.input === "string" ? toolCall.arguments.input : "";
customCallIds.add(normalized.callId);
outputItems.push({
type: "custom_tool_call",
id: normalized.itemId,
call_id: normalized.callId,
name: toolCall.customWireName,
input: rawInput,
} as ResponseInput[number]);
continue;
}
outputItems.push({
type: "function_call",
id: normalized.itemId,
call_id: normalized.callId,
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments),
});
}
}
const outputItems = convertResponsesAssistantMessage(
msg as AssistantMessage,
model,
msgIndex,
knownCallIds,
true,
customCallIds,
);
if (outputItems.length > 0) {
messages.push(...outputItems);
}
@@ -2533,49 +2410,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
}
if (msg.role === "toolResult") {
const supportsImages = model.input.includes("image");
const textResult = msg.content
.filter(content => content.type === "text")
.map(content => content.text)
.join("\n");
const hasImages = msg.content.some(content => content.type === "image");
const omittedImages = hasImages && !supportsImages;
const normalized = normalizeResponsesToolCallId(msg.toolCallId);
const output = (
omittedImages
? joinTextWithImagePlaceholder(textResult, true)
: textResult.length > 0
? textResult
: "(see attached image)"
).toWellFormed();
if (customCallIds.has(normalized.callId)) {
messages.push({
type: "custom_tool_call_output",
call_id: normalized.callId,
output,
} as ResponseInput[number]);
} else {
messages.push({
type: "function_call_output",
call_id: normalized.callId,
output,
});
}
if (hasImages && supportsImages) {
const contentParts: ResponseInputContent[] = [
{ type: "input_text", text: "Attached image(s) from tool result:" } satisfies ResponseInputText,
];
for (const block of msg.content) {
if (block.type === "image") {
contentParts.push({
type: "input_image",
detail: "auto",
image_url: `data:${block.mimeType};base64,${block.data}`,
} satisfies ResponseInputImage);
}
}
messages.push({ role: "user", content: contentParts });
}
appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
}
msgIndex += 1;
@@ -1,4 +1,4 @@
import { $env } from "@oh-my-pi/pi-utils";
import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import OpenAI from "openai";
import type {
ChatCompletionAssistantMessageParam,
@@ -54,7 +54,7 @@ import { parseStreamingJson } from "../utils/json-parse";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
import { notifyProviderResponse } from "../utils/provider-response";
import { callWithCopilotModelRetry, extractHttpStatusFromError } from "../utils/retry";
import { callWithCopilotModelRetry } from "../utils/retry";
import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
import { wrapFetchForSseDebug } from "../utils/sse-debug";
import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
@@ -64,6 +64,7 @@ import {
resolveGitHubCopilotBaseUrl,
} from "./github-copilot-headers";
import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat";
import { createInitialResponsesAssistantMessage } from "./openai-responses-shared";
import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
@@ -338,23 +339,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
let firstTokenTime: number | undefined;
let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -12,17 +12,20 @@ import type {
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
ImageContent,
Model,
StopReason,
TextContent,
TextSignatureV1,
ThinkingContent,
ToolCall,
ToolResultMessage,
import {
type Api,
type AssistantMessage,
type ImageContent,
type Model,
type ServiceTier,
type StopReason,
type StreamOptions,
shouldSendServiceTier,
type TextContent,
type TextSignatureV1,
type ThinkingContent,
type ToolCall,
type ToolResultMessage,
} from "../types";
import { normalizeResponsesToolCallId } from "../utils";
import type { AssistantMessageEventStream } from "../utils/event-stream";
@@ -540,19 +543,7 @@ export async function processResponsesStream<TApi extends Api>(
if (response?.id) {
output.responseId = response.id;
}
if (response?.usage) {
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
const reasoningTokens = response.usage.output_tokens_details?.reasoning_tokens || 0;
output.usage = {
input: (response.usage.input_tokens || 0) - cachedTokens,
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: response.usage.total_tokens || 0,
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
populateResponsesUsageFromResponse(output, response?.usage);
calculateCost(model, output.usage);
output.stopReason = mapOpenAIResponsesStopReason(response?.status);
if (response?.status === "failed" || response?.status === "cancelled") {
@@ -605,3 +596,131 @@ export function mapOpenAIResponsesStopReason(status: OpenAI.Responses.ResponseSt
}
}
}
/** Initial empty `AssistantMessage` that streaming providers accumulate into. */
export function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage {
return {
role: "assistant",
content: [],
api,
provider,
model: modelId,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
/** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */
export type ResponsesSamplingParamsExtras = {
top_p?: number;
top_k?: number;
min_p?: number;
presence_penalty?: number;
repetition_penalty?: number;
};
type CommonResponsesParams = OpenAI.Responses.ResponseCreateParamsStreaming & ResponsesSamplingParamsExtras;
type CommonSamplingOptions = Pick<
StreamOptions,
"temperature" | "topP" | "topK" | "minP" | "presencePenalty" | "repetitionPenalty" | "maxTokens"
> & { serviceTier?: ServiceTier };
/**
* Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
* temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
*/
export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
params: P,
options: CommonSamplingOptions | undefined,
provider: string,
): void {
if (options?.maxTokens) params.max_output_tokens = options.maxTokens;
if (options?.temperature !== undefined) params.temperature = options.temperature;
if (options?.topP !== undefined) params.top_p = options.topP;
if (options?.topK !== undefined) params.top_k = options.topK;
if (options?.minP !== undefined) params.min_p = options.minP;
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
if (shouldSendServiceTier(options?.serviceTier, provider)) {
params.service_tier = options.serviceTier;
}
}
type ReasoningOptions = {
reasoning?: string;
reasoningSummary?: "auto" | "detailed" | "concise" | null;
};
/**
* Apply reasoning-related Responses parameters: enable encrypted reasoning content for replay,
* set effort/summary when requested, and otherwise inject the GPT-5 "Juice: 0" no-reasoning hack.
* Mutates `params` and may push a developer message into `messages`.
*/
export function applyResponsesReasoningParams<P extends OpenAI.Responses.ResponseCreateParamsStreaming>(
params: P,
model: Model<Api>,
options: ReasoningOptions | undefined,
messages: ResponseInput,
mapEffort?: (effort: string) => string,
): void {
if (!model.reasoning) return;
// Always request encrypted reasoning content so reasoning items can be replayed in
// multi-turn conversations when store is false (items aren't persisted server-side, so
// we must include the full content). See: https://github.com/can1357/oh-my-pi/issues/41
params.include = ["reasoning.encrypted_content"];
if (options?.reasoning || options?.reasoningSummary !== undefined) {
const requested = options?.reasoning || "medium";
type ReasoningParam = NonNullable<OpenAI.Responses.ResponseCreateParamsStreaming["reasoning"]>;
const reasoningParams: ReasoningParam = {
effort: (mapEffort ? mapEffort(requested) : requested) as ReasoningParam["effort"],
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams as P["reasoning"];
} else if (model.name.toLowerCase().startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [{ type: "input_text", text: "# Juice: 0 !important" }],
});
}
}
/** Populate `output.usage` from a Responses-API `response.usage` payload. Does not invoke `calculateCost`. */
export function populateResponsesUsageFromResponse(
output: AssistantMessage,
usage:
| {
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
input_tokens_details?: { cached_tokens?: number | null } | null;
output_tokens_details?: { reasoning_tokens?: number | null } | null;
}
| null
| undefined,
): void {
if (!usage) return;
const cachedTokens = usage.input_tokens_details?.cached_tokens || 0;
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0;
output.usage = {
input: (usage.input_tokens || 0) - cachedTokens,
output: usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: usage.total_tokens || 0,
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
+12 -74
View File
@@ -7,7 +7,6 @@ import type {
} from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream";
import {
type Api,
type AssistantMessage,
type CacheRetention,
type Context,
@@ -19,7 +18,6 @@ import {
type ServiceTier,
type StreamFunction,
type StreamOptions,
shouldSendServiceTier,
type Tool,
type ToolChoice,
} from "../types";
@@ -54,10 +52,13 @@ import {
import { compactGrammarDefinition } from "./grammar";
import {
appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
applyResponsesReasoningParams,
collectCustomCallIds,
collectKnownCallIds,
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
} from "./openai-responses-shared";
@@ -187,23 +188,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const startTime = Date.now();
let firstTokenTime: number | undefined;
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "openai-responses" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const output: AssistantMessage = createInitialResponsesAssistantMessage(
"openai-responses",
model.provider,
model.id,
);
let rawRequestDump: RawHttpRequestDump | undefined;
const abortTracker = createAbortSourceTracker(options?.signal);
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
@@ -418,31 +407,7 @@ function buildParams(
store: false,
};
if (options?.maxTokens) {
params.max_output_tokens = options?.maxTokens;
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (options?.topP !== undefined) {
params.top_p = options.topP;
}
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.minP !== undefined) {
params.min_p = options.minP;
}
if (options?.presencePenalty !== undefined) {
params.presence_penalty = options.presencePenalty;
}
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
params.service_tier = options.serviceTier;
}
applyCommonResponsesSamplingParams(params, options, model.provider);
if (context.tools) {
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
@@ -460,36 +425,9 @@ function buildParams(
}
}
if (model.reasoning) {
// Always request encrypted reasoning content so reasoning items can be
// replayed in multi-turn conversations when store is false (items aren't
// persisted server-side, so we must include the full content).
// See: https://github.com/can1357/oh-my-pi/issues/41
params.include = ["reasoning.encrypted_content"];
if (options?.reasoning || options?.reasoningSummary !== undefined) {
const reasoningParams: NonNullable<typeof params.reasoning> = {
effort: mapReasoningEffort(options?.reasoning || "medium", model.compat?.reasoningEffortMap) as NonNullable<
OpenAIResponsesSamplingParams["reasoning"]
>["effort"],
};
if (options?.reasoningSummary !== null) {
reasoningParams.summary = options?.reasoningSummary || "auto";
}
params.reasoning = reasoningParams;
} else if (model.name.startsWith("gpt-5")) {
// Jesus Christ, see https://community.openai.com/t/need-reasoning-false-option-for-gpt-5/1351588/7
messages.push({
role: "developer",
content: [
{
type: "input_text",
text: "# Juice: 0 !important",
},
],
});
}
}
applyResponsesReasoningParams(params, model, options, messages, effort =>
mapReasoningEffort(effort as NonNullable<OpenAIResponsesOptions["reasoning"]>, model.compat?.reasoningEffortMap),
);
return { conversationMessages, params };
}
+15 -102
View File
@@ -8,18 +8,20 @@
* @see https://dev.synthetic.new/docs/api/overview
*/
import { ANTHROPIC_THINKING } from "../stream";
import type { Api, Context, Model, SimpleStreamOptions } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
import { createProviderErrorMessage } from "./shared/error-message";
import type { Api, Context, Model } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import {
type OpenAIAnthropicApiFormat,
type OpenAIAnthropicShimOptions,
streamOpenAIAnthropicShim,
} from "./openai-anthropic-shim";
export type SyntheticApiFormat = "openai" | "anthropic";
export type SyntheticApiFormat = OpenAIAnthropicApiFormat;
const SYNTHETIC_NEW_BASE_URL = "https://api.synthetic.new/openai/v1";
const SYNTHETIC_NEW_ANTHROPIC_BASE_URL = "https://api.synthetic.new/anthropic";
export interface SyntheticOptions extends SimpleStreamOptions {
export interface SyntheticOptions extends OpenAIAnthropicShimOptions {
/** API format: "openai" or "anthropic". Default: "openai" */
format?: SyntheticApiFormat;
}
@@ -33,102 +35,13 @@ export function streamSynthetic(
context: Context,
options?: SyntheticOptions,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const format = options?.format ?? "openai";
// Async IIFE to handle stream piping
(async () => {
try {
const mergedHeaders = options?.headers ?? {};
if (format === "anthropic") {
// Create a synthetic Anthropic model pointing to Synthetic's endpoint
const anthropicModel: Model<"anthropic-messages"> = {
id: model.id,
name: model.name,
api: "anthropic-messages",
provider: model.provider,
baseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
headers: mergedHeaders,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
};
// Calculate thinking budget from reasoning level
const reasoning = options?.reasoning;
const reasoningEffort = reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
: undefined;
const innerStream = streamAnthropic(anthropicModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? Math.min(model.maxTokens, 32000),
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
});
for await (const event of innerStream) {
stream.push(event);
}
} else {
// OpenAI format - use original model with Synthetic headers
const syntheticModel: Model<"openai-completions"> = {
...model,
baseUrl: SYNTHETIC_NEW_BASE_URL,
headers: mergedHeaders,
};
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(syntheticModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
minP: options?.minP,
presencePenalty: options?.presencePenalty,
repetitionPenalty: options?.repetitionPenalty,
maxTokens: options?.maxTokens ?? model.maxTokens,
signal: options?.signal,
headers: mergedHeaders,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onSseEvent: options?.onSseEvent,
reasoning: reasoningEffort,
});
for await (const event of innerStream) {
stream.push(event);
}
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: createProviderErrorMessage(model, err),
});
}
})();
return stream;
return streamOpenAIAnthropicShim(model, context, options, {
anthropicBaseUrl: SYNTHETIC_NEW_ANTHROPIC_BASE_URL,
openaiBaseUrl: SYNTHETIC_NEW_BASE_URL,
defaultFormat: "openai",
});
}
/**
* Check if a model is a Synthetic model.
*/
+1 -1
View File
@@ -64,7 +64,7 @@ type _CheckExhaustive =
? true
: ["ApiOptionsMap is missing some KnownApi values", Exclude<KnownApi, keyof ApiOptionsMap>]
: ["ApiOptionsMap doesn't extend Record<KnownApi, StreamOptions>"];
const _exhaustive: _CheckExhaustive = true;
true satisfies _CheckExhaustive;
export type OptionsForApi<TApi extends Api> =
| StreamOptions
| (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never);
+2 -2
View File
@@ -1,6 +1,6 @@
import * as path from "node:path";
import { getLogsDir } from "@oh-my-pi/pi-utils";
import { extractHttpStatusFromError, isCopilotTransientModelError } from "./retry.js";
import { extractHttpStatusFromError, getLogsDir } from "@oh-my-pi/pi-utils";
import { isCopilotTransientModelError } from "./retry.js";
import { formatErrorMessageWithRetryAfter } from "./retry-after.js";
export type RawHttpRequestDump = {
+6 -10
View File
@@ -1,7 +1,7 @@
/**
* GitHub Copilot OAuth flow (opencode OAuth app)
*/
import { abortableSleep } from "@oh-my-pi/pi-utils";
import { scheduler } from "node:timers/promises";
import { getBundledModels } from "../../models";
import type { OAuthCredentials } from "./types";
@@ -159,14 +159,6 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
};
}
async function sleepForGitHubAccessTokenPoll(ms: number, signal?: AbortSignal): Promise<void> {
try {
await abortableSleep(ms, signal);
} catch {
throw new Error("Login cancelled");
}
}
async function pollForGitHubAccessToken(
domain: string,
deviceCode: string,
@@ -187,7 +179,11 @@ async function pollForGitHubAccessToken(
const remainingMs = deadline - Date.now();
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
await sleepForGitHubAccessTokenPoll(waitMs, signal);
try {
await scheduler.wait(waitMs, { signal });
} catch {
throw new Error("Login cancelled");
}
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
+4 -3
View File
@@ -6,7 +6,8 @@ import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $env, abortableSleep, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
import { scheduler } from "node:timers/promises";
import { $env, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
import packageJson from "../../../package.json" with { type: "json" };
import type { OAuthController, OAuthCredentials } from "./types";
@@ -185,7 +186,7 @@ async function pollForToken(
const error = payload.error;
if (error === "authorization_pending") {
await abortableSleep(waitMs, signal);
await scheduler.wait(waitMs, { signal });
continue;
}
@@ -193,7 +194,7 @@ async function pollForToken(
waitMs += 5000;
const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined;
if (retryAfter && retryAfter > waitMs) waitMs = retryAfter;
await abortableSleep(waitMs, signal);
await scheduler.wait(waitMs, { signal });
continue;
}
-2
View File
@@ -10,8 +10,6 @@
import type { OAuthController, OAuthProvider } from "./types";
const PROVIDER_ID: OAuthProvider = "lm-studio";
const _AUTH_URL = "https://lmstudio.ai/docs/api";
const _DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:1234/v1";
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
/**
+8 -130
View File
@@ -1,104 +1,5 @@
import { abortableSleep } from "@oh-my-pi/pi-utils";
type ErrorLike = {
message?: string;
name?: string;
status?: number;
statusCode?: number;
response?: { status?: number };
cause?: unknown;
code?: unknown;
error?: { code?: unknown } | null;
};
export function isUnexpectedSocketCloseMessage(message: string): boolean {
return /\b(?:the\s+)?socket connection (?:was )?closed unexpectedly\b/i.test(message);
}
const TRANSIENT_MESSAGE_PATTERN =
/overloaded|rate.?limit|too many requests|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|stream stall/i;
const VALIDATION_MESSAGE_PATTERN =
/invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i;
/**
* Identify errors that should be retried (timeouts, 5xx, 408, 429, transient network failures).
*/
export function isRetryableError(error: unknown): boolean {
const info = error as ErrorLike | null;
const message = info?.message ?? "";
const name = info?.name ?? "";
if (name === "AbortError" || /timeout|timed out|aborted/i.test(message)) return true;
const status = extractHttpStatusFromError(error);
if (status !== undefined) {
if (status >= 500) return true;
if (status === 408 || status === 429) return true;
if (status >= 400 && status < 500) return false;
}
if (VALIDATION_MESSAGE_PATTERN.test(message)) return false;
return isUnexpectedSocketCloseMessage(message) || TRANSIENT_MESSAGE_PATTERN.test(message);
}
export function extractHttpStatusFromError(error: unknown): number | undefined {
return extractHttpStatusFromErrorInternal(error, 0);
}
function extractHttpStatusFromErrorInternal(error: unknown, depth: number): number | undefined {
if (!error || typeof error !== "object" || depth > 2) return undefined;
const info = error as ErrorLike;
const rawStatus =
info.status ??
info.statusCode ??
(info.response && typeof info.response === "object" ? info.response.status : undefined);
let status: number | undefined;
if (typeof rawStatus === "number" && Number.isFinite(rawStatus)) {
status = rawStatus;
} else if (typeof rawStatus === "string") {
const parsed = Number(rawStatus);
if (Number.isFinite(parsed)) {
status = parsed;
}
}
if (status !== undefined && status >= 100 && status <= 599) {
return status;
}
if (info.message) {
const extracted = extractStatusFromMessage(info.message);
if (extracted !== undefined) return extracted;
}
if (info.cause) {
return extractHttpStatusFromErrorInternal(info.cause, depth + 1);
}
return undefined;
}
function extractStatusFromMessage(message: string): number | undefined {
const patterns = [
/error\s*\((\d{3})\)/i,
/status\s*[:=]?\s*(\d{3})/i,
/\bhttp\s*(\d{3})\b/i,
/\b(\d{3})\s*(?:status|error)\b/i,
];
for (const pattern of patterns) {
const match = pattern.exec(message);
if (!match) continue;
const value = Number(match[1]);
if (Number.isFinite(value) && value >= 100 && value <= 599) {
return value;
}
}
return undefined;
}
import { scheduler } from "node:timers/promises";
import { extractHttpStatusFromError, isRetryableError } from "@oh-my-pi/pi-utils";
/**
* GitHub Copilot intermittently rejects preview models (gpt-5.3-codex,
@@ -115,33 +16,10 @@ function extractStatusFromMessage(message: string): number | undefined {
*/
export function isCopilotTransientModelError(error: unknown): boolean {
if (extractHttpStatusFromError(error) !== 400) return false;
return extractErrorCode(error) === "model_not_supported";
}
export function isCopilotRetryableError(error: unknown): boolean {
if (isCopilotTransientModelError(error)) return true;
const status = extractHttpStatusFromError(error);
if (status !== undefined) {
return status >= 500 || status === 408 || status === 429;
}
const message = error instanceof Error ? error.message : String(error);
return (
isUnexpectedSocketCloseMessage(message) ||
/request was aborted|aborted|fetch failed|network error|timed?\s*out|timeout|other side closed/i.test(message)
);
}
function extractErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined;
const info = error as ErrorLike;
if (typeof info.code === "string") return info.code;
const nested = info.error;
if (nested && typeof nested === "object" && typeof nested.code === "string") {
return nested.code;
}
return undefined;
if (!error || typeof error !== "object") return false;
const info = error as { code?: unknown; error?: { code?: unknown } | null };
const code = typeof info.code === "string" ? info.code : info.error?.code;
return code === "model_not_supported";
}
const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3;
@@ -166,9 +44,9 @@ export async function callWithCopilotModelRetry<T>(
return await fn();
} catch (error) {
lastError = error;
if (!isCopilotRetryableError(error)) throw error;
if (!isCopilotTransientModelError(error) && !isRetryableError(error)) throw error;
if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break;
await abortableSleep(COPILOT_MODEL_RETRY_BASE_DELAY_MS * (attempt + 1), options.signal);
await scheduler.wait(COPILOT_MODEL_RETRY_BASE_DELAY_MS * (attempt + 1), { signal: options.signal });
}
}
throw lastError;
@@ -470,7 +470,7 @@ describe("anthropic stream envelope handling", () => {
vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
for await (const _event of stream) {
for await (const _ of stream) {
// drain stream
}
const result = await stream.result();
@@ -497,7 +497,7 @@ describe("anthropic stream envelope handling", () => {
});
const eagerStream = streamAnthropic(model, toolContext, { apiKey: "sk-ant-test" });
for await (const _event of eagerStream) {
for await (const _ of eagerStream) {
// drain stream
}
await eagerStream.result();
@@ -507,7 +507,7 @@ describe("anthropic stream envelope handling", () => {
toolContext,
{ apiKey: "sk-ant-test" },
);
for await (const _event of disabledStream) {
for await (const _ of disabledStream) {
// drain stream
}
await disabledStream.result();
@@ -534,7 +534,7 @@ describe("anthropic stream envelope handling", () => {
apiKey: "sk-ant-test",
cacheRetention: "long",
});
for await (const _event of stream) {
for await (const _ of stream) {
// drain stream
}
await stream.result();
+3 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { callWithCopilotModelRetry, isCopilotTransientModelError, isRetryableError } from "@oh-my-pi/pi-ai/utils/retry";
import { callWithCopilotModelRetry, isCopilotTransientModelError } from "@oh-my-pi/pi-ai/utils/retry";
import { isRetryableError } from "@oh-my-pi/pi-utils";
type ErrorShape = { status: number; code?: string; error?: { code?: string; message?: string }; message: string };
@@ -132,7 +133,7 @@ describe("callWithCopilotModelRetry", () => {
{ provider: "github-copilot", signal: controller.signal },
),
).rejects.toBeDefined();
// fn runs once; abortableSleep rejects before a second attempt.
// fn runs once; scheduler.wait rejects before a second attempt.
expect(calls).toBe(1);
});
});
@@ -227,7 +227,6 @@ describe("loginGitHubCopilot", () => {
});
it("device flow error", async () => {
let _pollCount = 0;
const fetchMock = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://github.com/login/device/code") {
@@ -237,7 +236,6 @@ describe("loginGitHubCopilot", () => {
});
}
if (url === "https://github.com/login/oauth/access_token") {
_pollCount++;
return new Response(JSON.stringify({ error: "access_denied", error_description: "User denied" }), {
status: 200,
headers: { "Content-Type": "application/json" },
+38 -56
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { extractRetryDelay } from "@oh-my-pi/pi-ai/providers/google-gemini-cli";
import { extractRetryHint } from "@oh-my-pi/pi-utils";
// The fail-fast regex used inside the provider to distinguish "known quota errors" (throw immediately)
// from "ambiguous 429s" (retry up to RATE_LIMIT_BUDGET_MS).
@@ -7,102 +7,84 @@ import { extractRetryDelay } from "@oh-my-pi/pi-ai/providers/google-gemini-cli";
const FAIL_FAST_RE = /quota|exhausted/i;
const shouldFailFast = (errorText: string) => FAIL_FAST_RE.test(errorText);
// normalizeDelay adds a 1 second buffer and rounds up:
// normalizeDelay(ms) = Math.ceil(ms + 1000)
// So all extractRetryDelay results are at least 1 second more than the raw delay.
describe("google-gemini-cli 429 fail-fast detection", () => {
it("fails fast on 'Quota exceeded'", () => {
expect(shouldFailFast("Quota exceeded")).toBe(true);
it("fails fast on 'Quota exceeded' messages", () => {
expect(shouldFailFast("Quota exceeded for project")).toBe(true);
});
it("fails fast on 'Resource has been exhausted'", () => {
it("fails fast on 'exhausted' messages", () => {
expect(shouldFailFast("Resource has been exhausted")).toBe(true);
});
it("retries (does NOT fail fast) on 'Too many requests'", () => {
expect(shouldFailFast("Too many requests")).toBe(false);
it("does not fail fast on ambiguous 429 ('Please retry in 5s')", () => {
expect(shouldFailFast("Please retry in 5s")).toBe(false);
});
it("retries (does NOT fail fast) on 'rate limit exceeded'", () => {
expect(shouldFailFast("rate limit exceeded")).toBe(false);
it("does not fail fast on generic rate-limit text", () => {
expect(shouldFailFast("Rate limit exceeded, please slow down")).toBe(false);
});
it("fails fast on per-minute quota message", () => {
expect(shouldFailFast("Exceeded per minute quota for generateContent")).toBe(true);
it("matches case-insensitively", () => {
expect(shouldFailFast("QUOTA EXCEEDED")).toBe(true);
expect(shouldFailFast("Resource Has Been Exhausted")).toBe(true);
});
it("does NOT fail fast on generic 429 with empty body", () => {
it("does not fail fast on empty error", () => {
expect(shouldFailFast("")).toBe(false);
});
it("does NOT fail fast on unknown 429 message", () => {
expect(shouldFailFast("Internal server error")).toBe(false);
});
it("is case-insensitive for all variants", () => {
expect(shouldFailFast("QUOTA EXHAUSTED")).toBe(true);
expect(shouldFailFast("Rate Limit Exceeded")).toBe(false);
expect(shouldFailFast("TOO MANY REQUESTS")).toBe(false);
});
});
describe("extractRetryDelay – header parsing", () => {
it("reads retry-after header as seconds → normalizeDelay(5000) = 6000", () => {
describe("extractRetryHint – header parsing", () => {
it("reads retry-after header as seconds", () => {
const headers = new Headers({ "retry-after": "5" });
// normalizeDelay(5 * 1000) = Math.ceil(5000 + 1000) = 6000
expect(extractRetryDelay("", headers)).toBe(6000);
expect(extractRetryHint(headers)).toBe(5_000);
});
it("reads x-ratelimit-reset-after header as seconds → normalizeDelay(30000) = 31000", () => {
it("reads x-ratelimit-reset-after header as seconds", () => {
const headers = new Headers({ "x-ratelimit-reset-after": "30" });
// normalizeDelay(30 * 1000) = Math.ceil(30000 + 1000) = 31000
expect(extractRetryDelay("", headers)).toBe(31000);
expect(extractRetryHint(headers)).toBe(30_000);
});
it("prefers retry-after over x-ratelimit-reset-after when both are present", () => {
const headers = new Headers({ "retry-after": "5", "x-ratelimit-reset-after": "30" });
// retry-after is checked first → 6000
expect(extractRetryDelay("", headers)).toBe(6000);
expect(extractRetryHint(headers)).toBe(5_000);
});
});
describe("extractRetryDelay – body text parsing", () => {
it("parses 'retryDelay' JSON field in seconds → normalizeDelay(3000) = 4000", () => {
// Regex: /"retryDelay":\s*"([0-9.]+)(ms|s)"/i
const body = '"retryDelay": "3s"';
// normalizeDelay(3 * 1000) = Math.ceil(3000 + 1000) = 4000
expect(extractRetryDelay(body)).toBe(4000);
describe("extractRetryHint – body text parsing", () => {
it("parses 'retryDelay' JSON field in seconds", () => {
expect(extractRetryHint(undefined, '"retryDelay": "3s"')).toBe(3_000);
});
it("parses 'retryDelay' JSON field in milliseconds → normalizeDelay(500) = 1500", () => {
const body = '"retryDelay": "500ms"';
// normalizeDelay(500) = Math.ceil(500 + 1000) = 1500
expect(extractRetryDelay(body)).toBe(1500);
it("parses 'retryDelay' JSON field in milliseconds", () => {
expect(extractRetryHint(undefined, '"retryDelay": "500ms"')).toBe(500);
});
it("parses 'Please retry in Xs' pattern → normalizeDelay(5000) = 6000", () => {
// Regex: /Please retry in ([0-9.]+)(ms|s)/i
expect(extractRetryDelay("Please retry in 5s")).toBe(6000);
it("parses 'Please retry in Xs' pattern", () => {
expect(extractRetryHint(undefined, "Please retry in 5s")).toBe(5_000);
});
it("parses 'quota will reset after Xs' simple duration → normalizeDelay(39000) = 40000", () => {
// Regex: /reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i
// totalMs = 39 * 1000 = 39000 → normalizeDelay = 40000
expect(extractRetryDelay("Your quota will reset after 39s")).toBe(40000);
it("parses 'quota will reset after Xs' simple duration", () => {
expect(extractRetryHint(undefined, "Your quota will reset after 39s")).toBe(39_000);
});
it("parses compound duration 'reset after 1h30m10s'", () => {
// (1*3600 + 30*60 + 10) * 1000 = 5 410 000 ms
// normalizeDelay(5410000) = Math.ceil(5410000 + 1000) = 5411000
expect(extractRetryDelay("Your quota will reset after 1h30m10s")).toBe(5411000);
expect(extractRetryHint(undefined, "Your quota will reset after 1h30m10s")).toBe(5_410_000);
});
it("parses Codex-style 'try again in Xms'", () => {
expect(extractRetryHint(undefined, "try again in 250ms")).toBe(250);
});
it("parses Codex-style 'try again in Xs'", () => {
expect(extractRetryHint(undefined, "try again in 12s")).toBe(12_000);
});
it("returns undefined when body contains no recognised delay pattern", () => {
expect(extractRetryDelay("Quota exceeded, please try again later")).toBeUndefined();
expect(extractRetryHint(undefined, "Quota exceeded, please try again later")).toBeUndefined();
});
it("returns undefined for empty error string and no headers", () => {
expect(extractRetryDelay("")).toBeUndefined();
expect(extractRetryHint(undefined, "")).toBeUndefined();
});
});
@@ -244,7 +244,7 @@ describe("Google Gemini CLI alignment", () => {
const result = await stream.result();
expect(fetchCalls).toBe(1);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Server requested 121s retry delay (max: 1s)");
expect(result.errorMessage).toContain("Cloud Code Assist API error (503)");
});
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ import { $flag, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
* E2E tests require explicit opt-in via E2E=1 environment variable.
* This prevents accidental API calls when keys happen to be in the environment.
*/
export const E2E_ENABLED = $flag("E2E");
const E2E_ENABLED = $flag("E2E");
/**
* Get an API key from environment, but only if E2E tests are enabled.
+1 -1
View File
@@ -501,7 +501,7 @@ describe("Generate E2E Tests", () => {
);
const drain = (async () => {
for await (const _event of events) {
for await (const _ of events) {
}
})();
+3
View File
@@ -4,6 +4,7 @@
### Added
- Added `hide: true` frontmatter option for skill `SKILL.md` files. Hidden skills are still loaded and remain reachable via `skill://<name>` URLs and (when enabled) `/skill:<name>` slash commands, but are omitted from the rendered system prompt's `<skills>` listing so the model won't auto-discover them. Use for skills the user opts into explicitly rather than ones the model should pick up from descriptions.
- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://<id>`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`.
- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
- Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**.
@@ -24,6 +25,8 @@
### Fixed
- Fixed token display for sessions and subagents inflating far beyond the context window. `token_total` status-line segment and the subagent overlay token counter now show `input + output + cacheWrite` instead of `input + output + cacheRead + cacheWrite`. With prompt caching, `cacheRead` per turn equals the full cached context — summing it across all turns produces a cumulative total that is N×context_size (e.g. a 5-turn session with a 1 M-token context reported ~5 M tokens). Cache activity is still visible via the dedicated `cache_read`/`cache_write` status-line segments; billing cost is unaffected.
- Fixed ACP clients missing `config_option_update` notifications when the thinking level changed via any path other than the client's own `session/set_session_config_option` call (slash commands, model auto-adjust, extension UI). `AgentSession` now emits a `thinking_level_changed` event from `setThinkingLevel`, and `AcpAgent` subscribes to each managed session for the session's lifetime and pushes a fresh `config_option_update` whenever the effective level changes — independent of any active prompt turn. The subscription is installed inside `#scheduleBootstrapUpdates`'s 50 ms timer so it shares the same race guard that prevents Zed's `Received session notification for unknown session` drop when notifications fire before `session/new` (or fork) returns; the pre-bootstrap thinking level is reported in the response's `configOptions`. The `session/set_session_config_option` handler keeps its own push only when the subscription has not yet been installed, so client-driven thinking changes still notify pre-bootstrap, post-bootstrap they flow through the subscription exactly once. Subscriptions are released in `#disposeSessionRecord`.
- Fixed MCP OAuth refresh failing with `HTTP 401 invalid_client` for servers that require Dynamic Client Registration (RFC 7591) and have no `oauth.clientId` configured (e.g. `mcp.linear.app`). `MCPOAuthFlow` registered a fresh public PKCE client on each authorize and discarded the issued `client_id` once the flow object went out of scope; refresh then called the provider's `/token` endpoint without a `client_id`. The flow now exposes `resolvedClientId` / `registeredClientSecret` getters, `MCPCommandController#handleOAuthFlow` returns them alongside `credentialId`, and both the initial-connect and `/mcp reauth` paths persist them into `auth.{clientId,clientSecret}` (used at refresh) and `oauth.{clientId,clientSecret}` (used by subsequent `/mcp reauth` to skip re-registration). The `MCPAddWizard` `onOAuth` callback type is now `Promise<MCPAddWizardOAuthResult>` and `#launchOAuthFlow` folds the registered credentials into wizard state. Servers with a statically-configured `oauth.clientId` (Notion, Slack, Datadog) are unaffected — `#tryRegisterClient` short-circuits and the write-back is a no-op. ([#1061](https://github.com/can1357/oh-my-pi/pull/1061) by [@ldx](https://github.com/ldx)).
## [15.0.0] - 2026-05-13
### Breaking Changes
@@ -334,7 +334,6 @@ export default function planModeExtension(pi: ExtensionAPI) {
}
// Remove any previous plan-mode-context messages
const _beforeCount = event.messages.length;
const filtered = event.messages.filter(m => {
if (m.role === "user" && Array.isArray(m.content)) {
const hasOldContext = m.content.some(
@@ -1,3 +1,4 @@
import * as git from "../utils/git";
import type { ASIData, ASIValue, MetricDirection, NumericMetricMap } from "./types";
export const METRIC_LINE_PREFIX = "METRIC";
@@ -199,3 +200,19 @@ function sanitizeAsiValue(value: unknown): ASIValue | undefined {
}
return undefined;
}
export async function tryGitStatus(cwd: string): Promise<string> {
try {
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
} catch {
return "";
}
}
export async function tryGitPrefix(cwd: string): Promise<string> {
try {
return await git.show.prefix(cwd);
} catch {
return "";
}
}
@@ -8,7 +8,15 @@ import type { Theme } from "../../modes/theme/theme";
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
import * as git from "../../utils/git";
import { computeRunModifiedPaths, getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
import { ensureNumericMetricMap, formatNum, mergeAsi, pathMatchesSpec, sanitizeAsi } from "../helpers";
import {
ensureNumericMetricMap,
formatNum,
mergeAsi,
pathMatchesSpec,
sanitizeAsi,
tryGitPrefix,
tryGitStatus,
} from "../helpers";
import {
buildExperimentState,
computeConfidence,
@@ -445,22 +453,6 @@ async function tryReadHeadSha(cwd: string): Promise<string | null> {
}
}
async function tryGitStatus(cwd: string): Promise<string> {
try {
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
} catch {
return "";
}
}
async function tryGitPrefix(cwd: string): Promise<string> {
try {
return await git.show.prefix(cwd);
} catch {
return "";
}
}
function buildLogText(
state: ExperimentState,
experiment: ExperimentResult,
@@ -18,6 +18,8 @@ import {
killTree,
parseAsiLines,
parseMetricLines,
tryGitPrefix,
tryGitStatus,
} from "../helpers";
import { buildExperimentState } from "../state";
import { openAutoresearchStorageIfExists } from "../storage";
@@ -265,23 +267,6 @@ export function createRunExperimentTool(
},
};
}
async function tryGitStatus(cwd: string): Promise<string> {
try {
return await git.status(cwd, { porcelainV1: true, untrackedFiles: "all", z: true });
} catch {
return "";
}
}
async function tryGitPrefix(cwd: string): Promise<string> {
try {
return await git.show.prefix(cwd);
} catch {
return "";
}
}
async function executeProcess(opts: {
command: string[];
cwd: string;
@@ -14,6 +14,13 @@ export interface SkillFrontmatter {
description?: string;
globs?: string[];
alwaysApply?: boolean;
/**
* When `true`, the skill is loaded and accessible via `skill://<name>` (and
* `/skill:<name>` slash commands), but is omitted from the rendered system
* prompt's skill listing. Use for skills the user opts into explicitly
* rather than ones the model should auto-discover.
*/
hide?: boolean;
[key: string]: unknown;
}
+1 -1
View File
@@ -2,11 +2,11 @@
* List available models with optional fuzzy search
*/
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
import { fuzzyFilter } from "@oh-my-pi/pi-tui";
import { formatNumber } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { discoverAndLoadExtensions, loadExtensions } from "../extensibility/extensions";
import { EventBus } from "../utils/event-bus";
import { fuzzyFilter } from "../utils/fuzzy";
interface ProviderRow {
provider: string;
+3 -13
View File
@@ -5,10 +5,11 @@
*/
import * as path from "node:path";
import { createInterface } from "node:readline/promises";
import { type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
import { Shell } from "@oh-my-pi/pi-natives";
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { Settings, type ShellMinimizerSettings } from "../config/settings";
import { Settings } from "../config/settings";
import { buildMinimizerOptions } from "../exec/bash-executor";
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
export interface ShellCommandArgs {
@@ -41,17 +42,6 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined {
return result;
}
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
if (!group.enabled) return undefined;
return {
enabled: true,
settingsPath: group.settingsPath || undefined,
only: group.only.length > 0 ? group.only : undefined,
except: group.except.length > 0 ? group.except : undefined,
maxCaptureBytes: group.maxCaptureBytes,
};
}
export async function runShellCommand(cmd: ShellCommandArgs): Promise<void> {
if (!process.stdin.isTTY) {
process.stderr.write("Error: shell console requires an interactive TTY.\n");
+1 -1
View File
@@ -91,7 +91,7 @@ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "b
return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
}
export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
return resolveUpdateMethod(ompPath, bunBinDir);
}
async function resolveUpdateTarget(): Promise<UpdateTarget> {
@@ -1,52 +1,16 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" };
import type { ChangelogCategory, ConventionalAnalysis } from "../../commit/types";
import type { ConventionalAnalysis } from "../../commit/types";
import { toReasoningEffort } from "../../thinking";
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
const ConventionalAnalysisTool = {
name: "create_conventional_analysis",
description: "Analyze a diff and return conventional commit classification.",
parameters: Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
}),
};
const ConventionalAnalysisTool = createConventionalAnalysisTool(
"Analyze a diff and return conventional commit classification.",
);
export interface ConventionalAnalysisInput {
model: Model<Api>;
@@ -96,27 +60,5 @@ export async function generateConventionalAnalysis({
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
);
return parseAnalysisFromResponse(response);
}
function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis {
const toolCall = extractToolCall(message, "create_conventional_analysis");
if (toolCall) {
const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool);
}
@@ -1,52 +1,14 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
import reduceUserPrompt from "../../commit/prompts/reduce-user.md" with { type: "text" };
import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "../../commit/types";
import type { ConventionalAnalysis, FileObservation } from "../../commit/types";
import { toReasoningEffort } from "../../thinking";
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "../utils";
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../shared-llm";
const ReduceTool = {
name: "create_conventional_analysis",
description: "Synthesize file observations into a conventional commit analysis.",
parameters: Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
}),
};
const ReduceTool = createConventionalAnalysisTool("Synthesize file observations into a conventional commit analysis.");
export interface ReducePhaseInput {
model: Model<Api>;
@@ -83,26 +45,5 @@ export async function runReducePhase({
{ apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
);
return parseAnalysisResponse(response);
}
function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis {
const toolCall = extractToolCall(message, "create_conventional_analysis");
if (toolCall) {
const parsed = validateToolCall([ReduceTool], toolCall) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
return parseConventionalAnalysisResponse(response, ReduceTool);
}
+2 -2
View File
@@ -25,8 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
const SUMMARY_MAX_CHARS = 72;
const RECENT_COMMITS_COUNT = 8;
let _typesDescription: string | undefined;
const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt));
let typesDescription: string | undefined;
const TYPES_DESCRIPTION = (): string => (typesDescription ??= prompt.render(typesDescriptionPrompt));
/**
* Execute the omp commit pipeline for staged changes.
@@ -0,0 +1,89 @@
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import type { ChangelogCategory, ConventionalAnalysis } from "./types";
import { extractTextContent, extractToolCall, normalizeAnalysis, parseJsonPayload } from "./utils";
/**
* Shared TypeBox schema for the `create_conventional_analysis` tool used by
* both the single-pass analysis call and the map-reduce reduce phase. Schemas
* are identical across phases — only the surrounding tool `description`
* differs to reflect the input the phase is summarizing.
*/
export const conventionalAnalysisParameters = Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
});
export interface ConventionalAnalysisTool {
name: "create_conventional_analysis";
description: string;
parameters: typeof conventionalAnalysisParameters;
}
/**
* Build a `create_conventional_analysis` tool descriptor. Phase-specific
* `description` text is the only thing that varies between callers.
*/
export function createConventionalAnalysisTool(description: string): ConventionalAnalysisTool {
return {
name: "create_conventional_analysis",
description,
parameters: conventionalAnalysisParameters,
};
}
interface ParsedConventionalAnalysis {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
}
/**
* Extract a {@link ConventionalAnalysis} from an assistant response, preferring
* a structured tool call and falling back to JSON embedded in text content.
*/
export function parseConventionalAnalysisResponse(
message: AssistantMessage,
tool: ConventionalAnalysisTool,
): ConventionalAnalysis {
const toolCall = extractToolCall(message, tool.name);
if (toolCall) {
const parsed = validateToolCall([tool], toolCall) as ParsedConventionalAnalysis;
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as ParsedConventionalAnalysis;
return normalizeAnalysis(parsed);
}
@@ -72,15 +72,12 @@ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => {
})();
const WRAPPER_PREFIXES = ["duo-chat-"] as const;
let __referenceDataCache: CanonicalReferenceData | undefined;
let referenceDataCache: CanonicalReferenceData | undefined;
const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
overrides: new Map<string, string>(),
exclude: new Set<string>(),
};
const __resolutionCache: WeakMap<
CompiledEquivalenceConfig,
WeakMap<Model<Api>, ResolvedCanonicalModel>
> = new WeakMap();
const resolutionCache: WeakMap<CompiledEquivalenceConfig, WeakMap<Model<Api>, ResolvedCanonicalModel>> = new WeakMap();
const FAMILY_EXTRACTION_PATTERNS = [
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
@@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
}
function createCanonicalReferenceData(): CanonicalReferenceData {
if (__referenceDataCache) {
return __referenceDataCache;
if (referenceDataCache) {
return referenceDataCache;
}
const references = new Map<string, Model<Api>>();
for (const provider of getBundledProviders()) {
@@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
}
}
const officialIds = new Set(references.keys());
__referenceDataCache = {
referenceDataCache = {
references: Object.freeze(references) as Map<string, Model<Api>>,
officialIds: Object.freeze(officialIds) as Set<string>,
};
return __referenceDataCache;
return referenceDataCache;
}
function normalizeSelectorKey(selector: string): string {
@@ -668,10 +665,10 @@ export function buildCanonicalModelIndex(
const byId = new Map<string, CanonicalModelRecord>();
const bySelector = new Map<string, string>();
let modelCache = __resolutionCache.get(compiledEquivalence);
let modelCache = resolutionCache.get(compiledEquivalence);
if (!modelCache) {
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
__resolutionCache.set(compiledEquivalence, modelCache);
resolutionCache.set(compiledEquivalence, modelCache);
}
for (const model of models) {
@@ -12,10 +12,10 @@ import {
type Model,
modelsAreEqual,
} from "@oh-my-pi/pi-ai";
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import { fuzzyMatch } from "../utils/fuzzy";
import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import type { Settings } from "./settings";
@@ -607,9 +607,6 @@ export function resolveModelRoleValue(
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
}
const lastColonIndex = normalized.lastIndexOf(":");
const _thinkingSelector =
lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
if (!effectivePatterns || effectivePatterns.length === 0) {
return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
+1 -1
View File
@@ -850,7 +850,7 @@ export function isSettingsInitialized(): boolean {
* Reset the global singleton for testing.
* @internal
*/
export function _resetSettingsForTest(): void {
export function resetSettingsForTest(): void {
globalInstance = null;
globalInstancePromise = null;
}
@@ -180,7 +180,7 @@ export function rewriteImports(code: string): string {
* Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
* scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
*/
export function demoteTopLevelLexicals(code: string): string {
function demoteTopLevelLexicals(code: string): string {
if (!/\b(?:const|let|class)\b/.test(code)) return code;
const ast = parseProgram(code);
@@ -248,7 +248,7 @@ function returnFinalExpression(code: string): { source: string; returned: boolea
* common case avoids an extra transpile pass. We detect "looks like TS" with a cheap regex
* before invoking the transpiler.
*/
export function stripTypeScript(code: string): string {
function stripTypeScript(code: string): string {
if (!LOOKS_LIKE_TS.test(code)) return code;
try {
return new Bun.Transpiler({ loader: "ts" }).transformSync(code);
@@ -65,7 +65,8 @@ async function resolveShellCwd(cwd: string | undefined): Promise<string | undefi
}
}
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
/** Translate `ShellMinimizerSettings` into native `MinimizerOptions`, or `undefined` when disabled. */
export function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
if (!group.enabled) return undefined;
return {
enabled: true,
@@ -63,7 +63,7 @@ export type ExtensionErrorListener = (error: ExtensionError) => void;
export const EXTENSION_HANDLER_TIMEOUT_MS = 30_000;
let extensionHandlerTimeoutMs = EXTENSION_HANDLER_TIMEOUT_MS;
export function __test_setExtensionHandlerTimeoutMs(timeoutMs: number): void {
export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
extensionHandlerTimeoutMs = timeoutMs;
}
@@ -18,13 +18,11 @@ import type {
ProviderResponseMetadata,
SimpleStreamOptions,
TextContent,
ToolResultMessage,
} from "@oh-my-pi/pi-ai";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Rule } from "../../capability/rule";
import type { KeybindingsManager } from "../../config/keybindings";
import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit";
@@ -33,15 +31,9 @@ import type { BashResult } from "../../exec/bash-executor";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { CustomEditor } from "../../modes/components/custom-editor";
import type { Theme } from "../../modes/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
import type { CompactionResult } from "../../session/compaction";
import type { CustomMessage } from "../../session/messages";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
import type {
BashToolDetails,
BashToolInput,
@@ -53,8 +45,39 @@ import type {
SearchToolInput,
WriteToolInput,
} from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
import type { EventBus } from "../../utils/event-bus";
import type {
AgentEndEvent,
AgentStartEvent,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
ContextEvent,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionCompactingResult,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TodoReminderEvent,
ToolCallEventResult,
ToolResultEventResult,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
import type { SlashCommandInfo } from "../slash-commands";
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
@@ -104,6 +127,11 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
* UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation.
*/
// fallow-ignore-next-line code-duplication
// Parallel to HookUIContext: extensions expose a strictly larger UI surface
// (custom editor component, header/footer, widgets, theming, terminal input)
// and may be invoked from event handlers that have already taken the agent
// loop's lock — hooks intentionally cannot.
export interface ExtensionUIContext {
/** Show a selector and return the user's choice. */
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
@@ -221,6 +249,11 @@ export interface CompactOptions {
/**
* Context passed to extension event handlers.
*/
// fallow-ignore-next-line code-duplication
// Parallel to HookContext: extensions expose a strictly larger runtime
// surface (model registry, system prompt, shutdown, full session manager
// access). Field overlap is incidental; merging into a base would require
// hooks to widen their public contract.
export interface ExtensionContext {
/** UI methods for user interaction */
ui: ExtensionUIContext;
@@ -256,6 +289,10 @@ export interface ExtensionContext {
* Extended context for command handlers.
* Includes session control methods only safe in user-initiated commands.
*/
// fallow-ignore-next-line code-duplication
// Parallel to HookCommandContext: same method names, different invariants —
// extension commands additionally permit `switchSession` and `reload`,
// which hooks must not call to avoid deadlocking the agent loop.
export interface ExtensionCommandContext extends ExtensionContext {
/** Get current context usage for the active model. */
getContextUsage(): ContextUsage | undefined;
@@ -373,115 +410,30 @@ export interface ResourcesDiscoverResult {
}
// ============================================================================
// Session Events
// Session Events (shared with hooks subsystem)
// ============================================================================
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
reason: "new" | "resume" | "fork";
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
reason: "new" | "resume" | "fork";
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
preparation: CompactionPreparation;
branchEntries: SessionEntry[];
customInstructions?: string;
signal: AbortSignal;
}
/** Fired before compaction summarization to customize prompts/context */
export interface SessionCompactingEvent {
type: "session.compacting";
sessionId: string;
messages: AgentMessage[];
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromExtension: boolean;
}
/** Fired on process exit */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation */
export interface TreePreparation {
targetId: string;
oldLeafId: string | null;
commonAncestorId: string | null;
entriesToSummarize: SessionEntry[];
userWantsSummary: boolean;
}
/** Fired before navigating in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
preparation: TreePreparation;
signal: AbortSignal;
}
/** Fired after navigating in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromExtension?: boolean;
}
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactingEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
export type {
SessionBeforeBranchEvent,
SessionBeforeCompactEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TreePreparation,
} from "../shared-events";
// ============================================================================
// Agent Events
// ============================================================================
/** Fired before each LLM call. Can modify messages. */
export interface ContextEvent {
type: "context";
messages: AgentMessage[];
}
export type { ContextEvent } from "../shared-events";
/** Fired before a provider request is sent. Can replace the payload. */
export interface BeforeProviderRequestEvent {
@@ -502,31 +454,7 @@ export interface BeforeAgentStartEvent {
systemPrompt: string[];
}
/** Fired when an agent loop starts */
export interface AgentStartEvent {
type: "agent_start";
}
/** Fired when an agent loop ends */
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/** Fired at the start of each turn */
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/** Fired at the end of each turn */
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events";
/** Fired when a message starts (user, assistant, or toolResult) */
export interface MessageStartEvent {
@@ -574,55 +502,14 @@ export interface ToolExecutionEndEvent {
isError: boolean;
}
/** Fired when auto-compaction starts */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
/** Fired when auto-compaction ends */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
/** Fired when auto-retry starts */
export interface AutoRetryStartEvent {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
/** Fired when auto-retry ends */
export interface AutoRetryEndEvent {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
}
/** Fired when TTSR rule matching interrupts generation */
export interface TtsrTriggeredEvent {
type: "ttsr_triggered";
rules: Rule[];
}
/** Fired when todo reminder logic detects unfinished todos */
export interface TodoReminderEvent {
type: "todo_reminder";
todos: TodoItem[];
attempt: number;
maxAttempts: number;
}
export type {
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
TodoReminderEvent,
TtsrTriggeredEvent,
} from "../shared-events";
/** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */
export interface CredentialDisabledEvent {
@@ -857,10 +744,7 @@ export interface ContextEventResult {
export type BeforeProviderRequestEventResult = unknown;
export interface ToolCallEventResult {
block?: boolean;
reason?: string;
}
export type { ToolCallEventResult } from "../shared-events";
/** Result from input event handler */
export interface InputEventResult {
@@ -884,11 +768,7 @@ export interface UserPythonEventResult {
result?: PythonResult;
}
export interface ToolResultEventResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
}
export type { ToolResultEventResult } from "../shared-events";
export interface BeforeAgentStartEventResult {
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
@@ -896,33 +776,13 @@ export interface BeforeAgentStartEventResult {
systemPrompt?: string[];
}
export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
export interface SessionBeforeBranchResult {
cancel?: boolean;
skipConversationRestore?: boolean;
}
export interface SessionBeforeCompactResult {
cancel?: boolean;
compaction?: CompactionResult;
}
export interface SessionCompactingResult {
context?: string[];
prompt?: string;
preserveData?: Record<string, unknown>;
}
export interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: {
summary: string;
details?: unknown;
};
}
export type {
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
SessionCompactingResult,
} from "../shared-events";
// ============================================================================
// Message Rendering
@@ -942,6 +802,9 @@ export type MessageRenderer<T = unknown> = (
// Command Registration
// ============================================================================
// fallow-ignore-next-line code-duplication
// Parallel to HookAPI's RegisteredCommand: extensions add
// `getArgumentCompletions` and bind handlers to ExtensionCommandContext.
export interface RegisteredCommand {
name: string;
description?: string;
@@ -1,28 +1,44 @@
/**
* Hook system types.
*
* Hooks are TypeScript modules that can subscribe to agent lifecycle events
* and interact with the user via UI primitives.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
import type { Component, TUI } from "@oh-my-pi/pi-tui";
import type { Rule } from "../../capability/rule";
import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { Theme } from "../../modes/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
import type { HookMessage } from "../../session/messages";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
import type {
AgentEndEvent,
AgentStartEvent,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
ContextEvent,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionCompactingResult,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TodoReminderEvent,
ToolCallEventResult,
ToolResultEventResult,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -31,6 +47,11 @@ export type { ExecOptions, ExecResult } from "../../exec/exec";
* UI context for hooks to request interactive UI from the harness.
* Each mode (interactive, RPC, print) provides its own implementation.
*/
// fallow-ignore-next-line code-duplication
// Parallel to ExtensionUIContext: hooks expose a deliberately narrower UI
// surface — no terminal-input listener, no editor component override, no
// theme management — because hooks are invoked from inside the agent loop
// and must not be able to seize ownership of the editor.
export interface HookUIContext {
/**
* Show a selector and return the user's choice.
@@ -137,6 +158,11 @@ export interface HookUIContext {
* Context passed to hook event handlers.
* For command handlers, see HookCommandContext which extends this with session control methods.
*/
// fallow-ignore-next-line code-duplication
// Parallel to ExtensionContext: hooks see a narrower runtime context (no
// model registry mutation, no system prompt access, no shutdown). The
// overlap in field names is intentional API symmetry; widening hooks to
// match extensions would let hooks call methods that deadlock the agent.
export interface HookContext {
/** UI methods for user interaction */
ui: HookUIContext;
@@ -165,6 +191,11 @@ export interface HookContext {
* These methods are not available in event handlers because they can cause
* deadlocks when called from within the agent loop (e.g., tool_call, context events).
*/
// fallow-ignore-next-line code-duplication
// Parallel to ExtensionCommandContext: hooks intentionally omit
// `switchSession`, `reload`, `compact`, and `getContextUsage` — those are
// safe only from extension command handlers, not from the hook execution
// context.
export interface HookCommandContext extends HookContext {
/** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>;
@@ -210,138 +241,25 @@ export interface HookCommandContext extends HookContext {
}
// ============================================================================
// Session Events
// Session Events (shared with extensions subsystem)
// ============================================================================
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we're switching to (only for "resume") */
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired before compaction summarization to customize prompts/context */
export interface SessionCompactingEvent {
type: "session.compacting";
sessionId: string;
messages: AgentMessage[];
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromExtension: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook */
fromExtension?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactingEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
/**
* Event data for context event.
* Fired before each LLM call, allowing hooks to modify context non-destructively.
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
export type {
ContextEvent,
SessionBeforeBranchEvent,
SessionBeforeCompactEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionBranchEvent,
SessionCompactEvent,
SessionCompactingEvent,
SessionEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
TreePreparation,
} from "../shared-events";
/**
* Event data for before_agent_start event.
@@ -356,90 +274,18 @@ export interface BeforeAgentStartEvent {
images?: ImageContent[];
}
/**
* Event data for agent_start event.
* Fired when an agent loop starts (once per user prompt).
*/
export interface AgentStartEvent {
type: "agent_start";
}
/**
* Event data for agent_end event.
*/
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/**
* Event data for turn_start event.
*/
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/**
* Event data for turn_end event.
*/
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
/** Event data for auto_compaction_start event. */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
/** Event data for auto_compaction_end event. */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
/** Event data for auto_retry_start event. */
export interface AutoRetryStartEvent {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
/** Event data for auto_retry_end event. */
export interface AutoRetryEndEvent {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
}
/** Event data for ttsr_triggered event. */
export interface TtsrTriggeredEvent {
type: "ttsr_triggered";
rules: Rule[];
}
/** Event data for todo_reminder event. */
export interface TodoReminderEvent {
type: "todo_reminder";
todos: TodoItem[];
attempt: number;
maxAttempts: number;
}
export type {
AgentEndEvent,
AgentStartEvent,
AutoCompactionEndEvent,
AutoCompactionStartEvent,
AutoRetryEndEvent,
AutoRetryStartEvent,
TodoReminderEvent,
TtsrTriggeredEvent,
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
/**
* Event data for tool_call event.
@@ -559,29 +405,7 @@ export interface ContextEventResult {
messages?: Message[];
}
/**
* Return type for tool_call event handlers.
* Allows hooks to block tool execution.
*/
export interface ToolCallEventResult {
/** If true, block the tool from executing */
block?: boolean;
/** Reason for blocking (returned to LLM as error) */
reason?: string;
}
/**
* Return type for tool_result event handlers.
* Allows hooks to modify tool results.
*/
export interface ToolResultEventResult {
/** Replacement content array (text and images) */
content?: (TextContent | ImageContent)[];
/** Replacement details */
details?: unknown;
/** Override isError flag */
isError?: boolean;
}
export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events";
/**
* Return type for before_agent_start event handlers.
@@ -592,65 +416,13 @@ export interface BeforeAgentStartEventResult {
message?: Pick<HookMessage, "customType" | "content" | "display" | "details" | "attribution">;
}
/** Return type for session_before_switch handlers */
export interface SessionBeforeSwitchResult {
/** If true, cancel the switch */
cancel?: boolean;
}
/** Return type for session_before_branch handlers */
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint hook that restores code state separately.
* The hook handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` → nothing happens, user stays in current session
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
* - neither → branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
}
/** Return type for session_before_compact handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for session.compacting handlers */
export interface SessionCompactingResult {
/** Additional context lines to include in summary */
context?: string[];
/** Override the default compaction prompt */
prompt?: string;
/** Custom data to store in compaction entry */
preserveData?: Record<string, unknown>;
}
/** Return type for session_before_tree handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
export type {
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
SessionCompactingResult,
} from "../shared-events";
// ============================================================================
// Hook API
@@ -681,6 +453,9 @@ export type HookMessageRenderer<T = unknown> = (
/**
* Command registration options.
*/
// fallow-ignore-next-line code-duplication
// Parallel to extensions' RegisteredCommand: hooks bind to
// HookCommandContext and have no argument-completion hook.
export interface RegisteredCommand {
name: string;
description?: string;
@@ -0,0 +1,335 @@
/**
* Event payload and result shapes shared between the extensions and hooks
* subsystems.
*
* Both subsystems observe the same agent/session lifecycle, so the *event*
* payloads (what happened) and the simpler *result* shapes (handler return
* values that don't depend on subsystem-specific identifiers like
* `AgentMessage` vs `Message`) are intentionally identical.
*
* Anything that diverges between the two subsystems — UI context, runtime
* context, command context, tool-call discrimination, or return shapes that
* carry subsystem-specific message types — lives in the per-subsystem
* `types.ts` files and is documented there.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { Rule } from "../capability/rule";
import type { CompactionPreparation, CompactionResult } from "../session/compaction";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-manager";
import type { TodoItem } from "../tools/todo-write";
// ============================================================================
// Session Events
// ============================================================================
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we're switching to (only for "resume") */
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume" | "fork";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - handlers should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired before compaction summarization to customize prompts/context */
export interface SessionCompactingEvent {
type: "session.compacting";
sessionId: string;
messages: AgentMessage[];
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by an extension/hook */
fromExtension: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from extension/hook */
fromExtension?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactingEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
// ============================================================================
// Agent / Turn Events
// ============================================================================
/**
* Fired before each LLM call.
*
* Original session messages are NOT modified - only the messages sent to the
* LLM are affected when a handler returns a replacement (the return shape
* differs between extensions and hooks; see each subsystem's
* `ContextEventResult`).
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
/**
* Fired when an agent loop starts (once per user prompt).
*/
export interface AgentStartEvent {
type: "agent_start";
}
/** Fired when an agent loop ends */
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/** Fired at the start of each turn */
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/** Fired at the end of each turn */
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
// ============================================================================
// Auto-compaction / Auto-retry Events
// ============================================================================
/** Fired when auto-compaction starts */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
/** Fired when auto-compaction ends */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
action: "context-full" | "handoff";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
skipped?: boolean;
}
/** Fired when auto-retry starts */
export interface AutoRetryStartEvent {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
/** Fired when auto-retry ends */
export interface AutoRetryEndEvent {
type: "auto_retry_end";
success: boolean;
attempt: number;
finalError?: string;
}
// ============================================================================
// TTSR / Todo Reminders
// ============================================================================
/** Fired when TTSR rule matching interrupts generation */
export interface TtsrTriggeredEvent {
type: "ttsr_triggered";
rules: Rule[];
}
/** Fired when todo reminder logic detects unfinished todos */
export interface TodoReminderEvent {
type: "todo_reminder";
todos: TodoItem[];
attempt: number;
maxAttempts: number;
}
// ============================================================================
// Shared Event Result Shapes
// ============================================================================
/**
* Return type for `tool_call` handlers.
* Allows handlers to block tool execution.
*/
export interface ToolCallEventResult {
/** If true, block the tool from executing */
block?: boolean;
/** Reason for blocking (returned to LLM as error) */
reason?: string;
}
/**
* Return type for `tool_result` handlers.
* Allows handlers to modify tool results.
*/
export interface ToolResultEventResult {
/** Replacement content array (text and images) */
content?: (TextContent | ImageContent)[];
/** Replacement details */
details?: unknown;
/** Override isError flag */
isError?: boolean;
}
/** Return type for `session_before_switch` handlers */
export interface SessionBeforeSwitchResult {
/** If true, cancel the switch */
cancel?: boolean;
}
/** Return type for `session_before_branch` handlers */
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint handler that restores code state separately.
* The handler handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` → nothing happens, user stays in current session
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
* - neither → branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
}
/** Return type for `session_before_compact` handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for `session.compacting` handlers */
export interface SessionCompactingResult {
/** Additional context lines to include in summary */
context?: string[];
/** Override the default compaction prompt */
prompt?: string;
/** Custom data to store in compaction entry */
preserveData?: Record<string, unknown>;
}
/** Return type for `session_before_tree` handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
@@ -14,6 +14,12 @@ export interface Skill {
filePath: string;
baseDir: string;
source: string;
/**
* When `true`, the skill is loaded and reachable via `skill://<name>` and
* (when enabled) `/skill:<name>`, but is excluded from the rendered system
* prompt's `<skills>` listing.
*/
hide?: boolean;
/** Source metadata for display */
_source?: SourceMeta;
}
@@ -76,6 +82,7 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
filePath: capSkill.path,
baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
source: options.source,
hide: capSkill.frontmatter?.hide === true,
_source: capSkill._source,
})),
warnings: (result.warnings ?? []).map(message => ({ skillPath: options.dir, message })),
@@ -190,6 +197,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
filePath: capSkill.path,
baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
source: `${capSkill._source.provider}:${capSkill.level}`,
hide: capSkill.frontmatter?.hide === true,
_source: capSkill._source,
});
realPathSet.add(resolvedPath);
@@ -226,6 +234,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
filePath: capSkill.path,
baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
source: "custom:user",
hide: capSkill.frontmatter?.hide === true,
_source: { ...capSkill._source, providerName: "Custom" },
},
path: capSkill.path,
@@ -379,4 +379,4 @@ export const MENTAL_MODEL_FIRST_TURN_DEADLINE_MS = 1500;
export const MENTAL_MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
/** Need-only export of the raw seed list for tests. */
export const __builtinSeedsForTest: ReadonlyArray<Readonly<RawSeed>> = BUILTIN_SEEDS;
export const builtinSeedsForTest: ReadonlyArray<Readonly<RawSeed>> = BUILTIN_SEEDS;
@@ -14,29 +14,10 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { applyQuery, pathToQuery } from "./json-query";
import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
/**
* Snapshot of artifacts dirs for every registered session, deduped.
*
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt the
* parent's manager and report the parent's dir there; dedup then collapses
* the whole agent tree to one entry. Falls back to the raw session file
* when no live session reference is attached.
*/
function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
/**
* Handler for agent:// URLs.
*
@@ -12,27 +12,9 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
/**
* Snapshot of artifacts dirs across all registered sessions, deduped.
*
* Subagents adopt their parent's `ArtifactManager`, so their
* `sessionManager.getArtifactsDir()` returns the parent's dir; dedup
* collapses parent + N subagents to a single entry.
*/
function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
export class ArtifactProtocolHandler implements ProtocolHandler {
readonly scheme = "artifact";
readonly immutable = true;
@@ -0,0 +1,25 @@
/**
* Shared helpers for internal-url protocol handlers that resolve IDs against
* registered agent sessions.
*/
import { AgentRegistry } from "../registry/agent-registry";
/**
* Snapshot of artifacts dirs for every registered session, deduped.
*
* Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
* parent's `ArtifactManager` and report the parent's dir there; dedup then
* collapses parent + N subagents (the whole agent tree) to one entry. Falls
* back to the raw session file (with the `.jsonl` suffix stripped) when no
* live session reference is attached.
*/
export function artifactsDirsFromRegistry(): string[] {
const dirs: string[] = [];
for (const ref of AgentRegistry.global().list()) {
const dir =
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
if (!dir) continue;
if (!dirs.includes(dir)) dirs.push(dir);
}
return dirs;
}
@@ -133,6 +133,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
this.#resolvedClientId = this.#resolveClientId(config);
}
/**
* Client id used during the authorization request. Returns the value supplied
* via {@link MCPOAuthConfig.clientId} or, when the server required dynamic
* client registration, the id issued during registration. `undefined` until
* {@link generateAuthUrl} (or {@link login}) has run for a server that needs
* a client id.
*/
get resolvedClientId(): string | undefined {
return this.#resolvedClientId;
}
/**
* Client secret issued by dynamic client registration, if any. Always
* `undefined` for PKCE-only/public clients and when the caller supplies the
* client id via config.
*/
get registeredClientSecret(): string | undefined {
return this.#registeredClientSecret;
}
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
if (!this.#resolvedClientId) {
await this.#tryRegisterClient(redirectUri);
@@ -73,6 +73,15 @@ const MODEL_CONFIG_ID = "model";
const THINKING_CONFIG_ID = "thinking";
const THINKING_OFF = "off";
const SESSION_PAGE_SIZE = 50;
/**
* Delay between `session/new` (or `session/load` / `session/resume` /
* `unstable_session/fork`) returning and the agent firing the first
* notifications against the new session id. Mitigates Zed's
* `Received session notification for unknown session` race — see
* `#scheduleBootstrapUpdates`. Exported so the ACP test harness can
* wait past this guard without hard-coding the literal.
*/
export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
type AgentImageContent = {
type: "image";
@@ -97,6 +106,9 @@ type ManagedSessionRecord = {
liveMessageId: string | undefined;
liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
extensionsConfigured: boolean;
// Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
// in `#disposeSessionRecord`. Lives independent of any prompt turn.
lifetimeUnsubscribe: (() => void) | undefined;
};
type ReplayableMessage = {
@@ -314,13 +326,7 @@ export class AcpAgent implements Agent {
sessionId: record.session.sessionId,
update: this.#buildCurrentModeUpdate(record.session),
});
await this.#connection.sessionUpdate({
sessionId: record.session.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: this.#buildConfigOptions(record.session),
},
});
await this.#pushConfigOptionUpdate(record);
return {};
}
@@ -354,27 +360,21 @@ export class AcpAgent implements Agent {
});
}
const configOptions = this.#buildConfigOptions(record.session);
await this.#connection.sessionUpdate({
sessionId: record.session.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions,
},
});
return { configOptions };
// For `thinking` the lifetime subscription pushes post-bootstrap; only
// push here when it's not yet installed so pre-bootstrap callers still
// see the change without a post-bootstrap duplicate.
const thinkingHandledBySubscription =
params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined;
if (!thinkingHandledBySubscription) {
await this.#pushConfigOptionUpdate(record);
}
return { configOptions: this.#buildConfigOptions(record.session) };
}
async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
const record = this.#getSessionRecord(params.sessionId);
await this.#setModelById(record.session, params.modelId);
await this.#connection.sessionUpdate({
sessionId: record.session.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: this.#buildConfigOptions(record.session),
},
});
await this.#pushConfigOptionUpdate(record);
return {};
}
@@ -432,13 +432,7 @@ export class AcpAgent implements Agent {
});
},
notifyConfigChanged: async () => {
await this.#connection.sessionUpdate({
sessionId: record.session.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: this.#buildConfigOptions(record.session),
},
});
await this.#pushConfigOptionUpdate(record);
},
});
if (builtinResult !== false) {
@@ -688,6 +682,8 @@ export class AcpAgent implements Agent {
async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
const record = this.#createManagedSessionRecord(session);
session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities));
// `record.lifetimeUnsubscribe` is installed in `#scheduleBootstrapUpdates`
// so it shares the bootstrap race guard — see that comment for why.
try {
await this.#configureExtensions(record);
await this.#configureMcpServers(record, mcpServers);
@@ -707,9 +703,24 @@ export class AcpAgent implements Agent {
liveMessageId: undefined,
liveMessageProgress: undefined,
extensionsConfigured: false,
lifetimeUnsubscribe: undefined,
};
}
async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
if (event.type !== "thinking_level_changed") {
return;
}
try {
await this.#pushConfigOptionUpdate(record);
} catch (error) {
logger.warn("Failed to push thinking-level config_option_update", {
sessionId: record.session.sessionId,
error,
});
}
}
#getSessionRecord(sessionId: string): ManagedSessionRecord {
const record = this.#sessions.get(sessionId);
if (!record) {
@@ -912,6 +923,16 @@ export class AcpAgent implements Agent {
};
}
async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise<void> {
await this.#connection.sessionUpdate({
sessionId: record.session.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: this.#buildConfigOptions(record.session),
},
});
}
#buildConfigOptions(session: AgentSession): SessionConfigOption[] {
const currentModeId = this.#getCurrentModeId(session);
const modeOptions = this.#getAvailableModes(session).map(mode => ({
@@ -1124,18 +1145,25 @@ export class AcpAgent implements Agent {
}
#scheduleBootstrapUpdates(sessionId: string): void {
// Delay the bootstrap so the client has time to handle the `session/new`
// (or `session/load` / `session/resume`) RPC response and register the
// new sessionId before we start firing notifications against it. Zed's
// agent-client-protocol reader dispatches responses and notifications
// to different async tasks; sending the first `available_commands_update`
// from `setTimeout(0)` reliably loses the race against the response
// handler and Zed logs `Received session notification for unknown
// session` then drops the update — leaving the slash-command palette
// empty (#1015 follow-up; see zed-industries/zed#55965 for the same
// race biting other ACP agents). 50ms is invisible to the operator and
// large enough that the response future has scheduled before our timer
// fires on stdio-only transports.
// Defer first notifications until the response has reached the client.
// Zed's agent-client-protocol reader dispatches responses and
// notifications to different async tasks; sending the first
// `available_commands_update` from `setTimeout(0)` reliably loses the
// race against the response handler and Zed logs `Received session
// notification for unknown session` then drops the update — leaving
// the slash-command palette empty (#1015 follow-up; see
// zed-industries/zed#55965 for the same race biting other ACP agents).
// `ACP_BOOTSTRAP_RACE_GUARD_MS` is invisible to the operator and large
// enough that the response future has scheduled before our timer fires
// on stdio-only transports.
//
// The session-lifetime subscription is installed inside the same timer
// so it shares this guard — without it, an extension's `session_start`
// handler (or any async work it schedules) calling `setThinkingLevel`
// would push a `config_option_update` for a session id the client
// hasn't been told about yet. The pre-bootstrap thinking level is
// reported in the response's `configOptions`, so deferring the
// notification loses no state.
setTimeout(() => {
if (this.#connection.signal.aborted) {
return;
@@ -1144,8 +1172,13 @@ export class AcpAgent implements Agent {
if (!record) {
return;
}
if (!record.lifetimeUnsubscribe) {
record.lifetimeUnsubscribe = record.session.subscribe(event => {
void this.#handleLifetimeEvent(record, event);
});
}
void this.#emitBootstrapUpdates(sessionId, record);
}, 50);
}, ACP_BOOTSTRAP_RACE_GUARD_MS);
}
async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise<void> {
@@ -1674,6 +1707,7 @@ export class AcpAgent implements Agent {
}
async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
record.lifetimeUnsubscribe?.();
if (record.mcpManager) {
try {
await record.mcpManager.disconnectAll();
@@ -7,19 +7,23 @@ import {
Container,
Ellipsis,
ImageProtocol,
Loader,
Spacer,
type Loader,
TERMINAL,
Text,
type TUI,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { theme } from "../../modes/theme/theme";
import type { TruncationMeta } from "../../tools/output-meta";
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
import {
buildExecutionFrame,
buildStatusFooter,
createCollapsedPreview,
type ExecutionStatus,
resolveExecutionStatus,
} from "./execution-shared";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;
@@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50;
export class BashExecutionComponent extends Container {
#outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running";
#status: ExecutionStatus = "running";
#exitCode: number | undefined = undefined;
#loader: Loader;
#truncation?: TruncationMeta;
@@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container {
// Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode";
const borderColor = (str: string) => theme.fg(colorKey, str);
// Add spacer
this.addChild(new Spacer(1));
// Top border
this.addChild(new DynamicBorder(borderColor));
// Content container (holds dynamic content between borders)
this.#contentContainer = new Container();
this.addChild(this.#contentContainer);
const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
this.#contentContainer = contentContainer;
this.#loader = loader;
// Command header
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
this.#contentContainer.addChild(this.#headerText);
// Loader
this.#loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
this.#contentContainer.addChild(this.#loader);
// Bottom border
this.addChild(new DynamicBorder(borderColor));
}
/**
@@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container {
options?: { output?: string; truncation?: TruncationMeta },
): void {
this.#exitCode = exitCode;
this.#status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#status = resolveExecutionStatus(exitCode, cancelled);
this.#truncation = options?.truncation;
if (options?.output !== undefined) {
this.#setOutput(options.output);
@@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container {
} else {
// Use shared visual truncation utility, recomputed per render width
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`;
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
}
}
@@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container {
if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader);
} else {
const statusParts: string[] = [];
// Show how many lines are hidden (collapsed preview)
if (hiddenLineCount > 0 && !hasSixelOutput) {
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (this.#status === "cancelled") {
statusParts.push(theme.fg("warning", "(cancelled)"));
} else if (this.#status === "error") {
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
}
if (this.#truncation) {
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
}
if (statusParts.length > 0) {
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
}
const footer = buildStatusFooter({
status: this.#status,
exitCode: this.#exitCode,
truncation: this.#truncation,
hiddenLineCount,
suppressHiddenCount: hasSixelOutput,
});
if (footer) this.#contentContainer.addChild(footer);
}
}
@@ -1,9 +1,9 @@
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
import type { MessageRenderer } from "../../extensibility/extensions/types";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import type { CustomMessage } from "../../session/messages";
import { renderFramedMessage } from "./message-frame";
/**
* Component that renders a custom message entry from extensions.
@@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container {
}
#rebuild(): void {
// Remove previous content component
if (this.#customComponent) {
this.removeChild(this.#customComponent);
this.#customComponent = undefined;
}
this.removeChild(this.#box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
if (component) {
this.#customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
const custom = renderFramedMessage({
message: this.message,
box: this.#box,
expanded: this.#expanded,
customRenderer: this.customRenderer,
// Extension messages render full content; no collapse-on-fold behaviour.
});
// Default rendering uses our box
this.addChild(this.#box);
this.#box.clear();
// Default rendering: label + content
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
this.#box.addChild(new Text(label, 0, 0));
this.#box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
if (custom) {
this.#customComponent = custom;
this.addChild(custom);
} else {
text = this.message.content
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
this.addChild(this.#box);
}
this.#box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (value: string) => theme.fg("customMessageText", value),
}),
);
}
}
@@ -4,11 +4,17 @@
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
import { highlightCode, theme } from "../../modes/theme/theme";
import type { TruncationMeta } from "../../tools/output-meta";
import {
buildExecutionFrame,
buildStatusFooter,
createCollapsedPreview,
type ExecutionColorKey,
type ExecutionStatus,
resolveExecutionStatus,
} from "./execution-shared";
const PREVIEW_LINES = 20;
const MAX_DISPLAY_LINE_CHARS = 4000;
@@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js";
export class EvalExecutionComponent extends Container {
#outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running";
#status: ExecutionStatus = "running";
#exitCode: number | undefined = undefined;
#loader: Loader;
#truncation?: TruncationMeta;
@@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container {
return this.language === "js" ? "javascript" : "python";
}
#formatHeader(colorKey: "dim" | "pythonMode"): Text {
#formatHeader(colorKey: ExecutionColorKey): Text {
const prompt = theme.fg(colorKey, theme.bold(">>>"));
const continuation = theme.fg(colorKey, " ");
const codeLines = highlightCode(this.code, this.#highlightLang());
@@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container {
) {
super();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
const borderColor = (str: string) => theme.fg(colorKey, str);
const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
this.#contentContainer = contentContainer;
this.#loader = loader;
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor));
this.#contentContainer = new Container();
this.addChild(this.#contentContainer);
this.#contentContainer.addChild(this.#formatHeader(colorKey));
this.#loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
this.#contentContainer.addChild(this.#loader);
this.addChild(new DynamicBorder(borderColor));
}
setExpanded(expanded: boolean): void {
@@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container {
options?: { output?: string; truncation?: TruncationMeta },
): void {
this.#exitCode = exitCode;
this.#status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.#status = resolveExecutionStatus(exitCode, cancelled);
this.#truncation = options?.truncation;
if (options?.output !== undefined) {
this.#setOutput(options.output);
@@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container {
this.#contentContainer.clear();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
this.#contentContainer.addChild(this.#formatHeader(colorKey));
if (availableLines.length > 0) {
@@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container {
this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
} else {
const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
const previewText = `\n${styledOutput}`;
this.#contentContainer.addChild({
render: (width: number) => {
const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
return visualLines;
},
invalidate: () => {},
});
this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
}
}
if (this.#status === "running") {
this.#contentContainer.addChild(this.#loader);
} else {
const statusParts: string[] = [];
if (hiddenLineCount > 0) {
statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (this.#status === "cancelled") {
statusParts.push(theme.fg("warning", "(cancelled)"));
} else if (this.#status === "error") {
statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
}
if (this.#truncation) {
statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
}
if (statusParts.length > 0) {
this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
}
const footer = buildStatusFooter({
status: this.#status,
exitCode: this.#exitCode,
truncation: this.#truncation,
hiddenLineCount,
});
if (footer) this.#contentContainer.addChild(footer);
}
}
@@ -0,0 +1,102 @@
/**
* Shared rendering primitives for bash/eval execution components.
*
* Each helper isolates a piece of structure both components share verbatim
* (frame layout, collapsed preview, post-run status line). Differences in
* how each component prepares its header, output lines, or sixel masking
* stay in their respective files.
*/
import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
export type ExecutionStatus = "running" | "complete" | "cancelled" | "error";
/** Theme color keys valid for an execution frame. */
export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode";
/**
* Build the spacer + top border + content container + bottom border scaffold
* that bash and eval execution components share. The caller appends the
* header (command vs `>>>` prompt) and the returned loader to
* `contentContainer` so per-mode order is preserved.
*/
export function buildExecutionFrame(
parent: Container,
ui: TUI,
colorKey: ExecutionColorKey,
): { contentContainer: Container; loader: Loader } {
const borderColor = (str: string) => theme.fg(colorKey, str);
parent.addChild(new Spacer(1));
parent.addChild(new DynamicBorder(borderColor));
const contentContainer = new Container();
parent.addChild(contentContainer);
const loader = new Loader(
ui,
spinner => theme.fg(colorKey, spinner),
text => theme.fg("muted", text),
`Running… (esc to cancel)`,
getSymbolTheme().spinnerFrames,
);
parent.addChild(new DynamicBorder(borderColor));
return { contentContainer, loader };
}
/**
* Wrap a styled preview block in a render-time visual-line truncator.
* Recomputed per render width so wrapping stays in sync with terminal size.
*/
export function createCollapsedPreview(previewText: string, previewLines: number): Component {
return {
render: (width: number) => truncateToVisualLines(previewText, previewLines, width, 1).visualLines,
invalidate: () => {},
};
}
/**
* Build the post-run status block (hidden-line hint, exit/cancel marker,
* truncation notice). Returns undefined when there is nothing to display so
* callers can skip appending a stray Text child.
*/
export function buildStatusFooter(opts: {
status: ExecutionStatus;
exitCode: number | undefined;
truncation: TruncationMeta | undefined;
hiddenLineCount: number;
/** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */
suppressHiddenCount?: boolean;
}): Text | undefined {
const parts: string[] = [];
if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) {
parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`));
}
if (opts.status === "cancelled") {
parts.push(theme.fg("warning", "(cancelled)"));
} else if (opts.status === "error") {
parts.push(theme.fg("error", `(exit ${opts.exitCode})`));
}
if (opts.truncation) {
parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation)));
}
if (parts.length === 0) return undefined;
return new Text(`\n${parts.join("\n")}`, 1, 0);
}
/**
* Derive the post-run status from an exit code + cancellation flag using the
* same precedence both execution components apply.
*/
export function resolveExecutionStatus(exitCode: number | undefined, cancelled: boolean): ExecutionStatus {
if (cancelled) return "cancelled";
if (exitCode !== 0 && exitCode !== undefined && exitCode !== null) return "error";
return "complete";
}
@@ -1,9 +1,12 @@
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
import type { HookMessageRenderer } from "../../extensibility/hooks/types";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import type { HookMessage } from "../../session/messages";
import { renderFramedMessage } from "./message-frame";
/** Lines of default markdown body shown before the "…" fold when collapsed. */
const HOOK_COLLAPSED_LINES = 5;
/**
* Component that renders a custom message entry from hooks.
@@ -41,60 +44,25 @@ export class HookMessageComponent extends Container {
}
#rebuild(): void {
// Remove previous content component
if (this.#customComponent) {
this.removeChild(this.#customComponent);
this.#customComponent = undefined;
}
this.removeChild(this.#box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
if (component) {
// Custom renderer provides its own styled component
this.#customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
const custom = renderFramedMessage({
message: this.message,
box: this.#box,
expanded: this.#expanded,
customRenderer: this.customRenderer,
collapseAfterLines: HOOK_COLLAPSED_LINES,
});
// Default rendering uses our box
this.addChild(this.#box);
this.#box.clear();
// Default rendering: label + content
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
this.#box.addChild(new Text(label, 0, 0));
this.#box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
if (custom) {
this.#customComponent = custom;
this.addChild(custom);
} else {
text = this.message.content
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
this.addChild(this.#box);
}
// Limit lines when collapsed
if (!this.#expanded) {
const lines = text.split("\n");
if (lines.length > 5) {
text = `${lines.slice(0, 5).join("\n")}\n…`;
}
}
this.#box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
}
}
@@ -47,6 +47,18 @@ type WizardStep =
| "scope"
| "confirm";
/**
* Result of the wizard's OAuth callback. `credentialId` is mandatory;
* `clientId`/`clientSecret` are populated when the OAuth provider performed
* dynamic client registration (or when the caller pre-supplied them) so the
* wizard can fold them into the final `mcp.json` entry for refresh.
*/
export interface MCPAddWizardOAuthResult {
credentialId: string;
clientId?: string;
clientSecret?: string;
}
interface WizardState {
name: string;
transport: TransportType | null;
@@ -104,7 +116,13 @@ export class MCPAddWizard extends Container {
#onCompleteCallback: (name: string, config: MCPServerConfig, scope: Scope) => void;
#onCancelCallback: () => void;
#onOAuthCallback:
| ((authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string) => Promise<string>)
| ((
authUrl: string,
tokenUrl: string,
clientId: string,
clientSecret: string,
scopes: string,
) => Promise<MCPAddWizardOAuthResult>)
| null = null;
#onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
#onRenderCallback: (() => void) | null = null;
@@ -118,7 +136,7 @@ export class MCPAddWizard extends Container {
clientId: string,
clientSecret: string,
scopes: string,
) => Promise<string>,
) => Promise<MCPAddWizardOAuthResult>,
onTestConnection?: (config: MCPServerConfig) => Promise<void>,
onRender?: () => void,
initialName?: string,
@@ -1120,7 +1138,7 @@ export class MCPAddWizard extends Container {
try {
// Call OAuth handler
const credentialId = await this.#onOAuthCallback(
const oauthResult = await this.#onOAuthCallback(
this.#state.oauthAuthUrl,
this.#state.oauthTokenUrl,
this.#state.oauthClientId,
@@ -1128,8 +1146,11 @@ export class MCPAddWizard extends Container {
this.#state.oauthScopes,
);
// Store credential ID
this.#state.oauthCredentialId = credentialId;
// Store credential ID + any dynamically-registered client credentials,
// so the final mcp.json entry persists everything needed for refresh.
this.#state.oauthCredentialId = oauthResult.credentialId;
if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
// Show success message
this.#contentContainer.clear();
@@ -0,0 +1,88 @@
/**
* Shared rendering for extension/hook custom message frames.
*
* Both `CustomMessageComponent` and `HookMessageComponent` wrap a
* `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall
* back to a label + markdown body when the renderer returns nothing or
* throws. The only meaningful difference is that hook messages collapse to
* the first N lines when not expanded; extension messages render in full.
*/
import type { TextContent } from "@oh-my-pi/pi-ai";
import type { Box, Component } from "@oh-my-pi/pi-tui";
import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme";
/** Message shape consumed by the shared frame. */
export interface FramedMessage {
customType: string;
content: string | (TextContent | { type: string })[];
}
/**
* Callable signature shared by `MessageRenderer` (extensions) and
* `HookMessageRenderer` (hooks). Both narrow `message` to their own type;
* this signature is the structural intersection callers can hand off here.
*/
export type FramedRenderer<M extends FramedMessage> = (
message: M,
options: { expanded: boolean },
theme: Theme,
) => Component | undefined;
export interface RebuildFrameOptions<M extends FramedMessage> {
message: M;
box: Box;
expanded: boolean;
/** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
collapseAfterLines?: number;
customRenderer?: FramedRenderer<M>;
}
/**
* Attempt the custom renderer; on failure or undefined return, populate
* `box` with the default `[customType]` label + markdown body and return
* undefined. When the custom renderer succeeds, return its Component so the
* caller can mount it and skip the default box.
*/
export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
if (opts.customRenderer) {
try {
const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme);
if (component) return component;
} catch {
// Fall through to default rendering
}
}
opts.box.clear();
const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`));
opts.box.addChild(new Text(label, 0, 0));
opts.box.addChild(new Spacer(1));
let text: string;
if (typeof opts.message.content === "string") {
text = opts.message.content;
} else {
text = opts.message.content
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
}
if (!opts.expanded && opts.collapseAfterLines !== undefined) {
const lines = text.split("\n");
if (lines.length > opts.collapseAfterLines) {
text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`;
}
}
opts.box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (value: string) => theme.fg("customMessageText", value),
}),
);
return undefined;
}
@@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
import {
Container,
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
@@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver";
import type { Settings } from "../../config/settings";
import { type ThemeColor, theme } from "../../modes/theme/theme";
import { getThinkingLevelMetadata } from "../../thinking";
import { fuzzyFilter } from "../../utils/fuzzy";
import { getTabBarTheme } from "../shared";
import { DynamicBorder } from "./dynamic-border";
@@ -1,6 +1,7 @@
import {
type Component,
Container,
fuzzyFilter,
Input,
matchesKey,
padding,
@@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { SessionInfo } from "../../session/session-manager";
import { fuzzyFilter } from "../../utils/fuzzy";
import { DynamicBorder } from "./dynamic-border";
import { HookSelectorComponent } from "./hook-selector";
@@ -0,0 +1,108 @@
/**
* Shared helpers for /mcp and /ssh command controllers.
*
* Captures argument parsing, source grouping, and chat-message rendering that
* was duplicated between mcp-command-controller and ssh-command-controller.
* Intentionally kept narrow: subcommand routing, help text, success/error
* wording, and add-flow logic stay in the per-controller files because they
* diverge in workflow.
*/
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import type { SourceMeta } from "../../capability/types";
import { shortenPath } from "../../tools/render-utils";
import { DynamicBorder } from "../components/dynamic-border";
import { parseCommandArgs } from "../shared";
import type { InteractiveModeContext } from "../types";
export type ScopeValue = "project" | "user";
export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string };
/**
* Validate the value following a `--scope` flag.
*/
export function readScopeFlag(value: string | undefined): ScopeFlagResult {
if (!value || (value !== "project" && value !== "user")) {
return { ok: false, error: "Invalid --scope value. Use project or user." };
}
return { ok: true, scope: value };
}
export type RemoveArgs = { name: string | undefined; scope: ScopeValue };
export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string };
/**
* Parse the argument tail of `/<cmd> remove <name> [--scope project|user]`.
*
* `rest` is the text after the subcommand keyword. The caller is responsible
* for emitting the command-specific "<entity> name required" usage hint when
* `value.name` is undefined.
*/
export function parseRemoveArgs(rest: string): ParseRemoveResult {
const tokens = parseCommandArgs(rest);
let name: string | undefined;
let scope: ScopeValue = "project";
let i = 0;
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
name = tokens[0];
i = 1;
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--scope") {
const r = readScopeFlag(tokens[i + 1]);
if (!r.ok) return { ok: false, error: r.error };
scope = r.scope;
i += 2;
continue;
}
return { ok: false, error: `Unknown option: ${token}` };
}
return { ok: true, value: { name, scope } };
}
/**
* Group capability-loaded items by their source provider+path, yielding each
* group with a display-ready `shortPath`.
*/
export function* groupBySource<T>(
items: Iterable<T>,
getSource: (item: T) => SourceMeta,
): Iterable<{ providerName: string; shortPath: string; items: T[] }> {
const groups = new Map<string, T[]>();
for (const item of items) {
const src = getSource(item);
const key = `${src.providerName}|${src.path}`;
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
group.push(item);
}
for (const [key, grouped] of groups) {
const sepIdx = key.indexOf("|");
yield {
providerName: key.slice(0, sepIdx),
shortPath: shortenPath(key.slice(sepIdx + 1)),
items: grouped,
};
}
}
/**
* Render a message block (DynamicBorder / Text / DynamicBorder) into the chat
* container and request a render.
*/
export function showCommandMessage(ctx: InteractiveModeContext, text: string): void {
ctx.chatContainer.addChild(new Spacer(1));
ctx.chatContainer.addChild(new DynamicBorder());
ctx.chatContainer.addChild(new Text(text, 1, 1));
ctx.chatContainer.addChild(new DynamicBorder());
ctx.ui.requestRender();
}
@@ -256,12 +256,21 @@ export class CommandController {
}
#copyLastMessage() {
const text = this.ctx.session.getLastAssistantText();
if (!text) {
this.ctx.showError("No agent messages to copy yet.");
const assistantText = this.ctx.session.getLastAssistantText();
if (assistantText) {
this.#doCopy(assistantText, "Copied last agent message to clipboard");
return;
}
this.#doCopy(text, "Copied last agent message to clipboard");
if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
const handoffText = this.ctx.session.getLastVisibleHandoffText();
if (handoffText) {
this.#doCopy(handoffText, "Copied handoff context to clipboard");
return;
}
}
this.ctx.showError("No agent messages to copy yet.");
}
#copyCode() {
@@ -61,6 +61,7 @@ export class EventController {
todo_auto_clear: e => this.#handleTodoAutoClear(e),
irc_message: e => this.#handleIrcMessage(e),
notice: e => this.#handleNotice(e),
thinking_level_changed: async () => {},
} satisfies AgentSessionEventHandlers;
}
@@ -37,11 +37,11 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
import type { OAuthCredential } from "../../session/auth-storage";
import { shortenPath } from "../../tools/render-utils";
import { openPath } from "../../utils/open";
import { DynamicBorder } from "../components/dynamic-border";
import { MCPAddWizard } from "../components/mcp-add-wizard";
import { parseCommandArgs } from "../shared";
import { theme } from "../theme/theme";
import type { InteractiveModeContext } from "../types";
import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
@@ -49,6 +49,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
}
/**
* Outcome of {@link MCPCommandController}'s OAuth handler.
*
* `clientId`/`clientSecret` are populated when the OAuth provider required (or
* accepted) dynamic client registration; callers MUST persist them alongside
* `credentialId` so subsequent token refreshes and reauthorizations can reuse
* the same registered client. Both are also set when the caller pre-supplied a
* client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
* write-back is a no-op.
*/
interface OAuthFlowResult {
credentialId: string;
clientId?: string;
clientSecret?: string;
}
type MCPAddScope = "user" | "project";
type MCPAddTransport = "http" | "sse";
@@ -207,11 +223,11 @@ export class MCPCommandController {
break;
}
if (argToken === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
return { scope, error: "Invalid --scope value. Use project or user." };
const r = readScopeFlag(tokens[i + 1]);
if (!r.ok) {
return { scope, error: r.error };
}
scope = value;
scope = r.scope;
i += 2;
continue;
}
@@ -406,7 +422,7 @@ export class MCPCommandController {
try {
const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
const credentialId = await this.#handleOAuthFlow(
const oauthResult = await this.#handleOAuthFlow(
oauth.authorizationUrl,
oauth.tokenUrl,
oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
@@ -416,14 +432,21 @@ export class MCPCommandController {
finalConfig.oauth?.callbackPath,
finalConfig.oauth?.redirectUri,
);
const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
finalConfig = {
...finalConfig,
auth: {
type: "oauth",
credentialId,
credentialId: oauthResult.credentialId,
tokenUrl: oauth.tokenUrl,
clientId: oauth.clientId ?? finalConfig.oauth?.clientId,
clientSecret: finalConfig.oauth?.clientSecret,
clientId: persistedClientId,
clientSecret: persistedClientSecret,
},
oauth: {
...finalConfig.oauth,
clientId: persistedClientId ?? finalConfig.oauth?.clientId,
clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
},
};
} catch (oauthError) {
@@ -488,7 +511,7 @@ export class MCPCommandController {
callbackPort?: number,
callbackPath?: string,
redirectUri?: string,
): Promise<string> {
): Promise<OAuthFlowResult> {
const authStorage = this.ctx.session.modelRegistry.authStorage;
let parsedAuthUrl: URL;
@@ -600,7 +623,11 @@ export class MCPCommandController {
// Store under a synthetic provider name
await authStorage.set(credentialId, oauthCredential);
return credentialId;
return {
credentialId,
clientId: flow.resolvedClientId,
clientSecret: flow.registeredClientSecret,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -984,23 +1011,7 @@ export class MCPCommandController {
// Show discovered servers (from .claude.json, .cursor/mcp.json, .vscode/mcp.json, etc.)
if (discoveredServers.length > 0) {
// Group by source display name + path
const bySource = new Map<string, typeof discoveredServers>();
for (const entry of discoveredServers) {
const key = `${entry.source.providerName}|${entry.source.path}`;
let group = bySource.get(key);
if (!group) {
group = [];
bySource.set(key, group);
}
group.push(entry);
}
for (const [key, entries] of bySource) {
const sepIdx = key.indexOf("|");
const providerName = key.slice(0, sepIdx);
const sourcePath = key.slice(sepIdx + 1);
const shortPath = shortenPath(sourcePath);
for (const { providerName, shortPath, items: entries } of groupBySource(discoveredServers, e => e.source)) {
lines.push(theme.fg("accent", providerName) + theme.fg("muted", ` (${shortPath}):`));
for (const { name } of entries) {
const state = this.ctx.mcpManager!.getConnectionStatus(name);
@@ -1037,32 +1048,12 @@ export class MCPCommandController {
async #handleRemove(text: string): Promise<void> {
const match = text.match(/^\/mcp\s+(?:remove|rm)\b\s*(.*)$/i);
const rest = match?.[1]?.trim() ?? "";
const tokens = parseCommandArgs(rest);
let name: string | undefined;
let scope: "project" | "user" = "project";
let i = 0;
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
name = tokens[0];
i = 1;
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
this.ctx.showError("Invalid --scope value. Use project or user.");
return;
}
scope = value;
i += 2;
continue;
}
this.ctx.showError(`Unknown option: ${token}`);
const parsed = parseRemoveArgs(rest);
if (!parsed.ok) {
this.ctx.showError(parsed.error);
return;
}
const { name, scope } = parsed.value;
if (!name) {
this.ctx.showError("Server name required. Usage: /mcp remove <name> [--scope project|user]");
@@ -1348,7 +1339,7 @@ export class MCPCommandController {
this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
const credentialId = await this.#handleOAuthFlow(
const oauthResult = await this.#handleOAuthFlow(
oauth.authorizationUrl,
oauth.tokenUrl,
oauth.clientId ?? found.config.oauth?.clientId ?? "",
@@ -1359,14 +1350,22 @@ export class MCPCommandController {
found.config.oauth?.redirectUri,
);
const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
const updated: MCPServerConfig = {
...baseConfig,
auth: {
type: "oauth",
credentialId,
credentialId: oauthResult.credentialId,
tokenUrl: oauth.tokenUrl,
clientId: oauth.clientId ?? found.config.oauth?.clientId,
clientSecret: oauthClientSecret || undefined,
clientId: persistedClientId,
clientSecret: persistedClientSecret,
},
oauth: {
...found.config.oauth,
clientId: persistedClientId ?? found.config.oauth?.clientId,
clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
},
};
await updateMCPServer(found.filePath, name, updated);
@@ -1929,10 +1928,6 @@ export class MCPCommandController {
* Show a message in the chat
*/
#showMessage(text: string): void {
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(text, 1, 1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.ui.requestRender();
showCommandMessage(this.ctx, text);
}
}
@@ -3,18 +3,20 @@
*
* Handles /ssh subcommands for managing SSH host configurations.
*/
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import { getProjectDir, getSSHConfigPath } from "@oh-my-pi/pi-utils";
import { type SSHHost, sshCapability } from "../../capability/ssh";
import { loadCapability } from "../../discovery";
import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer";
import { shortenPath } from "../../tools/render-utils";
import { DynamicBorder } from "../components/dynamic-border";
import { parseCommandArgs } from "../shared";
import { theme } from "../theme/theme";
import type { InteractiveModeContext } from "../types";
type SSHAddScope = "user" | "project";
import {
groupBySource,
parseRemoveArgs,
readScopeFlag,
type ScopeValue,
showCommandMessage,
} from "./command-controller-shared";
export class SSHCommandController {
constructor(private ctx: InteractiveModeContext) {}
@@ -90,7 +92,7 @@ export class SSHCommandController {
}
let name: string | undefined;
let scope: SSHAddScope = "project";
let scope: ScopeValue = "project";
let host: string | undefined;
let username: string | undefined;
let port: number | undefined;
@@ -167,12 +169,12 @@ export class SSHCommandController {
continue;
}
if (argToken === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
this.ctx.showError("Invalid --scope value. Use project or user.");
const r = readScopeFlag(tokens[i + 1]);
if (!r.ok) {
this.ctx.showError(r.error);
return;
}
scope = value;
scope = r.scope;
i += 2;
continue;
}
@@ -300,23 +302,7 @@ export class SSHCommandController {
// Show discovered hosts (from ssh.json, .ssh.json in project root, etc.)
if (discoveredHosts.length > 0) {
// Group by source
const bySource = new Map<string, SSHHost[]>();
for (const host of discoveredHosts) {
const key = `${host._source.providerName}|${host._source.path}`;
let group = bySource.get(key);
if (!group) {
group = [];
bySource.set(key, group);
}
group.push(host);
}
for (const [key, hosts] of bySource) {
const sepIdx = key.indexOf("|");
const providerName = key.slice(0, sepIdx);
const sourcePath = key.slice(sepIdx + 1);
const shortPath = shortenPath(sourcePath);
for (const { providerName, shortPath, items: hosts } of groupBySource(discoveredHosts, h => h._source)) {
lines.push(
theme.fg("accent", "Discovered") +
theme.fg("muted", ` (${providerName}: ${shortPath}):`) +
@@ -357,33 +343,12 @@ export class SSHCommandController {
async #handleRemove(text: string): Promise<void> {
const match = text.match(/^\/ssh\s+(?:remove|rm)\b\s*(.*)$/i);
const rest = match?.[1]?.trim() ?? "";
const tokens = parseCommandArgs(rest);
let name: string | undefined;
let scope: "project" | "user" = "project";
let i = 0;
if (tokens.length > 0 && !tokens[0].startsWith("-")) {
name = tokens[0];
i = 1;
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--scope") {
const value = tokens[i + 1];
if (!value || (value !== "project" && value !== "user")) {
this.ctx.showError("Invalid --scope value. Use project or user.");
return;
}
scope = value;
i += 2;
continue;
}
this.ctx.showError(`Unknown option: ${token}`);
const parsed = parseRemoveArgs(rest);
if (!parsed.ok) {
this.ctx.showError(parsed.error);
return;
}
const { name, scope } = parsed.value;
if (!name) {
this.ctx.showError("Host name required. Usage: /ssh remove <name> [--scope project|user]");
return;
@@ -412,10 +377,6 @@ export class SSHCommandController {
* Show a message in the chat
*/
#showMessage(text: string): void {
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(text, 1, 1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.ui.requestRender();
showCommandMessage(this.ctx, text);
}
}
+10 -84
View File
@@ -7,8 +7,8 @@
*/
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import type { AgentSession } from "../session/agent-session";
import { initializeExtensions } from "./runtime-init";
/**
* Options for print mode.
@@ -39,90 +39,16 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
}
}
// Set up extensions for print mode (no UI, no command context)
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch(e => {
process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`);
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
process.stderr.write(
`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`,
);
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
getContextUsage: () => session.getContextUsage(),
waitForIdle: () => session.agent.waitForIdle(),
newSession: async options => {
const success = await session.newSession({ parentSession: options?.parentSession });
if (success && options?.setup) {
await options.setup(session.sessionManager);
}
return { cancelled: !success };
},
branch: async entryId => {
const result = await session.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// No UI context
);
extensionRunner.onError(err => {
await initializeExtensions(session, {
reportSendError: (action, err) => {
process.stderr.write(
`Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
);
},
reportRuntimeError: err => {
process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`);
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
}
},
});
// Always subscribe to enable session persistence via _handleAgentEvent
session.subscribe(event => {
+14 -87
View File
@@ -17,9 +17,9 @@ import type {
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../extensibility/extensions";
import { runExtensionCompact, runExtensionSetModel } from "../../extensibility/extensions/compact-handler";
import { type Theme, theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import { initializeExtensions } from "../runtime-init";
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
import type {
RpcCommand,
@@ -421,91 +421,18 @@ export async function runRpcMode(
setToolUIContext?.(rpcUiContext, true);
// Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch(e => {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
// ExtensionContextActions
{
getModel: () => session.agent.state.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {
shutdownState.requested = true;
},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
getContextUsage: () => session.getContextUsage(),
waitForIdle: () => session.agent.waitForIdle(),
newSession: async options => {
const success = await session.newSession({ parentSession: options?.parentSession });
// Note: setup callback runs but no UI feedback in RPC mode
if (success && options?.setup) {
await options.setup(session.sessionManager);
}
return { cancelled: !success };
},
branch: async entryId => {
const result = await session.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
rpcUiContext,
);
extensionRunner.onError(err => {
await initializeExtensions(session, {
reportSendError: (action, err) => {
output(error(undefined, action, err.message));
},
reportRuntimeError: err => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
}
},
onShutdown: () => {
shutdownState.requested = true;
},
uiContext: rpcUiContext,
});
// Output all agent events as JSON
session.subscribe(event => {
@@ -850,8 +777,8 @@ export async function runRpcMode(
async function checkShutdownRequested(): Promise<void> {
if (!shutdownState.requested) return;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" });
if (session.extensionRunner?.hasHandlers("session_shutdown")) {
await session.extensionRunner.emit({ type: "session_shutdown" });
}
process.exit(0);
@@ -0,0 +1,115 @@
/**
* Shared extension runtime wiring for print and RPC modes.
*
* Both modes initialize the extension runner with the same action handlers
* that delegate to the {@link AgentSession}. Only error reporting, shutdown
* behavior, and UI context differ between callers — those stay as
* caller-supplied hooks.
*/
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
import type { AgentSession } from "../session/agent-session";
/** Action name for an extension-originated send failure. */
export type ExtensionSendAction = "extension_send" | "extension_send_user";
export interface InitializeExtensionsOptions {
/** Reports an error thrown by an extension-initiated send. */
reportSendError: (action: ExtensionSendAction, error: Error) => void;
/** Reports a runtime error surfaced through {@link ExtensionRunner.onError}. */
reportRuntimeError: (error: ExtensionError) => void;
/** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
onShutdown?: () => void;
/** Optional UI context (rpc supplies one; print runs headless). */
uiContext?: ExtensionUIContext;
}
/**
* Initialize the session's extension runner with the standard action set
* shared by non-interactive modes, then emit `session_start`.
*
* No-op when the session was constructed without an extension runner.
*/
export async function initializeExtensions(session: AgentSession, options: InitializeExtensionsOptions): Promise<void> {
const runner = session.extensionRunner;
if (!runner) return;
const { reportSendError, reportRuntimeError, onShutdown, uiContext } = options;
const shutdown = onShutdown ?? (() => {});
runner.initialize(
// ExtensionActions
{
sendMessage: (message, sendOptions) => {
session.sendCustomMessage(message, sendOptions).catch(e => {
reportSendError("extension_send", e instanceof Error ? e : new Error(String(e)));
});
},
sendUserMessage: (content, sendOptions) => {
session.sendUserMessage(content, sendOptions).catch(e => {
reportSendError("extension_send_user", e instanceof Error ? e : new Error(String(e)));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown,
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
// ExtensionCommandContextActions — commands invokable via prompt("/command")
{
getContextUsage: () => session.getContextUsage(),
waitForIdle: () => session.agent.waitForIdle(),
newSession: async newOptions => {
const success = await session.newSession({ parentSession: newOptions?.parentSession });
if (success && newOptions?.setup) {
await newOptions.setup(session.sessionManager);
}
return { cancelled: !success };
},
branch: async entryId => {
const result = await session.branch(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, navOptions) => {
const result = await session.navigateTree(targetId, { summarize: navOptions?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
uiContext,
);
runner.onError(reportRuntimeError);
await runner.emit({ type: "session_start" });
}
@@ -2332,12 +2332,12 @@ export function getSymbolTheme(): SymbolTheme {
};
}
let _markdownTheme: MarkdownTheme | undefined;
let _markdownThemeRef: Theme | undefined;
let cachedMarkdownTheme: MarkdownTheme | undefined;
let cachedMarkdownThemeRef: Theme | undefined;
export function getMarkdownTheme(): MarkdownTheme {
if (_markdownTheme !== undefined && _markdownThemeRef === theme) {
return _markdownTheme;
if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
return cachedMarkdownTheme;
}
const markdownTheme: MarkdownTheme = {
heading: (text: string) => theme.fg("mdHeading", text),
@@ -2365,8 +2365,8 @@ export function getMarkdownTheme(): MarkdownTheme {
}
},
};
_markdownTheme = markdownTheme;
_markdownThemeRef = theme;
cachedMarkdownTheme = markdownTheme;
cachedMarkdownThemeRef = theme;
return markdownTheme;
}
@@ -245,7 +245,7 @@ export class UiHelpers {
break;
}
default: {
const _exhaustive: never = message;
message satisfies never;
}
}
return [];
@@ -16,7 +16,7 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import {
type Agent,
AgentBusyError,
@@ -47,14 +47,20 @@ import {
calculateRateLimitBackoffMs,
getSupportedEfforts,
isContextOverflow,
isUnexpectedSocketCloseMessage,
isUsageLimitError,
modelsAreEqual,
parseRateLimitReason,
streamSimple,
} from "@oh-my-pi/pi-ai";
import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import {
getAgentDbPath,
isEnoent,
isUnexpectedSocketCloseMessage,
logger,
prompt,
Snowflake,
} from "@oh-my-pi/pi-utils";
import { type AsyncJob, AsyncJobManager } from "../async";
import type { Rule } from "../capability/rule";
import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
@@ -208,7 +214,8 @@ export type AgentSessionEvent =
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
| { type: "todo_auto_clear" }
| { type: "irc_message"; message: CustomMessage }
| { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string };
| { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
| { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined };
/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -1446,7 +1453,7 @@ export class AgentSession {
const scheduled = (async () => {
if (delayMs > 0) {
try {
await abortableSleep(delayMs, signal);
await scheduler.wait(delayMs, { signal });
} catch {
return;
}
@@ -4545,6 +4552,7 @@ export class AgentSession {
if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
this.settings.set("defaultThinkingLevel", effectiveLevel);
}
this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
}
}
@@ -4678,8 +4686,6 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -4701,23 +4707,7 @@ export class AgentSession {
}
}
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
let summary: string;
let shortSummary: string | undefined;
@@ -4725,14 +4715,13 @@ export class AgentSession {
let tokensBefore: number;
let details: unknown;
if (hookCompaction) {
// Extension provided compaction content
summary = hookCompaction.summary;
shortSummary = hookCompaction.shortSummary;
firstKeptEntryId = hookCompaction.firstKeptEntryId;
tokensBefore = hookCompaction.tokensBefore;
details = hookCompaction.details;
preserveData ??= hookCompaction.preserveData;
if (compactionPrep.kind === "fromHook") {
summary = compactionPrep.summary;
shortSummary = compactionPrep.shortSummary;
firstKeptEntryId = compactionPrep.firstKeptEntryId;
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
} else {
// Generate compaction result. Only convert known abort-shaped
// rejections (AbortError raised while the abort signal is set,
@@ -4751,8 +4740,8 @@ export class AgentSession {
customInstructions,
compactionAbortController.signal,
{
promptOverride: hookPrompt,
extraContext: hookContext,
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
},
);
@@ -4761,7 +4750,7 @@ export class AgentSession {
firstKeptEntryId = result.firstKeptEntryId;
tokensBefore = result.tokensBefore;
details = result.details;
preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
} catch (err) {
if (err instanceof CompactionCancelledError) {
throw err;
@@ -5721,6 +5710,64 @@ export class AgentSession {
throw this.#buildCompactionAuthError();
}
async #prepareCompactionFromHooks(
preparation: CompactionPreparation,
hookCompaction: CompactionResult | undefined,
): Promise<
| {
kind: "fromHook";
summary: string;
shortSummary: string | undefined;
firstKeptEntryId: string;
tokensBefore: number;
details: unknown;
preserveData: Record<string, unknown> | undefined;
}
| {
kind: "needsLlm";
hookContext: string[] | undefined;
hookPrompt: string | undefined;
preserveData: Record<string, unknown> | undefined;
}
> {
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
if (hookCompaction) {
preserveData ??= hookCompaction.preserveData;
return {
kind: "fromHook",
summary: hookCompaction.summary,
shortSummary: hookCompaction.shortSummary,
firstKeptEntryId: hookCompaction.firstKeptEntryId,
tokensBefore: hookCompaction.tokensBefore,
details: hookCompaction.details,
preserveData,
};
}
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
}
/**
* Internal: Run auto-compaction with events.
*/
@@ -5842,8 +5889,6 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromExtension = false;
let hookContext: string[] | undefined;
let hookPrompt: string | undefined;
let preserveData: Record<string, unknown> | undefined;
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -5872,23 +5917,7 @@ export class AgentSession {
}
}
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
const result = (await this.#extensionRunner.emit({
type: "session.compacting",
sessionId: this.sessionId,
messages: compactMessages,
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
hookContext = result?.context;
hookPrompt = result?.prompt;
preserveData = result?.preserveData;
}
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
if (memoryBackendContext) {
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
}
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
let summary: string;
let shortSummary: string | undefined;
@@ -5896,14 +5925,13 @@ export class AgentSession {
let tokensBefore: number;
let details: unknown;
if (hookCompaction) {
// Extension provided compaction content
summary = hookCompaction.summary;
shortSummary = hookCompaction.shortSummary;
firstKeptEntryId = hookCompaction.firstKeptEntryId;
tokensBefore = hookCompaction.tokensBefore;
details = hookCompaction.details;
preserveData ??= hookCompaction.preserveData;
if (compactionPrep.kind === "fromHook") {
summary = compactionPrep.summary;
shortSummary = compactionPrep.shortSummary;
firstKeptEntryId = compactionPrep.firstKeptEntryId;
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
} else {
const candidates = this.#getCompactionModelCandidates(availableModels);
const retrySettings = this.settings.getGroup("retry");
@@ -5918,8 +5946,8 @@ export class AgentSession {
while (true) {
try {
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
promptOverride: hookPrompt,
extraContext: hookContext,
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
@@ -5976,7 +6004,7 @@ export class AgentSession {
error: message,
model: `${candidate.provider}/${candidate.id}`,
});
await abortableSleep(delayMs, autoCompactionSignal);
await scheduler.wait(delayMs, { signal: autoCompactionSignal });
}
}
@@ -5997,7 +6025,7 @@ export class AgentSession {
firstKeptEntryId = compactResult.firstKeptEntryId;
tokensBefore = compactResult.tokensBefore;
details = compactResult.details;
preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
}
if (autoCompactionSignal.aborted) {
@@ -6502,7 +6530,7 @@ export class AgentSession {
this.#retryAbortController?.abort();
this.#retryAbortController = retryAbortController;
try {
await abortableSleep(delayMs, retryAbortController.signal);
await scheduler.wait(delayMs, { signal: retryAbortController.signal });
} catch {
if (this.#retryAbortController !== retryAbortController) {
return false;
@@ -7834,21 +7862,11 @@ export class AgentSession {
* @returns Text content, or undefined if no assistant message exists
*/
getLastAssistantText(): string | undefined {
const lastAssistant = this.messages
.slice()
.reverse()
.find(m => {
if (m.role !== "assistant") return false;
const msg = m as AssistantMessage;
// Skip aborted messages with no content
if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
return true;
});
const lastAssistant = this.#getLastCopyCandidateAssistantMessage();
if (!lastAssistant) return undefined;
let text = "";
for (const content of (lastAssistant as AssistantMessage).content) {
for (const content of lastAssistant.content) {
if (content.type === "text") {
text += content.text;
}
@@ -7857,6 +7875,54 @@ export class AgentSession {
return text.trim() || undefined;
}
hasCopyCandidateAssistantMessage(): boolean {
return this.#getLastCopyCandidateAssistantMessage() !== undefined;
}
#getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (message.role !== "assistant") continue;
const assistantMessage = message as AssistantMessage;
// Skip aborted messages with no content
if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue;
return assistantMessage;
}
return undefined;
}
/**
* Get text content of the most recent visible handoff message.
* Fresh handoff sessions store the handoff context as a custom message, not
* an assistant message, so callers that copy the "last" message can use this
* as a fallback before the new session has an assistant response.
*/
getLastVisibleHandoffText(): string | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (message.role !== "custom") continue;
const customMessage = message as CustomMessage;
if (customMessage.customType !== "handoff" || !customMessage.display) continue;
if (typeof customMessage.content === "string") {
return customMessage.content.trim() || undefined;
}
let text = "";
for (const content of customMessage.content) {
if (content.type === "text") {
text += content.text;
}
}
return text.trim() || undefined;
}
return undefined;
}
/**
* Format the entire session as plain text for clipboard export.
* Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
@@ -364,8 +364,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
attribution: m.attribution ?? "agent",
};
default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine
const _exhaustiveCheck: never = m;
m satisfies never;
return undefined;
}
})
+4 -2
View File
@@ -530,9 +530,11 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
description: tools?.get(name)?.description ?? "",
}));
// Filter skills to only include those with read tool.
// Filter skills for the rendered system prompt:
// - require the `read` tool so the model can actually fetch skill content;
// - drop skills with frontmatter `hide: true` (still loadable via skill:// and /skill:<name>).
const hasRead = tools?.has("read");
const filteredSkills = hasRead ? skills : [];
const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
resolvedCustomPrompt,
+21 -120
View File
@@ -7,33 +7,27 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import type { OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
appendParseErrorsBulletList,
createCachedComponent,
dedupeParseErrors,
formatCodeFrameLine,
formatCount,
formatEmptyMessage,
formatErrorMessage,
formatParseErrors,
PARSE_ERRORS_LIMIT,
formatParseErrorsCountLabel,
PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils";
import { queueResolveHandler } from "./resolve";
import { ToolError } from "./tool-errors";
@@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
const normalizedRewrites = Object.fromEntries(ops);
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
const rawPaths = params.paths.map(normalizePathLikeInput);
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
}
resolvedPathInputs.push(resource.sourcePath);
}
let effectivePathInputs = resolvedPathInputs;
if (resolvedPathInputs.length > 1) {
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePathInputs = partition.valid;
}
if (effectivePathInputs.length === 1) {
const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
}
const resolvedSearchPath = searchPath;
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
let isDirectory: boolean;
try {
const stat = await Bun.file(resolvedSearchPath).stat();
isDirectory = stat.isDirectory();
} catch {
throw new ToolError(`Path not found: ${scopePath}`);
}
const scope = await resolveToolSearchScope({
rawPaths: params.paths,
cwd: this.session.cwd,
internalUrlAction: "rewrite",
});
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
rewrites: normalizedRewrites,
@@ -502,15 +445,7 @@ export const astEditToolRenderer = {
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
if (details?.parseErrors?.length) {
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
}
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
}
}
appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme);
return new Text(lines.join("\n"), 0, 0);
}
@@ -523,28 +458,7 @@ export const astEditToolRenderer = {
const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = textContent.split("\n");
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const allGroups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
allGroups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) allGroups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
allGroups.push(nonEmpty);
}
}
const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
const changeGroups = allGroups.filter(
group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
);
@@ -560,23 +474,15 @@ export const astEditToolRenderer = {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
}
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const changeLines = renderTreeList(
{
items: changeGroups,
expanded,
expanded: options.expanded,
maxCollapsed: changeGroups.length,
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
itemType: "change",
@@ -591,14 +497,9 @@ export const astEditToolRenderer = {
},
uiTheme,
);
const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
mergeCallAndResult: true,
};
+21 -119
View File
@@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui";
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format";
import type { OutputMeta } from "./output-meta";
import { resolveToolSearchScope } from "./path-utils";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
partitionExistingPaths,
resolveExplicitSearchPaths,
resolveToCwd,
} from "./path-utils";
import {
appendParseErrorsBulletList,
createCachedComponent,
dedupeParseErrors,
formatCodeFrameLine,
formatCount,
formatEmptyMessage,
formatErrorMessage,
formatParseErrors,
PARSE_ERRORS_LIMIT,
formatParseErrorsCountLabel,
PREVIEW_LIMITS,
splitGroupsByBlankLine,
} from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
@@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (!Number.isFinite(skip) || skip < 0) {
throw new ToolError("skip must be a non-negative number");
}
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string;
let scopePath: string;
let globFilter: string | undefined;
let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
const rawPaths = params.paths.map(normalizePathLikeInput);
if (rawPaths.some(rawPath => rawPath.length === 0)) {
throw new ToolError("`paths` must contain non-empty paths or globs");
}
const internalRouter = InternalUrlRouter.instance();
const resolvedPathInputs: string[] = [];
for (const rawPath of rawPaths) {
if (!internalRouter.canHandle(rawPath)) {
resolvedPathInputs.push(rawPath);
continue;
}
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
}
resolvedPathInputs.push(resource.sourcePath);
}
let effectivePathInputs = resolvedPathInputs;
if (resolvedPathInputs.length > 1) {
const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
if (partition.valid.length === 0) {
throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
}
effectivePathInputs = partition.valid;
}
if (effectivePathInputs.length === 1) {
const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
scopePath = formatScopePath(searchPath);
} else {
const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
if (!multiSearchPath) {
throw new ToolError("`paths` must contain at least one path or glob");
}
searchPath = multiSearchPath.basePath;
globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
multiTargets = multiSearchPath.targets;
scopePath = multiSearchPath.scopePath;
}
const resolvedSearchPath = searchPath;
scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
let isDirectory: boolean;
try {
const stat = await Bun.file(resolvedSearchPath).stat();
isDirectory = stat.isDirectory();
} catch {
throw new ToolError(`Path not found: ${scopePath}`);
}
const scope = await resolveToolSearchScope({
rawPaths: params.paths,
cwd: this.session.cwd,
internalUrlAction: "search",
});
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
const DEFAULT_AST_LIMIT = 50;
const result = multiTargets
@@ -388,13 +330,7 @@ export const astGrepToolRenderer = {
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
if (details?.parseErrors?.length) {
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
}
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
}
appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
}
return new Text(lines.join("\n"), 0, 0);
}
@@ -411,28 +347,7 @@ export const astGrepToolRenderer = {
);
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = textContent.split("\n");
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const allGroups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
allGroups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) allGroups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
allGroups.push(nonEmpty);
}
}
const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
const matchGroups = allGroups.filter(
group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
);
@@ -442,24 +357,16 @@ export const astGrepToolRenderer = {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
}
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const matchLines = renderTreeList(
{
items: matchGroups,
expanded,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
itemType: "match",
@@ -473,14 +380,9 @@ export const astGrepToolRenderer = {
},
uiTheme,
);
const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
mergeCallAndResult: true,
};
+27 -4
View File
@@ -9,7 +9,7 @@ import { type BashResult, executeBash } from "../exec/bash-executor";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import type { Theme } from "../modes/theme/theme";
import { highlightCode, type Theme } from "../modes/theme/theme";
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
@@ -484,8 +484,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
const env = normalizeBashEnv(rawEnv);
// Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
// Constrained to a single line so a `&&` that sits on a later line of a multiline
// script can't pull the entire script into the "cwd" capture.
if (!cwd) {
const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/);
const cdMatch = command.match(/^cd[ \t]+((?:[^&\\\n\r]|\\.)+?)[ \t]*&&[ \t]*/);
if (cdMatch) {
cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
command = command.slice(cdMatch[0].length);
@@ -892,6 +894,27 @@ export function formatBashCommand(args: BashRenderArgs): string {
return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
}
/**
* Returns the bash command formatted for the result body: the dim `$ cd … &&`
* prefix joined with syntax-highlighted command lines. The prefix is applied
* only to the first line so multi-line commands display cleanly — terminals
* reset SGR state at line boundaries, which made the previous single-string
* `theme.fg("dim", ...)` form render only the first line as dim.
*/
export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): string[] {
const command = replaceTabs(args.command || "…");
const cwd = getProjectDir();
const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd);
const envAssignments = formatBashEnvAssignments(getBashEnvForDisplay(args));
const prefixParts = ["$"];
if (displayWorkdir) prefixParts.push(`cd ${displayWorkdir} &&`);
if (envAssignments) prefixParts.push(envAssignments);
const prefix = uiTheme.fg("dim", `${prefixParts.join(" ")} `);
const highlightedLines = highlightCode(command, "bash");
if (highlightedLines.length === 0) return [prefix.trimEnd()];
return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
}
function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
return {
command: config.resolveCommand?.(args),
@@ -922,7 +945,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
args?: TArgs,
): Component {
const renderArgs = toBashRenderArgs(args, config);
const cmdText = args ? formatBashCommand(renderArgs) : undefined;
const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
const isError = result.isError === true;
const icon = options.isPartial ? "pending" : isError ? "error" : "success";
const title = config.resolveTitle(args, options);
@@ -1000,7 +1023,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
header,
state: options.isPartial ? "pending" : isError ? "error" : "success",
sections: [
{ lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
{ lines: cmdLines ?? [] },
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
],
width,
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
import type { Browser, Page } from "puppeteer-core";
import { ToolError, throwIfAborted } from "../tool-errors";
export const ATTACH_TARGET_SKIP_PATTERN =
const ATTACH_TARGET_SKIP_PATTERN =
/request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
/**
@@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
* accepts both `--flag=value` and `--flag value`). Returns null if absent or
* malformed.
*/
export function findCdpPortInArgs(args: string[]): number | null {
function findCdpPortInArgs(args: string[]): number | null {
for (const arg of args) {
const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
if (m) {
@@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null {
}
/** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
const probeTimeout = AbortSignal.timeout(1500);
const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
try {
@@ -30,12 +30,12 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
* connection dropped, etc.).
*/
export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
export const STEALTH_IGNORE_DEFAULT_ARGS = [
const STEALTH_IGNORE_DEFAULT_ARGS = [
"--disable-extensions",
"--disable-default-apps",
"--disable-component-extensions-with-background-pages",
];
export const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
@@ -82,7 +82,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
* The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
*/
let chromiumExecutablePromise: Promise<string | undefined> | undefined;
export async function ensureChromiumExecutable(): Promise<string | undefined> {
async function ensureChromiumExecutable(): Promise<string | undefined> {
const sysChrome = resolveSystemChromium();
if (sysChrome) return sysChrome;
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -138,7 +138,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
return chromiumExecutablePromise;
}
let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
function isExecutableFile(p: string): boolean {
try {
@@ -209,19 +209,19 @@ function systemChromiumCandidates(): string[] {
return candidates;
}
export function resolveSystemChromium(): string | undefined {
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
function resolveSystemChromium(): string | undefined {
if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
const seen = new Set<string>();
for (const candidate of systemChromiumCandidates()) {
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
if (isExecutableFile(candidate)) {
_resolvedChromium = candidate;
resolvedChromium = candidate;
logger.debug("Using system Chrome/Chromium", { path: candidate });
return candidate;
}
}
_resolvedChromium = null;
resolvedChromium = null;
return undefined;
}
@@ -26,10 +26,6 @@ export interface BrowserHandle {
const browsers = new Map<string, BrowserHandle>();
export function listBrowsers(): BrowserHandle[] {
return [...browsers.values()];
}
function browserKey(kind: BrowserKind): string {
switch (kind.kind) {
case "headless":
@@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
}
}
export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
if (handle.kind.kind === "headless") {
if (handle.browser.connected) {
try {
@@ -89,10 +89,6 @@ export function getTab(name: string): TabSession | undefined {
return tabs.get(name);
}
export function listTabs(): TabSession[] {
return [...tabs.values()];
}
export async function acquireTab(
name: string,
browser: BrowserHandle,
+20 -38
View File
@@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import findDescription from "../prompts/tools/find.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
import {
Ellipsis,
Hasher,
type RenderCache,
renderFileList,
renderStatusLine,
renderTreeList,
truncateToWidth,
} from "../tui";
import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
@@ -33,7 +25,13 @@ import {
resolveExplicitFindPatterns,
resolveToCwd,
} from "./path-utils";
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
import {
createCachedComponent,
formatCount,
formatEmptyMessage,
formatErrorMessage,
PREVIEW_LIMITS,
} from "./render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
@@ -401,30 +399,22 @@ export const findToolRenderer = {
},
uiTheme,
);
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const listLines = renderTreeList(
{
items: lines,
expanded,
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
itemType: "file",
renderItem: line => uiTheme.fg("accent", line),
},
uiTheme,
);
const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
}
const fileCount = details?.fileCount ?? 0;
@@ -467,28 +457,20 @@ export const findToolRenderer = {
}
if (missingNote) extraLines.push(missingNote);
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
return createCachedComponent(
() => options.expanded,
width => {
const fileLines = renderFileList(
{
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
expanded,
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
},
uiTheme,
);
const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: result };
return result;
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
invalidate() {
cached = undefined;
},
};
);
},
mergeCallAndResult: true,
};
+7 -6
View File
@@ -1,9 +1,10 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { abortableSleep, getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { Settings } from "../config/settings";
import githubDescription from "../prompts/tools/github.md" with { type: "text" };
@@ -3400,7 +3401,7 @@ async function executeRunWatch(
note,
}),
});
await abortableSleep(graceSeconds * 1000, signal);
await scheduler.wait(graceSeconds * 1000, { signal });
run = await fetchRunSnapshot(session.cwd, repo, runId, signal);
}
@@ -3435,7 +3436,7 @@ async function executeRunWatch(
return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails);
}
await abortableSleep(intervalSeconds * 1000, signal);
await scheduler.wait(intervalSeconds * 1000, { signal });
}
}
@@ -3477,7 +3478,7 @@ async function executeRunWatch(
note,
}),
});
await abortableSleep(graceSeconds * 1000, signal);
await scheduler.wait(graceSeconds * 1000, { signal });
runs = await fetchRunsForCommit(session.cwd, repo, headSha, branch, signal);
}
@@ -3533,11 +3534,11 @@ async function executeRunWatch(
note,
}),
});
await abortableSleep(intervalSeconds * 1000, signal);
await scheduler.wait(intervalSeconds * 1000, { signal });
continue;
}
settledSuccessSignature = undefined;
await abortableSleep(intervalSeconds * 1000, signal);
await scheduler.wait(intervalSeconds * 1000, { signal });
}
}
@@ -1,7 +1,8 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { extractTextContent } from "../commit/utils";
import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
@@ -30,14 +31,6 @@ export interface InspectImageToolDetails {
mimeType: string;
}
function extractResponseText(message: AssistantMessage): string {
return message.content
.filter(content => content.type === "text")
.map(content => content.text)
.join("")
.trim();
}
export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
readonly name = "inspect_image";
readonly label = "InspectImage";
@@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
throw new ToolError("inspect_image request aborted.");
}
const text = extractResponseText(response);
const text = extractTextContent(response);
if (!text) {
throw new ToolError("inspect_image model returned no text output.");
}

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