Merge upstream/main into feat/profiles-and-alias

This commit is contained in:
Ogrodev
2026-06-08 15:54:57 -03:00
761 changed files with 12182 additions and 5683 deletions
Generated
+4 -4
View File
@@ -2331,7 +2331,7 @@ dependencies = [
[[package]]
name = "pi-ast"
version = "15.10.4"
version = "15.10.5"
dependencies = [
"anyhow",
"ast-grep-core",
@@ -2399,7 +2399,7 @@ dependencies = [
[[package]]
name = "pi-iso"
version = "15.10.4"
version = "15.10.5"
dependencies = [
"async-trait",
"libc",
@@ -2411,7 +2411,7 @@ dependencies = [
[[package]]
name = "pi-natives"
version = "15.10.4"
version = "15.10.5"
dependencies = [
"anyhow",
"arboard",
@@ -2457,7 +2457,7 @@ dependencies = [
[[package]]
name = "pi-shell"
version = "15.10.4"
version = "15.10.5"
dependencies = [
"anyhow",
"brush-builtins",
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
resolver = "3"
[workspace.package]
version = "15.10.4"
version = "15.10.5"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+22 -22
View File
@@ -15,7 +15,7 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "15.10.4",
"version": "15.10.5",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
@@ -30,7 +30,7 @@
},
"packages/ai": {
"name": "@oh-my-pi/pi-ai",
"version": "15.10.4",
"version": "15.10.5",
"dependencies": {
"@bufbuild/protobuf": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -44,7 +44,7 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "15.10.4",
"version": "15.10.5",
"bin": {
"omp": "src/cli.ts",
},
@@ -90,7 +90,7 @@
},
"packages/hashline": {
"name": "@oh-my-pi/hashline",
"version": "15.10.4",
"version": "15.10.5",
"dependencies": {
"diff": "catalog:",
"lru-cache": "catalog:",
@@ -101,7 +101,7 @@
},
"packages/mnemopi": {
"name": "@oh-my-pi/pi-mnemopi",
"version": "15.10.4",
"version": "15.10.5",
"bin": {
"mnemopi": "src/cli.ts",
},
@@ -118,7 +118,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "15.10.4",
"version": "15.10.5",
"devDependencies": {
"@napi-rs/cli": "catalog:",
"@types/bun": "catalog:",
@@ -126,7 +126,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "15.10.4",
"version": "15.10.5",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -151,7 +151,7 @@
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "15.10.4",
"version": "15.10.5",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -167,7 +167,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "15.10.4",
"version": "15.10.5",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -208,7 +208,7 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "15.10.4",
"version": "15.10.5",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"beautiful-mermaid": "catalog:",
@@ -248,15 +248,15 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.0",
"@oh-my-pi/hashline": "15.10.4",
"@oh-my-pi/omp-stats": "15.10.4",
"@oh-my-pi/pi-agent-core": "15.10.4",
"@oh-my-pi/pi-ai": "15.10.4",
"@oh-my-pi/pi-coding-agent": "15.10.4",
"@oh-my-pi/pi-mnemopi": "15.10.4",
"@oh-my-pi/pi-natives": "15.10.4",
"@oh-my-pi/pi-tui": "15.10.4",
"@oh-my-pi/pi-utils": "15.10.4",
"@oh-my-pi/hashline": "15.10.5",
"@oh-my-pi/omp-stats": "15.10.5",
"@oh-my-pi/pi-agent-core": "15.10.5",
"@oh-my-pi/pi-ai": "15.10.5",
"@oh-my-pi/pi-coding-agent": "15.10.5",
"@oh-my-pi/pi-mnemopi": "15.10.5",
"@oh-my-pi/pi-natives": "15.10.5",
"@oh-my-pi/pi-tui": "15.10.5",
"@oh-my-pi/pi-utils": "15.10.5",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -391,7 +391,7 @@
"@huggingface/blake3-jit": ["@huggingface/blake3-jit@0.0.2", "", {}, "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w=="],
"@huggingface/hub": ["@huggingface/hub@2.13.0", "", { "dependencies": { "@huggingface/tasks": "^0.21.1", "@huggingface/xetchunk-wasm": "^0.0.6" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-IAoqdpTV9HeMyooxKVvVGWirOJ+S2IAKnU2FSQSMz62ehPTKzxADAATy1fwAlYYQdHCV119GHFf4p9+ECQ+I5g=="],
"@huggingface/hub": ["@huggingface/hub@2.13.1", "", { "dependencies": { "@huggingface/tasks": "^0.21.8", "@huggingface/xetchunk-wasm": "^0.0.6" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-2eMXRsSvGSzayXAYKO27PoZdfREY20eNhSU4HRTFwBmKb0L2lAUO9tldJOIol1YtHahgI/cgcpJgCrZArHQtsw=="],
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
@@ -833,7 +833,7 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
"beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="],
@@ -937,7 +937,7 @@
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="],
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+107 -21
View File
@@ -80,7 +80,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -91,7 +91,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -239,8 +239,6 @@ dependencies = [
[[package]]
name = "brush-core"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "967a82f3b1bf090db686d5fb86424b19725073828532a42f960d5a13bff14ecb"
dependencies = [
"async-recursion",
"async-trait",
@@ -269,10 +267,12 @@ dependencies = [
"terminfo",
"thiserror",
"tokio",
"tokio-util",
"tracing",
"uuid",
"uzers",
"whoami",
"windows-sys 0.59.0",
]
[[package]]
@@ -480,7 +480,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -647,7 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1197,7 +1197,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1603,7 +1603,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1802,7 +1802,7 @@ dependencies = [
"getrandom",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1812,7 +1812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1871,7 +1871,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1885,6 +1885,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tracing"
version = "0.1.44"
@@ -2237,7 +2250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9"
dependencies = [
"windows-core 0.51.1",
"windows-targets",
"windows-targets 0.48.5",
]
[[package]]
@@ -2246,7 +2259,7 @@ version = "0.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
dependencies = [
"windows-targets",
"windows-targets 0.48.5",
]
[[package]]
@@ -2308,6 +2321,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2323,13 +2345,29 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
@@ -2338,42 +2376,90 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+4 -16
View File
@@ -1,6 +1,6 @@
use std::{io::Write, path::PathBuf};
use brush_core::{ExecutionResult, builtins, error};
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
/// Change the current shell working directory.
@@ -19,11 +19,6 @@ pub(crate) struct CdCommand {
#[arg(short = 'e')]
exit_on_failed_cwd_resolution: bool,
/// Show file with extended attributes as a dir with extended
/// attributes.
#[arg(short = '@')]
file_with_xattr_as_dir: bool,
/// By default it is the value of the HOME shell variable. If `TARGET_DIR` is
/// "-", it is converted to $OLDPWD.
target_dir: Option<PathBuf>,
@@ -36,11 +31,6 @@ impl builtins::Command for CdCommand {
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// TODO(cd): implement 'cd -@'
if self.file_with_xattr_as_dir {
return error::unimp("cd -@");
}
let mut should_print = false;
let mut target_dir = if let Some(target_dir) = &self.target_dir {
// `cd -', equivalent to `cd $OLDPWD'
@@ -72,11 +62,9 @@ impl builtins::Command for CdCommand {
.options()
.do_not_resolve_symlinks_when_changing_dir
{
// -e is only relevant in physical mode.
if self.exit_on_failed_cwd_resolution {
return error::unimp("cd -e");
}
// -e is only relevant in physical mode. `canonicalize()` is the
// cwd-resolution step this implementation supports; failures already
// propagate as a non-zero result before updating PWD/OLDPWD.
target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
}
+5 -2
View File
@@ -1,4 +1,4 @@
use brush_core::{ExecutionResult, builtins, error};
use brush_core::{ExecutionResult, builtins};
/// No-op command.
pub(crate) struct ColonCommand {}
@@ -13,7 +13,10 @@ impl builtins::SimpleCommand for ColonCommand {
builtins::ContentType::DetailedHelp => Ok("Null command; always returns success.".into()),
builtins::ContentType::ShortUsage => Ok(":: :".into()),
builtins::ContentType::ShortDescription => Ok(": - Null command".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
builtins::ContentType::ManPage => Ok(
"NAME\n : - Null command.\n\nSYNOPSIS\n :\n\nDESCRIPTION\n Null command.\n\n No effect; the command does nothing.\n\n Exit Status:\n Always succeeds.\n"
.into(),
),
}
}
+50 -8
View File
@@ -3,7 +3,7 @@ use std::{collections::HashMap, fmt::Write as _, io::Write};
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
completion::{self, CompleteAction, CompleteOption, Spec},
error, escape,
escape,
};
use clap::Parser;
@@ -188,6 +188,30 @@ impl CommonCompleteCommandArgs {
actions
}
fn has_completion_spec(&self) -> bool {
!self.options.is_empty()
|| !self.actions.is_empty()
|| self.glob_pattern.is_some()
|| self.word_list.is_some()
|| self.function_name.is_some()
|| self.command.is_some()
|| self.filter_pattern.is_some()
|| self.prefix.is_some()
|| self.suffix.is_some()
|| self.action_alias
|| self.action_builtin
|| self.action_command
|| self.action_directory
|| self.action_exported
|| self.action_file
|| self.action_group
|| self.action_job
|| self.action_keyword
|| self.action_service
|| self.action_user
|| self.action_variable
}
}
/// Configure programmable command completion.
@@ -234,7 +258,7 @@ impl builtins::Command for CompleteCommand {
|| self.use_for_initial_word
|| self.names.is_empty()
{
self.process_global(&mut context)?;
result = self.process_global(&mut context)?;
} else {
for name in &self.names {
if !self.try_process_for_command(&mut context, name.as_str())? {
@@ -251,7 +275,7 @@ impl CompleteCommand {
fn process_global(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
) -> Result<ExecutionResult, brush_core::Error> {
// Read options before taking mutable borrow on completion_config
let extended_globbing = context.shell.options().extended_globbing;
@@ -272,13 +296,20 @@ impl CompleteCommand {
};
// Treat 'complete' with no options the same as 'complete -p'.
if self.print || (!self.remove && target_spec.is_none()) {
if self.print
|| (!self.remove && target_spec.is_none() && !self.common_args.has_completion_spec())
{
if let Some(target_spec) = target_spec {
if let Some(existing_spec) = target_spec {
let existing_spec = existing_spec.clone();
Self::display_spec(context, Some(special_option_name), None, &existing_spec)?;
} else {
return error::unimp("special spec not found");
writeln!(
context.stderr(),
"complete: {}: no completion specification",
Self::special_spec_name(special_option_name)
)?;
return Ok(ExecutionResult::general_error());
}
} else {
for (command_name, spec) in context.shell.completion_config().iter() {
@@ -297,11 +328,22 @@ impl CompleteCommand {
let mut new_spec = Some(self.common_args.create_spec(extended_globbing));
std::mem::swap(&mut new_spec, target_spec);
} else {
return error::unimp("set unspecified spec");
debug_assert!(self.common_args.has_completion_spec());
writeln!(context.stderr(), "complete: invalid usage")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
Ok(())
Ok(ExecutionResult::success())
}
fn special_spec_name(option_name: &str) -> &'static str {
match option_name {
"-D" => "_DefaultCmD_",
"-E" => "_EmptycmD_",
"-I" => "_InitialWorD_",
_ => "",
}
}
fn try_display_spec_for_command(
@@ -510,7 +552,7 @@ impl builtins::Command for CompGenCommand {
}
},
completion::Answer::RestartCompletionProcess => {
return error::unimp("restart completion");
return Ok(ExecutionResult::general_error());
},
}
+22 -6
View File
@@ -3,7 +3,6 @@ use std::{io::Write, sync::LazyLock};
use brush_core::{
ErrorKind, ExecutionResult, builtins,
env::{self, EnvironmentLookup, EnvironmentScope},
error,
parser::ast,
variables::{
self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
@@ -132,10 +131,6 @@ impl builtins::Command for DeclareCommand {
return Ok(ExecutionResult::general_error());
}
if self.locals_inherit_from_prev_scope {
return error::unimp("declare -I");
}
let mut result = ExecutionResult::success();
if !self.declarations.is_empty() {
for declaration in &self.declarations {
@@ -303,7 +298,28 @@ impl DeclareCommand {
ShellValueUnsetType::Untyped
};
let mut var = ShellVariable::new(ShellValue::Unset(unset_type));
let mut var = if create_var_local
&& (self.locals_inherit_from_prev_scope
|| context.shell.options().local_vars_inherit_value_and_attrs)
{
if let Some(prev_var) = context
.shell
.env()
.get_using_policy(name.as_str(), EnvironmentLookup::Anywhere)
{
if prev_var.is_readonly() {
return Err(ErrorKind::ReadonlyVariable.into());
}
let mut var = prev_var.clone();
var.unset_treat_as_nameref();
var
} else {
ShellVariable::new(ShellValue::Unset(unset_type))
}
} else {
ShellVariable::new(ShellValue::Unset(unset_type))
};
self.apply_attributes_before_update(&mut var)?;
+17 -6
View File
@@ -1,6 +1,6 @@
use std::io::Write;
use brush_core::{ExecutionResult, builtins, error};
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
use itertools::Itertools;
@@ -44,15 +44,26 @@ impl builtins::Command for EnableCommand {
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.shared_object_path.is_some() {
return error::unimp("enable -f");
}
if self.remove_loaded_builtin {
return error::unimp("enable -d");
if let Some(shared_object_path) = &self.shared_object_path {
writeln!(
context.stderr(),
"{}: cannot open shared object {shared_object_path}: dynamic loading is not supported",
context.command_name
)?;
return Ok(ExecutionResult::general_error());
}
if !self.names.is_empty() {
for name in &self.names {
if self.remove_loaded_builtin {
if context.shell.builtins().contains_key(name) {
writeln!(context.stderr(), "{name}: not dynamically loaded")?;
} else {
writeln!(context.stderr(), "{name}: not a shell builtin")?;
}
result = ExecutionResult::general_error();
continue;
}
if let Some(builtin) = context.shell.builtin_mut(name) {
builtin.disabled = self.disable;
} else {
+65 -8
View File
@@ -1,4 +1,7 @@
use std::{borrow::Cow, os::unix::process::CommandExt};
use std::{
borrow::Cow,
os::unix::process::{CommandExt, ExitStatusExt},
};
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands};
use clap::Parser;
@@ -47,7 +50,7 @@ impl builtins::Command for ExecCommand {
// expectation of returning.
if context.shell.is_subshell() {
if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
return brush_core::error::unimp("exec with options in subshell not yet supported");
return self.execute_external_in_subshell(context).await;
}
let cmd_cmd = crate::command::CommandCommand {
@@ -58,16 +61,12 @@ impl builtins::Command for ExecCommand {
return cmd_cmd.execute(context).await;
}
let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
if self.exec_as_login {
argv0 = Cow::Owned(std::format!("-{argv0}"));
}
let argv0 = self.argv0();
let mut cmd = commands::compose_std_command(
&context,
&self.args[0],
argv0.as_str(),
argv0.as_ref(),
&self.args[1..],
self.empty_environment,
)?;
@@ -81,3 +80,61 @@ impl builtins::Command for ExecCommand {
}
}
}
impl ExecCommand {
fn argv0(&self) -> Cow<'_, str> {
let argv0 = self
.name_for_argv0
.as_deref()
.unwrap_or_else(|| self.args[0].as_str());
if self.exec_as_login {
Cow::Owned(std::format!("-{argv0}"))
} else {
Cow::Borrowed(argv0)
}
}
async fn execute_external_in_subshell<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, brush_core::Error> {
let argv0 = self.argv0();
let cmd = commands::compose_std_command(
&context,
&self.args[0],
argv0.as_ref(),
&self.args[1..],
self.empty_environment,
)?;
let mut cmd = tokio::process::Command::from(cmd);
cmd.kill_on_drop(true);
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(spawn_err) => {
if spawn_err.kind() == std::io::ErrorKind::NotFound {
return Ok(ExecutionExitCode::NotFound.into());
}
return Err(ErrorKind::from(spawn_err).into());
},
};
let status = child.wait().await?;
if let Some(code) = status.code() {
#[expect(clippy::cast_sign_loss)]
return Ok(ExecutionResult::new((code & 0xff) as u8));
}
if let Some(signal) = status.signal() {
#[expect(clippy::cast_sign_loss)]
return Ok(ExecutionResult::new((signal & 0xff) as u8 + 128));
}
tracing::error!("unhandled process exit");
Ok(ExecutionExitCode::NotFound.into())
}
}
+5 -2
View File
@@ -1,4 +1,4 @@
use brush_core::{ExecutionResult, builtins, error};
use brush_core::{ExecutionResult, builtins};
/// Return exit code 1.
pub(crate) struct FalseCommand {}
@@ -13,7 +13,10 @@ impl builtins::SimpleCommand for FalseCommand {
builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
builtins::ContentType::ShortUsage => Ok("false".into()),
builtins::ContentType::ShortDescription => Ok("false - fail".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
builtins::ContentType::ManPage => Ok(
"NAME\n false - Return an unsuccessful result.\n\nSYNOPSIS\n false\n\nDESCRIPTION\n Return an unsuccessful result.\n\n Exit Status:\n Always fails.\n\nSEE ALSO\n bash(1)\n"
.into(),
),
}
}
+145 -2
View File
@@ -1,4 +1,8 @@
use std::io::Write;
use std::{
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
};
use brush_core::{ExecutionResult, builtins, error, history};
use clap::Parser;
@@ -50,7 +54,7 @@ impl builtins::Command for FcCommand {
return self.do_list(&context);
}
error::unimp("fc editor mode is not yet implemented")
self.do_edit(context).await
}
}
@@ -88,6 +92,96 @@ impl FcCommand {
Ok(ExecutionResult::success())
}
async fn do_edit(
&self,
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
let mut commands = String::new();
let indices: Vec<usize> = if reverse {
(first_idx..=last_idx).rev().collect()
} else {
(first_idx..=last_idx).collect()
};
for idx in indices {
let item = history
.get(idx)
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?;
commands.push_str(&item.command_line);
commands.push('\n');
}
let editor = self.editor_name(&context);
if editor.as_deref() != Some("-") {
let temp_file = FcTempFile::create()?;
fs::write(temp_file.path(), commands)?;
let edit_cmd = format!(
"{} {}",
editor.as_deref().unwrap_or("vi"),
shell_quote_path(temp_file.path())
);
let source_info = brush_core::SourceInfo::from("(fc editor)");
let edit_result = context
.shell
.run_string(edit_cmd, &source_info, &context.params)
.await?;
if !edit_result.is_success() {
return Ok(edit_result);
}
commands = fs::read_to_string(temp_file.path())?;
}
let history_mut = context
.shell
.history_mut()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
if commands.trim().is_empty() {
return Ok(ExecutionResult::success());
}
let source_info = brush_core::SourceInfo::from("(history)");
let result = context
.shell
.run_string(commands.clone(), &source_info, &context.params)
.await?;
context.shell.add_to_history(commands.trim_end())?;
Ok(result)
}
fn editor_name(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Option<String> {
if let Some(editor) = self.editor.as_ref().filter(|value| !value.is_empty()) {
return Some(editor.clone());
}
context
.shell
.env()
.get_str("FCEDIT", context.shell)
.filter(|value| !value.is_empty())
.or_else(|| {
context
.shell
.env()
.get_str("EDITOR", context.shell)
.filter(|value| !value.is_empty())
})
.map(|value| value.into_owned())
}
async fn do_execute(
&self,
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
@@ -291,6 +385,55 @@ impl FcCommand {
}
}
struct FcTempFile {
path: PathBuf,
}
impl FcTempFile {
fn create() -> Result<Self, brush_core::Error> {
let temp_dir = std::env::temp_dir();
let process_id = std::process::id();
for attempt in 0_u32..100 {
let path = temp_dir.join(format!("brush-fc-{process_id}-{attempt}.sh"));
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => return Ok(Self { path }),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {},
Err(err) => return Err(err.into()),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"failed to create a unique fc temporary file",
)
.into())
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for FcTempFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn shell_quote_path(path: &Path) -> String {
let mut quoted = String::from("'");
for ch in path.to_string_lossy().chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
/// Returns the effective history count (excluding the fc command itself).
fn effective_history_count(history: &history::History) -> usize {
history.count().saturating_sub(1)
+308 -8
View File
@@ -1,6 +1,6 @@
use std::{io::Write, path::PathBuf};
use std::{fs::File, io::Write, path::PathBuf};
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, history};
use clap::Parser;
/// Query or manipulate the shell's command history.
@@ -135,12 +135,24 @@ impl HistoryCommand {
return Ok(ExecutionResult::success());
}
if self.append_rest_of_file_to_session.is_some() {
return error::unimp("history -n is not yet implemented");
if let Some(read_option) = &self.append_rest_of_file_to_session {
if let Some(file_path) =
get_effective_history_file_path(config.default_history_file_path, read_option.as_ref())
{
append_history_file_to_session(history, file_path, HistoryReadMode::Unread)?;
}
if self.append_file_to_session.is_some() {
return error::unimp("history -r is not yet implemented");
return Ok(ExecutionResult::success());
}
if let Some(read_option) = &self.append_file_to_session {
if let Some(file_path) =
get_effective_history_file_path(config.default_history_file_path, read_option.as_ref())
{
append_history_file_to_session(history, file_path, HistoryReadMode::All)?;
}
return Ok(ExecutionResult::success());
}
if let Some(write_option) = &self.write_session_to_file {
@@ -158,8 +170,8 @@ impl HistoryCommand {
return Ok(ExecutionResult::success());
}
if self.expand_args.is_some() {
return error::unimp("history -p is not yet implemented");
if let Some(args) = &self.expand_args {
return expand_history_args(history, args, stdout, stderr);
}
if let Some(args) = &self.append_args_to_session {
@@ -179,6 +191,219 @@ impl HistoryCommand {
}
}
fn expand_history_args(
history: &history::History,
args: &[String],
mut stdout: impl Write,
mut stderr: impl Write,
) -> Result<ExecutionResult, brush_core::Error> {
let mut result = ExecutionResult::success();
for arg in args {
match expand_history_arg(history, arg) {
Ok(expanded) => {
writeln!(stdout, "{expanded}")?;
},
Err(()) => {
writeln!(stderr, "history: {arg}: history expansion failed")?;
result = ExecutionResult::general_error();
},
}
}
Ok(result)
}
fn expand_history_arg(history: &history::History, arg: &str) -> Result<String, ()> {
let chars: Vec<char> = arg.chars().collect();
let mut expanded = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] != '!' {
expanded.push(chars[i]);
i += 1;
continue;
}
i += 1;
if i == chars.len() {
expanded.push('!');
break;
}
let event = match chars[i] {
'!' => {
i += 1;
latest_history_event(history)?
},
'#' => {
i += 1;
let current_line = expanded.clone();
expanded.push_str(&current_line);
continue;
},
':' => latest_history_event(history)?,
'$' | '^' | '*' => {
let event = latest_history_event(history)?;
let selected = select_history_words(&event, chars[i], None)?;
i += 1;
expanded.push_str(&selected);
continue;
},
'-' => {
i += 1;
let (offset, next_i) = parse_history_number(&chars, i).ok_or(())?;
i = next_i;
relative_history_event(history, offset)?
},
'?' => {
i += 1;
let start = i;
while i < chars.len() && chars[i] != '?' {
i += 1;
}
let needle: String = chars[start..i].iter().collect();
if i < chars.len() && chars[i] == '?' {
i += 1;
}
find_history_event(history, &needle, HistorySearchMode::Contains)?
},
c if c.is_ascii_digit() => {
let (number, next_i) = parse_history_number(&chars, i).ok_or(())?;
i = next_i;
numbered_history_event(history, number)?
},
c if is_history_event_char(c) => {
let start = i;
while i < chars.len() && is_history_event_char(chars[i]) {
i += 1;
}
let prefix: String = chars[start..i].iter().collect();
find_history_event(history, &prefix, HistorySearchMode::Prefix)?
},
_ => {
expanded.push('!');
continue;
},
};
if i < chars.len() && chars[i] == ':' {
i += 1;
if i == chars.len() {
return Err(());
}
let selector = chars[i];
i += 1;
let number = if selector.is_ascii_digit() {
let (number, next_i) = parse_history_number_from_first(&chars, i - 1);
i = next_i;
Some(number)
} else {
None
};
let selected = select_history_words(&event, selector, number)?;
expanded.push_str(&selected);
} else {
expanded.push_str(&event);
}
}
Ok(expanded)
}
fn latest_history_event(history: &history::History) -> Result<String, ()> {
history
.iter()
.last()
.map(|item| item.command_line.clone())
.ok_or(())
}
fn numbered_history_event(history: &history::History, number: usize) -> Result<String, ()> {
if number == 0 {
return Err(());
}
history
.get(number - 1)
.map(|item| item.command_line.clone())
.ok_or(())
}
fn relative_history_event(history: &history::History, offset: usize) -> Result<String, ()> {
let count = history.count();
if offset == 0 || offset > count {
return Err(());
}
numbered_history_event(history, count - offset + 1)
}
enum HistorySearchMode {
Prefix,
Contains,
}
fn find_history_event(
history: &history::History,
needle: &str,
mode: HistorySearchMode,
) -> Result<String, ()> {
let mut match_result = None;
for item in history.iter() {
let matches = match mode {
HistorySearchMode::Prefix => item.command_line.starts_with(needle),
HistorySearchMode::Contains => item.command_line.contains(needle),
};
if matches {
match_result = Some(item.command_line.clone());
}
}
match_result.ok_or(())
}
fn select_history_words(
event: &str,
selector: char,
number: Option<usize>,
) -> Result<String, ()> {
let words: Vec<&str> = event.split_whitespace().collect();
match selector {
'0'..='9' => {
let index = number.ok_or(())?;
words.get(index).map(|word| (*word).to_owned()).ok_or(())
},
'^' => words.get(1).map(|word| (*word).to_owned()).ok_or(()),
'$' => words.last().map(|word| (*word).to_owned()).ok_or(()),
'*' => Ok(words.get(1..).unwrap_or_default().join(" ")),
'p' => Ok(event.to_owned()),
_ => Err(()),
}
}
fn parse_history_number(chars: &[char], i: usize) -> Option<(usize, usize)> {
if i == chars.len() || !chars[i].is_ascii_digit() {
return None;
}
Some(parse_history_number_from_first(chars, i))
}
fn parse_history_number_from_first(chars: &[char], mut i: usize) -> (usize, usize) {
let mut value = 0;
while i < chars.len() && chars[i].is_ascii_digit() {
value = value * 10 + chars[i].to_digit(10).unwrap_or_default() as usize;
i += 1;
}
(value, i)
}
fn is_history_event_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/')
}
fn display_history(
history: &history::History,
config: &HistoryConfig,
@@ -213,6 +438,30 @@ fn display_history(
Ok(())
}
enum HistoryReadMode {
All,
Unread,
}
fn append_history_file_to_session(
history: &mut history::History,
file_path: PathBuf,
mode: HistoryReadMode,
) -> Result<(), brush_core::Error> {
let file = File::open(file_path)?;
let imported_history = history::History::import(file)?;
let already_read_count = match mode {
HistoryReadMode::All => 0,
HistoryReadMode::Unread => history.iter().filter(|item| !item.dirty).count(),
};
for item in imported_history.iter().skip(already_read_count) {
history.add(item.clone())?;
}
Ok(())
}
fn get_effective_history_file_path(
default_history_file_path: Option<PathBuf>,
option: Option<&String>,
@@ -222,6 +471,12 @@ fn get_effective_history_file_path(
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::Result;
use pretty_assertions::{assert_eq, assert_matches};
@@ -240,4 +495,49 @@ mod tests {
Ok(())
}
#[test]
fn test_append_history_file_to_session_reads_all_entries() -> Result<()> {
let file_path = write_temp_history("history-r", "one\ntwo\n")?;
let mut history = history::History::default();
history.add(history::Item::new("local"))?;
append_history_file_to_session(&mut history, file_path.clone(), HistoryReadMode::All)?;
assert_eq!(history.count(), 3);
assert_eq!(history.get(0).map(|item| item.command_line.as_str()), Some("local"));
assert_eq!(history.get(1).map(|item| item.command_line.as_str()), Some("one"));
assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("two"));
assert_eq!(history.get(1).map(|item| item.dirty), Some(false));
fs::remove_file(file_path)?;
Ok(())
}
#[test]
fn test_append_history_file_to_session_reads_unread_entries_after_clean_history() -> Result<()> {
let initial_file_path = write_temp_history("history-n-initial", "one\ntwo\n")?;
let mut history = history::History::import(fs::File::open(&initial_file_path)?)?;
history.add(history::Item::new("local"))?;
let updated_file_path = write_temp_history("history-n-updated", "one\ntwo\nthree\n")?;
append_history_file_to_session(&mut history, updated_file_path.clone(), HistoryReadMode::Unread)?;
assert_eq!(history.count(), 4);
assert_eq!(history.get(2).map(|item| item.command_line.as_str()), Some("local"));
assert_eq!(history.get(3).map(|item| item.command_line.as_str()), Some("three"));
assert_eq!(history.get(3).map(|item| item.dirty), Some(false));
fs::remove_file(initial_file_path)?;
fs::remove_file(updated_file_path)?;
Ok(())
}
fn write_temp_history(name: &str, contents: &str) -> Result<PathBuf> {
let mut path = std::env::temp_dir();
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
path.push(format!("brush-{name}-{nanos}.history"));
fs::write(&path, contents)?;
Ok(path)
}
}
+34 -7
View File
@@ -1,6 +1,6 @@
use std::io::Write;
use brush_core::{ExecutionResult, builtins, error, jobs};
use brush_core::{ExecutionResult, builtins, jobs};
use clap::Parser;
/// Manage jobs.
@@ -38,22 +38,31 @@ impl builtins::Command for JobsCommand {
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.also_show_pids {
return error::unimp("jobs -l");
}
if self.list_changed_only {
return error::unimp("jobs -n");
for (job, result) in context.shell.jobs_mut().poll()? {
result?;
self.display_job(&context, &job)?;
}
return Ok(ExecutionResult::success());
}
let mut exit_code = ExecutionResult::success();
if self.job_specs.is_empty() {
for job in &context.shell.jobs().jobs {
self.display_job(&context, job)?;
}
} else {
return error::unimp("jobs with job specs");
for job_spec in &self.job_specs {
if let Some(job) = resolve_job_spec(context.shell.jobs(), job_spec) {
self.display_job(&context, job)?;
} else {
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, job_spec)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(ExecutionResult::success())
Ok(exit_code)
}
}
@@ -74,6 +83,14 @@ impl JobsCommand {
if let Some(pid) = job.representative_pid() {
writeln!(context.stdout(), "{pid}")?;
}
} else if self.also_show_pids {
write!(context.stdout(), "[{}]{:3}", job.id, job.annotation())?;
if let Some(pid) = job.representative_pid() {
write!(context.stdout(), "{pid}\t")?;
} else {
write!(context.stdout(), "<pid unknown>\t")?;
}
writeln!(context.stdout(), "{}\t{}", job.state, job.command_line)?;
} else {
writeln!(context.stdout(), "{job}")?;
}
@@ -81,3 +98,13 @@ impl JobsCommand {
Ok(())
}
}
fn resolve_job_spec<'a>(job_manager: &'a jobs::JobManager, job_spec: &str) -> Option<&'a jobs::Job> {
match job_manager.resolve_job_spec_selector(job_spec)? {
jobs::JobSelector::JobId(id) => job_manager.jobs.iter().find(|job| job.id == id),
jobs::JobSelector::ProcessId(pid) => job_manager
.jobs
.iter()
.find(|job| job.representative_pid().is_some_and(|job_pid| job_pid == pid)),
}
}
+59 -32
View File
@@ -1,6 +1,6 @@
use std::io::{Read, Write};
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables};
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, escape, variables};
use clap::Parser;
/// Read lines from standard input into an indexed array variable.
@@ -48,11 +48,8 @@ impl builtins::Command for MapFileCommand {
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.callback_group_size != 5000 || self.callback.is_some() {
return error::unimp("mapfile -C/-c is not yet implemented");
}
if let Some(origin) = self.origin {
if origin < 0 {
@@ -81,33 +78,21 @@ impl builtins::Command for MapFileCommand {
.try_fd(self.fd)
.ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?;
// Read!
let results = self.read_entries(input_file)?;
if let Some(origin) = self.origin {
// -O: preserve existing array, assign at offset.
for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() {
// If the user is getting to wraparounds in *bash*, they got bigger problems.
#[allow(clippy::cast_possible_wrap)]
let elem_idx = elem_idx as i64;
context.shell.env_mut().update_or_add_array_element(
// Read and assign entries. When no origin is specified, bash clears the
// target array before reading; callbacks then see earlier assigned entries
// but not the entry that is currently being delivered to the callback.
if self.origin.is_none() {
context.shell.env_mut().update_or_add(
&self.array_var_name,
(elem_idx + origin).to_string(),
value,
variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![])),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
} else {
// No -O: replace the entire variable (clears existing).
context.shell.env_mut().update_or_add(
&self.array_var_name,
variables::ShellValueLiteral::Array(results),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
if let Some(result) = self.read_entries(input_file, &mut context).await? {
return Ok(result);
}
Ok(ExecutionResult::success())
@@ -115,15 +100,17 @@ impl builtins::Command for MapFileCommand {
}
impl MapFileCommand {
fn read_entries(
async fn read_entries<SE: brush_core::ShellExtensions>(
&self,
mut input_file: brush_core::openfiles::OpenFile,
) -> Result<variables::ArrayLiteral, brush_core::Error> {
context: &mut brush_core::ExecutionContext<'_, SE>,
) -> Result<Option<ExecutionResult>, brush_core::Error> {
let _term_mode = setup_terminal_settings(&input_file)?;
let mut entries = vec![];
let mut entry_count = 0usize;
let mut read_count = 0;
let max_count = self.max_count.try_into()?;
let callback_group_size: usize = self.callback_group_size.try_into()?;
let delimiter = match &self.delimiter {
Some(d) if d.is_empty() => b'\0',
Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'),
@@ -132,7 +119,7 @@ impl MapFileCommand {
let mut buf = [0u8; 1];
while max_count == 0 || entries.len() < max_count {
while max_count == 0 || entry_count < max_count {
let mut line = vec![];
let mut saw_delimiter = false;
@@ -168,12 +155,52 @@ impl MapFileCommand {
}
let line_str = String::from_utf8_lossy(&line).to_string();
let array_index = self.origin.unwrap_or(0) + i64::try_from(entry_count)?;
entries.push((None, line_str));
if let Some(callback) = &self.callback
&& (entry_count + 1) % callback_group_size == 0
{
let result = run_callback(callback, array_index, &line_str, context).await?;
if !result.is_normal_flow() {
return Ok(Some(result));
}
}
Ok(variables::ArrayLiteral(entries))
context.shell.env_mut().update_or_add_array_element(
&self.array_var_name,
array_index.to_string(),
line_str,
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
entry_count += 1;
}
Ok(None)
}
}
async fn run_callback<SE: brush_core::ShellExtensions>(
callback: &str,
array_index: i64,
line: &str,
context: &mut brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, brush_core::Error> {
let index_arg = array_index.to_string();
let index_arg = escape::quote_if_needed(&index_arg, escape::QuoteMode::SingleQuote);
let line_arg = escape::quote_if_needed(line, escape::QuoteMode::SingleQuote);
let mut command = String::with_capacity(callback.len() + index_arg.len() + line_arg.len() + 2);
command.push_str(callback);
command.push(' ');
command.push_str(index_arg.as_ref());
command.push(' ');
command.push_str(line_arg.as_ref());
let source_info = context.shell.call_stack().current_pos_as_source_info();
context.shell.run_string(command, &source_info, &context.params).await
}
fn setup_terminal_settings(
+12 -6
View File
@@ -84,12 +84,6 @@ impl builtins::Command for ReadCommand {
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.use_readline {
return error::unimp("read -e");
}
if self.initial_text.is_some() {
return error::unimp("read -i");
}
// Validate timeout value if provided.
if let Some(result) = self.validate_timeout(&context)? {
@@ -105,6 +99,17 @@ impl builtins::Command for ReadCommand {
let input_stream = context
.try_fd(fd_num)
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?;
// Bash only uses readline for `read -e` when reading from a terminal.
// For non-terminal input, `-e` and `-i` are accepted but do not affect
// the bytes read. `-i` only supplies initial text to readline, so without
// an available readline-backed terminal path it is likewise a no-op.
if self.use_readline && input_stream.is_terminal() {
return error::unimp(if self.initial_text.is_some() {
"read -e -i"
} else {
"read -e"
});
}
// Retrieve effective value of IFS for splitting.
// We convert to owned String to release the borrow before the mutable borrow
@@ -272,6 +277,7 @@ fn build_variable_fields(
///
/// This enum clearly represents all possible outcomes of `read_line()`,
/// making the contract with callers explicit.
#[derive(Debug)]
enum ReadResult {
/// Successfully read a complete line (delimiter or char limit reached).
Line(String),
+15 -2
View File
@@ -1,8 +1,21 @@
use brush_core::{ExecutionResult, builtins, error};
use brush_core::{ExecutionResult, builtins};
/// No-op command. Same with :.
pub(crate) struct TrueCommand {}
const MAN_PAGE: &str = "\
TRUE(1)
NAME
true - return a successful result
SYNOPSIS
true
DESCRIPTION
The true utility returns a successful exit status.
";
impl builtins::SimpleCommand for TrueCommand {
fn get_content(
_name: &str,
@@ -13,7 +26,7 @@ impl builtins::SimpleCommand for TrueCommand {
builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()),
builtins::ContentType::ShortUsage => Ok("true".into()),
builtins::ContentType::ShortDescription => Ok("true - success".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
builtins::ContentType::ManPage => Ok(MAN_PAGE.into()),
}
}
+114 -1
View File
@@ -33,7 +33,9 @@ impl builtins::Command for UmaskCommand {
let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?;
set_umask(parsed)?;
} else {
return brush_core::error::unimp("umask setting mode from symbolic value");
let current_umask = get_umask()?;
let parsed = parse_symbolic_umask(mode, current_umask)?;
set_umask(parsed)?;
}
} else {
let umask = get_umask()?;
@@ -74,6 +76,81 @@ cfg_if! {
}
}
fn parse_symbolic_umask(mode: &str, current_umask: u32) -> Result<nix::sys::stat::mode_t, brush_core::Error> {
let mut umask = current_umask & 0o777;
let mut chars = mode.chars().peekable();
let mut saw_clause = false;
while chars.peek().is_some() {
saw_clause = true;
let mut who_bits = 0;
while let Some(&ch) = chars.peek() {
let bits = match ch {
'u' => 0o700,
'g' => 0o070,
'o' => 0o007,
'a' => 0o777,
_ => break,
};
who_bits |= bits;
chars.next();
}
if who_bits == 0 {
who_bits = 0o777;
}
loop {
let op = chars.next().ok_or(ErrorKind::InvalidUmask)?;
if !matches!(op, '+' | '-' | '=') {
return Err(ErrorKind::InvalidUmask.into());
}
let mut perm_bits = 0;
while let Some(&ch) = chars.peek() {
let bits = match ch {
'r' => 0o444,
'w' => 0o222,
'x' => 0o111,
'+' | '-' | '=' | ',' => break,
_ => return Err(ErrorKind::InvalidUmask.into()),
};
perm_bits |= bits & who_bits;
chars.next();
}
match op {
'+' => umask &= !perm_bits,
'-' => umask |= perm_bits,
'=' => {
umask |= who_bits;
umask &= !perm_bits;
}
_ => unreachable!(),
}
match chars.peek() {
Some(',') => {
chars.next();
if chars.peek().is_none() {
return Err(ErrorKind::InvalidUmask.into());
}
break;
}
Some('+' | '-' | '=') => continue,
Some(_) => return Err(ErrorKind::InvalidUmask.into()),
None => break,
}
}
}
if saw_clause {
Ok(umask as nix::sys::stat::mode_t)
} else {
Err(ErrorKind::InvalidUmask.into())
}
}
fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> {
// value of mode_t can be platform dependent
let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?;
@@ -96,3 +173,39 @@ fn symbolic_mask_from_bits(bits: u32) -> String {
result
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(mode: &str, current_umask: u32) -> u32 {
parse_symbolic_umask(mode, current_umask).unwrap() as u32
}
#[test]
fn parses_symbolic_umask_assignments() {
assert_eq!(parse("u=rwx,g=rx,o=", 0o022), 0o027);
assert_eq!(parse("=r", 0o022), 0o333);
assert_eq!(parse("a=", 0o022), 0o777);
assert_eq!(parse("u=", 0o022), 0o722);
}
#[test]
fn parses_symbolic_umask_incremental_ops() {
assert_eq!(parse("u+rw", 0o777), 0o177);
assert_eq!(parse("g-w", 0o022), 0o022);
assert_eq!(parse("+x", 0o022), 0o022);
assert_eq!(parse("u+r-w", 0o777), 0o377);
assert_eq!(parse("a+r,u-w", 0o777), 0o333);
}
#[test]
fn rejects_invalid_symbolic_umasks() {
assert!(parse_symbolic_umask("", 0o022).is_err());
assert!(parse_symbolic_umask("u", 0o022).is_err());
assert!(parse_symbolic_umask("u+z", 0o022).is_err());
assert!(parse_symbolic_umask("z+r", 0o022).is_err());
assert!(parse_symbolic_umask("u=,", 0o022).is_err());
assert!(parse_symbolic_umask("u,,g=r", 0o022).is_err());
}
}
+26 -4
View File
@@ -42,11 +42,12 @@ impl builtins::Command for UnsetCommand {
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
//
// TODO(nameref): implement nameref
//
if self.name_interpretation.name_references {
return brush_core::error::unimp("unset: name references are not yet implemented");
for name in &self.names {
unset_name_reference(context.shell, name)?;
}
return Ok(ExecutionResult::success());
}
let unspecified = self.name_interpretation.unspecified();
@@ -91,6 +92,27 @@ impl builtins::Command for UnsetCommand {
Ok(ExecutionResult::success())
}
}
fn unset_name_reference(
shell: &mut Shell<impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
let Ok(brush_parser::word::Parameter::Named(name)) =
brush_parser::word::parse_parameter(name, &shell.parser_options())
else {
return Ok(false);
};
if shell
.env()
.get(name.as_str())
.is_some_and(|(_, var)| var.is_treated_as_nameref())
{
shell.env_mut().unset(name.as_str()).map(|v| v.is_some())
} else {
Ok(false)
}
}
fn unset_array_index(
shell: &mut Shell<impl brush_core::ShellExtensions>,
+121 -13
View File
@@ -1,6 +1,12 @@
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error};
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
env::{EnvironmentLookup, EnvironmentScope},
int_utils,
jobs::{Job, JobSelector},
variables::ShellValueLiteral,
};
use clap::Parser;
/// Wait for jobs to terminate.
@@ -31,37 +37,74 @@ impl builtins::Command for WaitCommand {
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.wait_for_terminate {
return error::unimp("wait -f");
}
if self.wait_for_first_or_next {
return error::unimp("wait -n");
}
if self.variable_to_receive_id.is_some() {
return error::unimp("wait -p");
if let Some(variable) = &self.variable_to_receive_id {
context.shell.env_mut().unset(variable)?;
}
let mut result = ExecutionResult::success();
if self.wait_for_first_or_next {
let selectors = match resolve_wait_selectors(&context, &self.ids)? {
WaitSelectorResolution::Selectors(selectors) => selectors,
WaitSelectorResolution::Failure(result) => return Ok(result),
};
let Some(waited) = context.shell.jobs_mut().wait_next(&selectors).await? else {
return Ok(ExecutionExitCode::NotFound.into());
};
if let Some(variable) = &self.variable_to_receive_id {
assign_wait_variable(context.shell, variable, waited.identifier)?;
}
return Ok(waited.result);
}
let mut waited_identifier = None;
if !self.ids.is_empty() {
for id in &self.ids {
if id.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) {
job.wait().await?;
waited_identifier = Some(job_identifier(job));
result = if self.wait_for_terminate {
job.wait_for_termination().await?
} else {
job.wait().await?
};
} else {
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
result = ExecutionExitCode::GeneralError.into();
}
} else if let Ok(pid) = int_utils::parse::<i32>(id, 10) {
if let Some(job) = context.shell.jobs_mut().resolve_process_id(pid) {
waited_identifier = Some(pid.to_string());
result = if self.wait_for_terminate {
job.wait_for_termination().await?
} else {
// It's a process ID.
return error::unimp("wait with process IDs");
job.wait().await?
};
} else {
writeln!(
context.stderr(),
"{}: pid {pid} is not a child of this shell",
context.command_name
)?;
result = ExecutionExitCode::NotFound.into();
}
} else {
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
result = ExecutionExitCode::GeneralError.into();
}
}
} else {
// Wait for all jobs.
let jobs = context.shell.jobs_mut().wait_all().await?;
let jobs = if self.wait_for_terminate {
context.shell.jobs_mut().wait_all_for_termination().await?
} else {
context.shell.jobs_mut().wait_all().await?
};
waited_identifier = jobs.last().map(job_identifier);
if context.shell.options().enable_job_control {
for job in jobs {
@@ -70,6 +113,71 @@ impl builtins::Command for WaitCommand {
}
}
if let (Some(variable), Some(identifier)) = (&self.variable_to_receive_id, waited_identifier) {
assign_wait_variable(context.shell, variable, identifier)?;
}
Ok(result)
}
}
enum WaitSelectorResolution {
Selectors(Vec<JobSelector>),
Failure(ExecutionResult),
}
fn resolve_wait_selectors<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
ids: &[String],
) -> Result<WaitSelectorResolution, brush_core::Error> {
let mut selectors = Vec::new();
for id in ids {
if id.starts_with('%') {
if let Some(selector) = context.shell.jobs().resolve_job_spec_selector(id) {
selectors.push(selector);
} else {
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
return Ok(WaitSelectorResolution::Failure(
ExecutionExitCode::GeneralError.into(),
));
}
} else if let Ok(pid) = int_utils::parse::<i32>(id, 10) {
if context.shell.jobs().contains_process_id(pid) {
selectors.push(JobSelector::ProcessId(pid));
} else {
writeln!(
context.stderr(),
"{}: pid {pid} is not a child of this shell",
context.command_name
)?;
return Ok(WaitSelectorResolution::Failure(ExecutionExitCode::NotFound.into()));
}
} else {
writeln!(context.stderr(), "{}: no such job: {}", context.command_name, id)?;
return Ok(WaitSelectorResolution::Failure(
ExecutionExitCode::GeneralError.into(),
));
}
}
Ok(WaitSelectorResolution::Selectors(selectors))
}
fn assign_wait_variable(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
name: &str,
value: String,
) -> Result<(), brush_core::Error> {
shell.env_mut().update_or_add(
name,
ShellValueLiteral::Scalar(value),
|_| Ok(()),
EnvironmentLookup::Anywhere,
EnvironmentScope::Global,
)
}
fn job_identifier(job: &Job) -> String {
job
.representative_pid()
.map_or_else(|| job.id.to_string(), |pid| pid.to_string())
}
+2
View File
@@ -148,6 +148,7 @@ features = [
"rt-multi-thread",
"signal",
"sync",
"time",
]
[target.'cfg(target_family = "wasm")'.dependencies.tokio]
@@ -156,6 +157,7 @@ features = [
"io-util",
"macros",
"rt",
"time",
"sync",
]
+132 -2
View File
@@ -174,8 +174,138 @@ impl<SE: extensions::ShellExtensions> Registration<SE> {
}
}
fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result<String, error::Error> {
error::unimp("man page rendering is not yet implemented")
fn get_builtin_man_page(name: &str, command: &clap::Command) -> Result<String, error::Error> {
let mut man_page = String::new();
append_man_section(&mut man_page, "NAME");
let description = command
.get_about()
.map_or_else(String::new, std::string::ToString::to_string);
if description.is_empty() {
man_page.push_str(name);
man_page.push('\n');
} else {
man_page.push_str(name);
man_page.push_str(" - ");
man_page.push_str(&description);
man_page.push('\n');
}
append_man_section(&mut man_page, "SYNOPSIS");
let mut usage_command = command.clone();
let usage = usage_command.render_usage();
man_page.push_str(&usage.to_string());
man_page.push('\n');
if let Some(about) = command.get_long_about().or_else(|| command.get_about()) {
append_man_section(&mut man_page, "DESCRIPTION");
man_page.push_str(&about.to_string());
man_page.push('\n');
}
append_man_arguments_section(&mut man_page, command);
append_man_options_section(&mut man_page, command);
Ok(man_page)
}
fn append_man_section(buf: &mut String, title: &str) {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(title);
buf.push('\n');
}
fn append_man_arguments_section(buf: &mut String, command: &clap::Command) {
let mut section_written = false;
for arg in command.get_positionals() {
if arg.is_hide_set() {
continue;
}
if !section_written {
append_man_section(buf, "ARGUMENTS");
section_written = true;
}
write_man_arg_help(buf, &format_man_value_names(arg), arg);
}
}
fn append_man_options_section(buf: &mut String, command: &clap::Command) {
let mut section_written = false;
for arg in command.get_opts() {
if arg.is_hide_set() {
continue;
}
if !section_written {
append_man_section(buf, "OPTIONS");
section_written = true;
}
write_man_arg_help(buf, &format_man_option(arg), arg);
}
}
fn write_man_arg_help(buf: &mut String, label: &str, arg: &clap::Arg) {
buf.push_str(" ");
buf.push_str(label);
buf.push('\n');
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
buf.push_str(" ");
buf.push_str(&help.to_string());
buf.push('\n');
}
}
fn format_man_option(arg: &clap::Arg) -> String {
let mut option = String::new();
if let Some(short) = arg.get_short() {
option.push('-');
option.push(short);
}
if let Some(long) = arg.get_long() {
if !option.is_empty() {
option.push_str(", ");
}
option.push_str("--");
option.push_str(long);
}
let values = format_man_value_names(arg);
if !values.is_empty() {
if !option.is_empty() {
option.push(' ');
}
option.push_str(&values);
}
option
}
fn format_man_value_names(arg: &clap::Arg) -> String {
let Some(value_names) = arg.get_value_names() else {
return String::new();
};
let mut values = String::new();
for name in value_names {
if !values.is_empty() {
values.push(' ');
}
values.push('<');
values.push_str(name);
values.push('>');
}
values
}
fn get_builtin_short_description(name: &str, command: &clap::Command) -> String {
+13 -2
View File
@@ -814,8 +814,19 @@ pub(crate) async fn invoke_shell_function(
// Handle control-flow.
match result.next_control_flow {
ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => {
return error::unimp("break or continue returned from function invocation");
ExecutionControlFlow::BreakLoop { .. } => {
writeln!(
context.params.stderr(context.shell),
"break: only meaningful in a `for', `while', or `until' loop"
)?;
result.next_control_flow = ExecutionControlFlow::Normal;
},
ExecutionControlFlow::ContinueLoop { .. } => {
writeln!(
context.params.stderr(context.shell),
"continue: only meaningful in a `for', `while', or `until' loop"
)?;
result.next_control_flow = ExecutionControlFlow::Normal;
},
ExecutionControlFlow::ReturnFromFunctionOrScript => {
// It's now been handled.
+4
View File
@@ -29,6 +29,10 @@ pub enum ErrorKind {
#[error("cannot assign list to array member")]
AssigningListToArrayMember,
/// An attempt was made to assign an associative array value without using a subscript.
#[error("must use subscript when assigning associative array")]
AssociativeArrayMissingSubscript,
/// An attempt was made to convert an associative array to an indexed array.
#[error("cannot convert associative array to indexed array")]
ConvertingAssociativeArrayToIndexedArray,
@@ -4,7 +4,7 @@ use brush_parser::ast;
use crate::{
ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, extensions,
namedoptions, patterns,
namedoptions, patterns, regex,
sys::{
fs::{MetadataExt, PathExt},
users,
@@ -160,7 +160,13 @@ pub(crate) fn apply_unary_predicate_to_str(
Ok(md.gid() == users::get_effective_gid()?)
},
ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => {
error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead")
let path = shell.absolute_path(Path::new(operand));
if !path.exists() {
return Ok(false);
}
let md = path.metadata()?;
Ok(md.modified()? > md.accessed()?)
},
ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => {
let path = shell.absolute_path(Path::new(operand));
@@ -527,7 +533,15 @@ pub(crate) fn apply_binary_predicate_to_strs(
},
ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right),
ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right),
_ => error::unimp("unsupported test binary predicate"),
ast::BinaryPredicate::StringContainsSubstring => Ok(left.contains(right)),
ast::BinaryPredicate::StringMatchesRegex => {
let re = regex::compile_regex(
right.to_owned(),
shell.options().case_insensitive_conditionals,
true,
)?;
Ok(re.is_match(left)?)
},
}
}
+26 -14
View File
@@ -1766,7 +1766,7 @@ async fn apply_assignment(
existing_value.assign_at_index(array_index, s, assignment.append)?;
},
ShellValueLiteral::Array(_) => {
return error::unimp("replacing an array item with an array");
return Err(error::ErrorKind::AssigningListToArrayMember.into());
},
}
} else {
@@ -1796,7 +1796,7 @@ async fn apply_assignment(
ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)]))
},
ShellValueLiteral::Array(_) => {
return error::unimp("cannot assign list to array member");
return Err(error::ErrorKind::AssigningListToArrayMember.into());
},
}
} else {
@@ -1917,7 +1917,10 @@ pub(crate) async fn setup_redirect(
ast::IoFileRedirectKind::DuplicateInput => 0,
ast::IoFileRedirectKind::DuplicateOutput => 1,
_ => {
return error::unimp("unexpected redirect kind");
return Err(error::ErrorKind::InternalError(format!(
"unexpected redirect kind for file descriptor target: {kind:?}"
))
.into());
},
};
@@ -1937,7 +1940,10 @@ pub(crate) async fn setup_redirect(
ast::IoFileRedirectKind::DuplicateInput => 0,
ast::IoFileRedirectKind::DuplicateOutput => 1,
_ => {
return error::unimp("unexpected redirect kind");
return Err(error::ErrorKind::InternalError(format!(
"unexpected redirect kind for duplicate target: {kind:?}"
))
.into());
},
};
@@ -2008,7 +2014,12 @@ pub(crate) async fn setup_redirect(
params.open_files.set_fd(fd_num, target_file);
},
_ => return error::unimp("invalid process substitution"),
_ => {
return Err(error::ErrorKind::InternalError(format!(
"process substitution used with invalid redirect kind: {kind:?}"
))
.into());
},
}
},
}
@@ -2113,6 +2124,16 @@ fn setup_process_substitution(
let mut child_params = params.clone();
child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup;
// Starting at 63 (a.k.a. 64-1)--and decrementing--look for an
// available fd before starting the substitution command.
let mut candidate_fd_num = 63;
while params.open_files.contains_fd(candidate_fd_num) {
candidate_fd_num -= 1;
if candidate_fd_num == 0 {
return Err(error::ErrorKind::TooManyOpenFiles.into());
}
}
// Set up pipe so we can connect to the command.
let (reader, writer) = std::io::pipe()?;
let (reader, writer) = (reader.into(), writer.into());
@@ -2139,15 +2160,6 @@ fn setup_process_substitution(
.await;
});
// Starting at 63 (a.k.a. 64-1)--and decrementing--look for an
// available fd.
let mut candidate_fd_num = 63;
while params.open_files.contains_fd(candidate_fd_num) {
candidate_fd_num -= 1;
if candidate_fd_num == 0 {
return error::unimp("no available file descriptors");
}
}
Ok((candidate_fd_num, target_file))
}
+176 -15
View File
@@ -1,6 +1,6 @@
//! Job management
use std::{collections::VecDeque, fmt::Display};
use std::{collections::VecDeque, fmt::Display, time::Duration};
#[cfg(windows)]
use std::os::windows::io::OwnedHandle;
@@ -12,6 +12,35 @@ use crate::{ExecutionResult, error, processes, sys, trace_categories, traps};
pub(crate) type JobJoinHandle = tokio::task::JoinHandle<Result<ExecutionResult, error::Error>>;
pub(crate) type JobResult = (Job, Result<ExecutionResult, error::Error>);
const WAIT_NEXT_POLL_INTERVAL: Duration = Duration::from_millis(10);
/// Selects a managed job by shell job ID or child process ID.
#[derive(Clone, Copy)]
pub enum JobSelector {
/// Shell-internal job ID.
JobId(usize),
/// Child process ID.
ProcessId(i32),
}
/// Result returned when waiting for a single managed job.
pub struct WaitedJob {
/// Shell-internal job ID.
pub id: usize,
/// Process ID when known, otherwise the shell-internal job ID.
pub identifier: String,
/// Command line associated with the job.
pub command_line: String,
/// Exit status returned by the job.
pub result: ExecutionResult,
}
impl WaitedJob {
fn from_job(job: Job, result: ExecutionResult, identifier: String) -> Self {
Self { id: job.id, identifier, command_line: job.command_line, result }
}
}
/// Manages the jobs that are currently managed by the shell.
#[derive(Default)]
pub struct JobManager {
@@ -42,17 +71,21 @@ impl JobTask {
}
/// Waits for the task to complete. Returns the result of the wait.
pub async fn wait(&mut self) -> Result<JobTaskWaitResult, error::Error> {
pub async fn wait(
&mut self,
wait_for_terminate: bool,
) -> Result<JobTaskWaitResult, error::Error> {
match self {
Self::External(process) => {
Self::External(process) => loop {
let wait_result = process.wait(None).await?;
match wait_result {
processes::ProcessWaitResult::Completed(output) => {
Ok(JobTaskWaitResult::Completed(output.into()))
break Ok(JobTaskWaitResult::Completed(output.into()));
},
processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped),
processes::ProcessWaitResult::Stopped if wait_for_terminate => {},
processes::ProcessWaitResult::Stopped => break Ok(JobTaskWaitResult::Stopped),
processes::ProcessWaitResult::Cancelled => {
Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130)))
break Ok(JobTaskWaitResult::Completed(ExecutionResult::new(130)));
},
}
},
@@ -162,15 +195,110 @@ impl JobManager {
}
}
/// Tries to resolve the given job specification to a wait selector.
///
/// # Arguments
///
/// * `job_spec` - The job specification to resolve.
pub fn resolve_job_spec_selector(&self, job_spec: &str) -> Option<JobSelector> {
let remainder = job_spec.strip_prefix('%')?;
match remainder {
"%" | "+" => self.current_job().map(|job| JobSelector::JobId(job.id)),
"-" => self.prev_job().map(|job| JobSelector::JobId(job.id)),
s if s.chars().all(char::is_numeric) => {
let id = s.parse::<usize>().ok()?;
self
.jobs
.iter()
.any(|job| job.id == id)
.then_some(JobSelector::JobId(id))
},
_ => {
tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'");
None
},
}
}
/// Returns whether a managed job contains the given process ID.
pub fn contains_process_id(&self, pid: i32) -> bool {
self.jobs.iter().any(|job| job.contains_process_id(pid))
}
/// Tries to resolve the given process ID to a managed job.
///
/// # Arguments
///
/// * `pid` - The process ID to resolve.
pub fn resolve_process_id(&mut self, pid: i32) -> Option<&mut Job> {
self.jobs.iter_mut().find(|job| job.contains_process_id(pid))
}
/// Waits for all managed jobs to complete.
pub async fn wait_all(&mut self) -> Result<Vec<Job>, error::Error> {
self.wait_all_with_policy(false).await
}
/// Waits for all managed jobs to terminate, ignoring stopped-state changes.
pub async fn wait_all_for_termination(&mut self) -> Result<Vec<Job>, error::Error> {
self.wait_all_with_policy(true).await
}
async fn wait_all_with_policy(
&mut self,
wait_for_terminate: bool,
) -> Result<Vec<Job>, error::Error> {
for job in &mut self.jobs {
job.wait().await?;
job.wait_with_policy(wait_for_terminate).await?;
}
Ok(self.sweep_completed_jobs())
}
/// Waits for the next matching managed job to complete.
pub async fn wait_next(
&mut self,
selectors: &[JobSelector],
) -> Result<Option<WaitedJob>, error::Error> {
loop {
let mut found_candidate = false;
let mut i = 0;
while i != self.jobs.len() {
if !selectors.is_empty()
&& !selectors
.iter()
.any(|selector| self.jobs[i].matches_selector(*selector))
{
i += 1;
continue;
}
found_candidate = true;
let identifier = self.jobs[i].wait_identifier();
if let Some(result) = self.jobs[i].poll_done()? {
let job = self.jobs.remove(i);
return result.map(|result| Some(WaitedJob::from_job(job, result, identifier)));
}
if matches!(self.jobs[i].state, JobState::Done) {
let job = self.jobs.remove(i);
return Ok(Some(WaitedJob::from_job(
job,
ExecutionResult::success(),
identifier,
)));
}
i += 1;
}
if !found_candidate {
return Ok(None);
}
tokio::time::sleep(WAIT_NEXT_POLL_INTERVAL).await;
}
}
/// Polls all managed jobs for completion.
pub fn poll(&mut self) -> Result<Vec<JobResult>, error::Error> {
let mut results = vec![];
@@ -371,10 +499,22 @@ impl Job {
/// Waits for the job to complete.
pub async fn wait(&mut self) -> Result<ExecutionResult, error::Error> {
self.wait_with_policy(false).await
}
/// Waits for the job to terminate, ignoring stopped-state changes.
pub async fn wait_for_termination(&mut self) -> Result<ExecutionResult, error::Error> {
self.wait_with_policy(true).await
}
async fn wait_with_policy(
&mut self,
wait_for_terminate: bool,
) -> Result<ExecutionResult, error::Error> {
let mut result = ExecutionResult::success();
while let Some(task) = self.tasks.back_mut() {
match task.wait().await? {
match task.wait(wait_for_terminate).await? {
JobTaskWaitResult::Completed(execution_result) => {
result = execution_result;
self.tasks.pop_back();
@@ -393,16 +533,17 @@ impl Job {
/// Moves the job to execute in the background.
pub fn move_to_background(&mut self) -> Result<(), error::Error> {
if matches!(self.state, JobState::Stopped) {
if let Some(pgid) = self.process_group_id() {
match &self.state {
JobState::Stopped => {
let pgid = self
.process_group_id()
.ok_or(error::ErrorKind::FailedToSendSignal)?;
sys::signal::continue_process(pgid)?;
self.state = JobState::Running;
Ok(())
} else {
Err(error::ErrorKind::FailedToSendSignal.into())
}
} else {
error::unimp("move job to background")
},
JobState::Running => Ok(()),
JobState::Unknown | JobState::Done => Err(error::ErrorKind::FailedToSendSignal.into()),
}
}
@@ -457,6 +598,26 @@ impl Job {
}
}
fn matches_selector(&self, selector: JobSelector) -> bool {
match selector {
JobSelector::JobId(id) => self.id == id,
JobSelector::ProcessId(pid) => self.contains_process_id(pid),
}
}
fn contains_process_id(&self, pid: i32) -> bool {
self.tasks.iter().any(|task| match task {
JobTask::External(process) => process.pid().is_some_and(|process_pid| process_pid == pid),
JobTask::Internal(_) => false,
})
}
fn wait_identifier(&self) -> String {
self
.representative_pid()
.map_or_else(|| self.id.to_string(), |pid| pid.to_string())
}
/// Tries to retrieve a "representative" pid for the job.
pub fn representative_pid(&self) -> Option<sys::process::ProcessId> {
for task in &self.tasks {
+8 -1
View File
@@ -71,7 +71,7 @@ fn format_prompt_piece(
return error::unimp("prompt: current command number");
},
brush_parser::prompt::PromptPiece::CurrentHistoryNumber => {
return error::unimp("prompt: current history number");
format_current_history_number(shell)
},
brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?,
brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { tilde_replaced, basename } => {
@@ -171,6 +171,13 @@ fn format_current_working_directory(
working_dir_str
}
fn format_current_history_number(shell: &Shell<impl extensions::ShellExtensions>) -> String {
// Bash renders \! as the history number that will be assigned to the next
// interactive command. When command history is disabled, bash keeps this at
// 1 rather than rendering 0.
shell.history().map_or(1, |history| history.count() + 1).to_string()
}
fn format_time<Tz: chrono::TimeZone>(
datetime: &chrono::DateTime<Tz>,
format: &brush_parser::prompt::PromptTimeFormat,
+18 -2
View File
@@ -1,7 +1,10 @@
//! Function support for shells.
use std::io::Write;
use crate::{
ExecutionParameters, commands, error, extensions, functions, results::ExecutionWaitResult,
ExecutionParameters, commands, error, extensions, functions, jobs,
results::{ExecutionResult, ExecutionWaitResult},
};
impl<SE: extensions::ShellExtensions> crate::Shell<SE> {
@@ -117,7 +120,20 @@ impl<SE: extensions::ShellExtensions> crate::Shell<SE> {
match result.wait_with_cancel(params.cancel_token()).await? {
ExecutionWaitResult::Completed(result) => Ok(result.exit_code.into()),
ExecutionWaitResult::Stopped(..) => error::unimp("stopped child from function invocation"),
ExecutionWaitResult::Stopped(child) => {
let result = ExecutionResult::stopped();
let job = self.jobs_mut().add_as_current(jobs::Job::new(
[jobs::JobTask::External(child)],
name.to_owned(),
jobs::JobState::Stopped,
));
let formatted = job.to_string();
// N.B. We use the '\r' to overwrite any ^Z output.
writeln!(params.stderr(self), "\r{formatted}")?;
Ok(result.exit_code.into())
},
}
}
}
@@ -2,7 +2,7 @@
use std::path::PathBuf;
use crate::{Shell, error, extensions, interp};
use crate::{Shell, error, expansion, extensions, interp};
/// Behavior for loading profile files.
#[derive(Default)]
@@ -130,14 +130,24 @@ impl<SE: extensions::ShellExtensions> Shell<SE> {
"BASH_ENV"
};
if self.env.is_set(env_var_name) {
//
// TODO(well-known-vars): look at $ENV/BASH_ENV; source its expansion if that
// file exists
//
return error::unimp(
"load config from $ENV/BASH_ENV for non-interactive, non-login shell",
);
if let Some(config_path) = self.env_str(env_var_name) {
let config_path = config_path.into_owned();
let options = expansion::ExpanderOptions {
brace_expand: false,
pathname_expand: false,
..Default::default()
};
let expanded_path = expansion::basic_expand_word_with_options(
self,
&params,
config_path.as_str(),
&options,
)
.await?;
if !expanded_path.is_empty() {
self.source_if_exists(PathBuf::from(expanded_path), &params).await?;
}
}
}
}
+32 -12
View File
@@ -437,10 +437,8 @@ impl ShellVariable {
}
Ok(())
},
_ => {
tracing::error!("assigning to index {array_index} of {:?}", self.value);
error::unimp("assigning to index of non-array variable")
},
ShellValue::Dynamic { .. } => Ok(()),
ShellValue::Unset(_) | ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()),
}
}
@@ -800,15 +798,36 @@ impl ShellValue {
existing_values: &mut BTreeMap<String, String>,
literal_values: ArrayLiteral,
) -> Result<(), error::Error> {
let mut current_key = None;
for (key, value) in literal_values.0 {
if let Some(current_key) = current_key.take() {
if key.is_some() {
return error::unimp("misaligned keys/values in associative array literal");
} else {
existing_values.insert(current_key, value);
let mut literal_values = literal_values.0.into_iter();
let Some((first_key, first_value)) = literal_values.next() else {
return Ok(());
};
if let Some(first_key) = first_key {
existing_values.insert(first_key, first_value);
for (key, value) in literal_values {
let Some(key) = key else {
return Err(error::ErrorKind::AssociativeArrayMissingSubscript.into());
};
existing_values.insert(key, value);
}
} else if let Some(key) = key {
} else {
let mut current_key = Some(first_value);
for (key, value) in literal_values {
let value = if let Some(key) = key {
let mut word = String::with_capacity(key.len() + value.len() + 3);
word.push('[');
word.push_str(key.as_str());
word.push_str("]=");
word.push_str(value.as_str());
word
} else {
value
};
if let Some(key) = current_key.take() {
existing_values.insert(key, value);
} else {
current_key = Some(value);
@@ -818,6 +837,7 @@ impl ShellValue {
if let Some(current_key) = current_key {
existing_values.insert(current_key, String::new());
}
}
Ok(())
}
+197 -1
View File
@@ -8,10 +8,12 @@
//! full span; pointing at a continuation line or a lone closing delimiter
//! resolves to nothing.
use std::collections::BTreeSet;
use anyhow::{Result, anyhow};
use ast_grep_core::tree_sitter::LanguageExt;
use serde::{Deserialize, Serialize};
use tree_sitter::{Parser, Point};
use tree_sitter::{Parser, Point, TreeCursor};
use crate::summary::{node_content_end_line, node_start_line, resolve_language};
@@ -111,6 +113,141 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result<Option<BlockRange>>
}))
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct LineRange {
/// 1-indexed inclusive first visible line.
pub start_line: u32,
/// 1-indexed inclusive last visible line.
pub end_line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnclosingBoundaryOptions {
/// Source code to inspect.
pub code: String,
/// Language alias (e.g. "rust", "typescript") used before path inference.
pub lang: Option<String>,
/// File path used to infer language by extension when `lang` is omitted.
pub path: Option<String>,
/// 1-indexed inclusive visible line ranges (the lines actually shown).
pub ranges: Vec<LineRange>,
}
/// Sort, drop invalid, and merge adjacent/overlapping ranges so visibility
/// tests can binary-search a non-overlapping list.
fn normalize_ranges(mut ranges: Vec<LineRange>) -> Vec<LineRange> {
ranges.retain(|range| range.start_line > 0 && range.end_line >= range.start_line);
ranges.sort_by(|a, b| {
a.start_line
.cmp(&b.start_line)
.then(a.end_line.cmp(&b.end_line))
});
let mut merged: Vec<LineRange> = Vec::with_capacity(ranges.len());
for range in ranges {
if let Some(last) = merged.last_mut()
&& range.start_line <= last.end_line.saturating_add(1)
{
last.end_line = last.end_line.max(range.end_line);
continue;
}
merged.push(range);
}
merged
}
fn is_visible(merged: &[LineRange], line: u32) -> bool {
merged
.binary_search_by(|range| {
if line < range.start_line {
std::cmp::Ordering::Greater
} else if line > range.end_line {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.is_ok()
}
/// Depth-first walk collecting boundary lines from every multi-line named node
/// that straddles a visible-range edge. A single reused [`TreeCursor`] keeps
/// the traversal allocation-free.
fn collect_boundaries(cursor: &mut TreeCursor<'_>, merged: &[LineRange], out: &mut BTreeSet<u32>) {
let node = cursor.node();
// Skip the whole-file root: its only "boundary" is EOF, never a useful
// matching line (mirrors `block_range_at` excluding the root).
if node.is_named() && node.parent().is_some() {
let start = node_start_line(node);
let end = node_content_end_line(node);
if end > start {
let start_visible = is_visible(merged, start);
let end_visible = is_visible(merged, end);
// Opener shown, closer off-window → surface the closer (and vice
// versa). A node fully inside or fully outside the window adds
// nothing.
if start_visible && !end_visible {
out.insert(end);
} else if end_visible && !start_visible {
out.insert(start);
}
}
}
if cursor.goto_first_child() {
loop {
collect_boundaries(cursor, merged, out);
if !cursor.goto_next_sibling() {
break;
}
}
cursor.goto_parent();
}
}
/// Generalize "show the matching bracket" to every tree-sitter block: for each
/// multi-line named node whose span crosses the visible window, return the
/// boundary line sitting *outside* that window.
///
/// - node opens on a visible line but closes past the window → its closing line
/// - node closes on a visible line but opens before the window → its opening
/// line
///
/// Because the trigger is an endpoint *inside* the window, the result is
/// bounded by the window size (not nesting depth), exactly like a bracket scan
/// — but it also covers indentation languages (Python) and uses real syntactic
/// spans.
///
/// Returns `None` when the language is unrecognized or the source fails to
/// parse / carries a syntax error (caller falls back to a lexical bracket
/// scan); `Some(sorted unique boundary lines)` otherwise (possibly empty).
pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result<Option<Vec<u32>>> {
let EnclosingBoundaryOptions { code, lang, path, ranges } = options;
let merged = normalize_ranges(ranges);
if code.is_empty() || merged.is_empty() {
return Ok(Some(Vec::new()));
}
let Some(language) = resolve_language(lang.as_deref(), path.as_deref()) else {
return Ok(None);
};
let mut parser = Parser::new();
parser
.set_language(&language.get_ts_language())
.map_err(|err| anyhow!("Failed to load tree-sitter language: {err}"))?;
let Some(tree) = parser.parse(&code, None) else {
return Ok(None);
};
let root = tree.root_node();
// A file-level syntax error makes error-recovery spans unreliable; defer to
// the lexical scanner rather than emit boundaries off a broken tree.
if root.has_error() {
return Ok(None);
}
let mut boundaries = BTreeSet::new();
let mut cursor = root.walk();
collect_boundaries(&mut cursor, &merged, &mut boundaries);
Ok(Some(boundaries.into_iter().collect()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -220,4 +357,63 @@ mod tests {
let code = "struct A;\nstruct B {\n x: u32,\n}\n";
assert_eq!(resolve(code, "r.rs", 2), Some(BlockRange { start_line: 2, end_line: 4 }));
}
fn boundaries(code: &str, path: &str, ranges: &[(u32, u32)]) -> Option<Vec<u32>> {
enclosing_block_boundaries(EnclosingBoundaryOptions {
code: code.to_string(),
lang: None,
path: Some(path.to_string()),
ranges: ranges
.iter()
.map(|&(start_line, end_line)| LineRange { start_line, end_line })
.collect(),
})
.expect("boundary resolution succeeds")
}
const TS_FN: &str = "function outer() {\n const a = 1;\n const b = 2;\n const c = 3;\n \
return a + b + c;\n}\nafter();\n";
#[test]
fn surfaces_closing_brace_for_visible_opener() {
// Window is the opening line only; its block closes on line 6.
assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 1)]), Some(vec![6]));
}
#[test]
fn surfaces_opening_brace_for_visible_closer() {
// Window is the closing line only; its block opens on line 1.
assert_eq!(boundaries(TS_FN, "x.ts", &[(6, 6)]), Some(vec![1]));
}
#[test]
fn interior_only_window_adds_no_boundary() {
// Neither the opener (1) nor the closer (6) is visible, so the bracket
// scan would add nothing — and neither do we.
assert_eq!(boundaries(TS_FN, "x.ts", &[(3, 4)]), Some(vec![]));
}
#[test]
fn whole_file_window_adds_no_boundary() {
assert_eq!(boundaries(TS_FN, "x.ts", &[(1, 7)]), Some(vec![]));
}
#[test]
fn python_indentation_block_uses_syntactic_span() {
// Python has no closing delimiter — the def's span ends at the last
// body line. Showing the `def` header surfaces that end line.
let code = "def greet(name):\n a = 1\n b = 2\n return a + b\n";
assert_eq!(boundaries(code, "g.py", &[(1, 1)]), Some(vec![4]));
}
#[test]
fn syntax_error_falls_back_to_none() {
let code = "function broken() {\n if (y) {\n";
assert_eq!(boundaries(code, "b.ts", &[(1, 1)]), None);
}
#[test]
fn unrecognized_language_falls_back_to_none() {
assert_eq!(boundaries(TS_FN, "x.unknownext", &[(1, 1)]), None);
}
}
+150
View File
@@ -128,6 +128,45 @@ pub struct AstFindResult {
pub parse_errors: Option<Vec<String>>,
}
/// Options for `astMatch`: run ast-grep patterns against an in-memory source
/// string instead of files on disk.
#[napi(object)]
pub struct AstMatchOptions<'env> {
/// Source code to match against (parsed in memory, never read from disk).
pub source: String,
/// Language of `source` (required; e.g. "ts", "tsx", "rust", "python").
pub lang: String,
/// ast-grep patterns to search for (OR across patterns).
pub patterns: Vec<String>,
/// Rule selector for multi-rule ast-grep configurations.
pub selector: Option<String>,
/// Pattern strictness; defaults to smart matching when omitted.
pub strictness: Option<AstMatchStrictness>,
/// Maximum matches to return after `offset` (default applies when omitted).
pub limit: Option<u32>,
/// Number of leading matches to skip before applying `limit`.
pub offset: Option<u32>,
/// When true, include meta-variable bindings per match.
pub include_meta: Option<bool>,
/// Optional cancellation handle (library-specific).
pub signal: Option<Unknown<'env>>,
/// Wall-clock timeout for the worker task in milliseconds.
pub timeout_ms: Option<u32>,
}
/// Result of an in-memory `astMatch` run.
#[napi(object)]
pub struct AstMatchResult {
/// Page of matches after sort, offset, and limit.
pub matches: Vec<AstFindMatch>,
/// Total matches found before paging (can exceed `matches.length`).
pub total_matches: u32,
/// True when results were truncated by `limit`.
pub limit_reached: bool,
/// Non-fatal parse or pattern-compile errors collected during the run.
pub parse_errors: Option<Vec<String>>,
}
/// Options for `astEdit`: rewrite rules, scan scope, safety limits, and
/// dry-run.
#[napi(object)]
@@ -688,6 +727,117 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Promise<AstFindResult> {
})
}
/// Match ast-grep patterns against an in-memory source string; returns a
/// promise resolved on a worker thread.
///
/// This is the file-free counterpart to [`ast_grep`]: callers that already hold
/// the source (streaming buffers, generated code, editor contents) avoid a
/// temp-file round trip. `lang` is required since there is no path to infer it
/// from.
#[napi]
pub fn ast_match(options: AstMatchOptions<'_>) -> task::Promise<AstMatchResult> {
let AstMatchOptions {
source,
lang,
patterns,
selector,
strictness,
limit,
offset,
include_meta,
signal,
timeout_ms,
} = options;
let ct = task::CancelToken::new(timeout_ms, signal);
let normalized_limit = limit.unwrap_or(DEFAULT_FIND_LIMIT).max(1);
let normalized_offset = offset.unwrap_or(0);
task::blocking("ast_match", ct, move |ct| {
let patterns = normalize_pattern_list(Some(patterns))?;
let strictness = resolve_strictness(strictness);
let include_meta = include_meta.unwrap_or(false);
let lang_str = lang.trim();
if lang_str.is_empty() {
return Err(Error::from_reason("`lang` is required for ast_match".to_string()));
}
let language = resolve_supported_lang(lang_str)?;
let mut parse_errors = Vec::new();
let mut compiled_patterns = Vec::with_capacity(patterns.len());
for pattern in &patterns {
ct.heartbeat()?;
match compile_pattern(pattern, selector.as_deref(), &strictness, language) {
Ok(compiled) => compiled_patterns.push(compiled),
Err(err) => parse_errors.push(format!("{pattern}: {err}")),
}
}
let mut all_matches = Vec::new();
let mut total_matches = 0u32;
if !compiled_patterns.is_empty() {
let ast = language.ast_grep(&source);
if ast.root().dfs().any(|node| node.is_error()) {
parse_errors.push("parse error (syntax tree contains error nodes)".to_string());
}
for pattern in &compiled_patterns {
ct.heartbeat()?;
for matched in ast.root().find_all(pattern.clone()) {
ct.heartbeat()?;
total_matches = total_matches.saturating_add(1);
let range = matched.range();
let start = matched.start_pos();
let end = matched.end_pos();
let meta_variables = if include_meta {
Some(HashMap::<String, String>::from(matched.get_env().clone()))
} else {
None
};
all_matches.push(AstFindMatch {
path: String::new(),
text: matched.text().into_owned(),
byte_start: to_u32(range.start),
byte_end: to_u32(range.end),
start_line: to_u32(start.line().saturating_add(1)),
start_column: to_u32(start.column(matched.get_node()).saturating_add(1)),
end_line: to_u32(end.line().saturating_add(1)),
end_column: to_u32(end.column(matched.get_node()).saturating_add(1)),
meta_variables,
});
}
}
}
all_matches.sort_by(|left, right| {
left
.start_line
.cmp(&right.start_line)
.then(left.start_column.cmp(&right.start_column))
.then(left.end_line.cmp(&right.end_line))
.then(left.end_column.cmp(&right.end_column))
.then(left.byte_start.cmp(&right.byte_start))
.then(left.byte_end.cmp(&right.byte_end))
});
let visible_matches = all_matches
.into_iter()
.skip(normalized_offset as usize)
.collect::<Vec<_>>();
let limit_reached = visible_matches.len() > normalized_limit as usize;
let matches = visible_matches
.into_iter()
.take(normalized_limit as usize)
.collect::<Vec<_>>();
Ok(AstMatchResult {
matches,
total_matches,
limit_reached,
parse_errors: (!parse_errors.is_empty()).then_some(parse_errors),
})
})
}
/// Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns
/// a promise.
#[napi]
+48
View File
@@ -45,3 +45,51 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result<Option<BlockRange>>
.map(|range| range.map(Into::into))
.map_err(|error| Error::from_reason(error.to_string()))
}
#[napi(object)]
pub struct LineRange {
/// 1-indexed inclusive first visible line.
pub start_line: u32,
/// 1-indexed inclusive last visible line.
pub end_line: u32,
}
#[napi(object)]
pub struct EnclosingBoundaryOptions {
/// Source code to inspect.
pub code: String,
/// Language alias (e.g. "rust", "typescript") used before path inference.
pub lang: Option<String>,
/// File path used to infer language by extension when `lang` is omitted.
pub path: Option<String>,
/// 1-indexed inclusive visible line ranges (the lines actually shown).
pub ranges: Vec<LineRange>,
}
/// Matching-bracket context for an arbitrary tree-sitter language.
///
/// For each multi-line named node whose span crosses the visible window, return
/// the boundary line sitting *outside* that window (the closer when the opener
/// is shown, the opener when the closer is shown). Covers brace and indentation
/// languages alike using real syntactic spans.
///
/// Returns `null` when the language is unrecognized or the source fails to
/// parse / carries a syntax error (caller should fall back to a lexical scan);
/// a sorted, unique list of 1-indexed boundary lines otherwise.
#[napi]
pub fn enclosing_block_boundaries(options: EnclosingBoundaryOptions) -> Result<Option<Vec<u32>>> {
pi_ast::block::enclosing_block_boundaries(pi_ast::block::EnclosingBoundaryOptions {
code: options.code,
lang: options.lang,
path: options.path,
ranges: options
.ranges
.into_iter()
.map(|range| pi_ast::block::LineRange {
start_line: range.start_line,
end_line: range.end_line,
})
.collect(),
})
.map_err(|error| Error::from_reason(error.to_string()))
}
+1 -1
View File
@@ -68,5 +68,5 @@ use napi_derive::napi;
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
/// `packages/natives/native/index.js` (which derives the name from
/// `package.json#version`).
#[napi(js_name = "__piNativesV15_10_4")]
#[napi(js_name = "__piNativesV15_10_5")]
pub const fn pi_natives_version_sentinel() {}
+187
View File
@@ -490,6 +490,7 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
}
shell.register_builtin("sleep", builtins::builtin::<SleepCommand, _>());
shell.register_builtin("timeout", builtins::builtin::<TimeoutCommand, _>());
shell.register_builtin("nohup", builtins::builtin::<NohupCommand, _>());
let mut merged_path: Option<String> = None;
for (key, value) in std::env::vars() {
@@ -1573,6 +1574,61 @@ impl builtins::Command for TimeoutCommand {
}
}
}
#[derive(Parser)]
#[command(disable_help_flag = true)]
struct NohupCommand {
#[arg(num_args = 0.., trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
}
impl builtins::Command for NohupCommand {
type Error = brush_core::Error;
fn execute<SE: brush_core::ShellExtensions>(
&self,
context: ExecutionContext<'_, SE>,
) -> impl Future<Output = std::result::Result<ExecutionResult, brush_core::Error>> + Send {
let command = self.command.clone();
async move {
if context.is_cancelled() {
return Ok(ExecutionExitCode::Interrupted.into());
}
// coreutils `nohup` with no operand fails with exit code 125.
if command.is_empty() {
let _ = writeln!(context.stderr(), "nohup: missing operand");
return Ok(ExecutionResult::new(125));
}
// Deliberately *not* nohup: we neither ignore SIGHUP nor detach the
// child into a new session. The command runs as an ordinary brush
// descendant so it is reaped together with the host instead of
// lingering as an orphan once the host process goes away. Agents
// reach for `nohup` assuming the shell is one-shot; in this
// persistent embedded shell that assumption is wrong and the only
// effect of real `nohup` would be to leak background processes.
//
// coreutils `nohup` additionally redirects stdin from /dev/null and
// stdout/stderr to `nohup.out`, but *only* when those streams are
// terminals. The embedded host always hands commands a pipe with a
// /dev/null stdin, so none of that redirection ever applies here.
let mut command_line = String::new();
for (idx, arg) in command.iter().enumerate() {
if idx > 0 {
command_line.push(' ');
}
command_line.push_str(&quote_arg(arg));
}
let params = context.params.clone();
let source_info = SourceInfo::from("pi-natives:nohup");
context
.shell
.run_string(command_line, &source_info, &params)
.await
}
}
}
fn parse_duration(input: &str) -> Option<Duration> {
let trimmed = input.trim();
if trimmed.is_empty() {
@@ -1918,6 +1974,57 @@ mod tests {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_accepts_last_background_process_id() {
let options = ShellExecuteOptions {
command: "/bin/sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(),
..Default::default()
};
let result = execute_shell(options, None, CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(7));
assert!(!result.cancelled);
assert!(!result.timed_out);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_n_p_records_completed_process_id() {
let options = ShellExecuteOptions {
command: "/bin/sh -c 'sleep 0.2; exit 42' & slow=$!; /bin/sh -c 'exit 13' & fast=$!; \
wait -n -p hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" \
-eq 13 ] && [ \"$hit\" = \"$fast\" ]"
.to_string(),
..Default::default()
};
let result = execute_shell(options, None, CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(0));
assert!(!result.cancelled);
assert!(!result.timed_out);
}
#[tokio::test(flavor = "multi_thread")]
async fn wait_f_accepts_process_id() {
let options = ShellExecuteOptions {
command: "/bin/sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(),
..Default::default()
};
let result = execute_shell(options, None, CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(5));
assert!(!result.cancelled);
assert!(!result.timed_out);
}
#[tokio::test]
async fn abort_state_signals_cancel_token() {
let abort_state = ShellAbortState::default();
@@ -2129,4 +2236,84 @@ mod tests {
assert_eq!(result.exit_code, Some(0), "command did not run to completion");
}
/// The `nohup` builtin runs its operand command and surfaces that command's
/// own exit status — not nohup's (`125`/`126`/`127`) error codes.
#[tokio::test(flavor = "multi_thread")]
async fn nohup_builtin_propagates_command_exit_code() {
let options = ShellExecuteOptions {
command: "nohup /bin/sh -c 'exit 7'".to_string(),
..Default::default()
};
let result = execute_shell(options, None, CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(7));
assert!(!result.cancelled);
assert!(!result.timed_out);
}
/// `nohup` with no operand mirrors coreutils: a `missing operand` diagnostic
/// and exit code 125 (a nohup-level error, distinct from any command code).
#[tokio::test(flavor = "multi_thread")]
async fn nohup_builtin_without_command_reports_missing_operand() {
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let options = ShellExecuteOptions { command: "nohup".to_string(), ..Default::default() };
let result = execute_shell(options, Some(tx), CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(125));
let mut out = String::new();
while let Some(chunk) = rx.recv().await {
out.push_str(&chunk);
}
assert!(
out.contains("missing operand"),
"expected a missing-operand diagnostic, got: {out:?}"
);
}
/// The contract that makes this a *builtin* and not the external tool: the
/// child must **not** inherit `SIGHUP = SIG_IGN`. Real `nohup` masks SIGHUP
/// (and it survives `exec`), so a process launched through `/usr/bin/nohup`
/// reports `IGN` here; the builtin runs the command as an ordinary
/// descendant, so it reports `DFL` and dies with the host on hangup. The
/// probe needs `getsid`-style signal introspection, so it is gated on
/// `python3` (skipped, not failed, when absent — matching the embedded
/// session-detach e2e suite).
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn nohup_builtin_does_not_mask_sighup() {
let python_ok = std::process::Command::new("python3")
.arg("-c")
.arg("pass")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success());
if !python_ok {
eprintln!("skipping nohup_builtin_does_not_mask_sighup: python3 unavailable");
return;
}
let probe = "import signal,sys; sys.stdout.write('IGN' if \
signal.getsignal(signal.SIGHUP)==signal.SIG_IGN else 'DFL')";
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let options = ShellExecuteOptions {
command: format!("nohup python3 -c \"{probe}\""),
..Default::default()
};
let result = execute_shell(options, Some(tx), CancelToken::default())
.await
.expect("execute should succeed");
assert_eq!(result.exit_code, Some(0));
let mut out = String::new();
while let Some(chunk) = rx.recv().await {
out.push_str(&chunk);
}
assert!(
out.contains("DFL") && !out.contains("IGN"),
"builtin nohup masked SIGHUP like the external tool (output: {out:?})",
);
}
}
+85
View File
@@ -0,0 +1,85 @@
# Adding a provider
Providers in `packages/ai` are described by a single declarative
`ProviderDefinition` and collected in one registry. Every scattered structure —
the `KnownProvider` / `OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`,
`DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the
`/login` provider list, the `refreshOAuthToken` / `AuthStorage.login` dispatch,
and the coding-agent callback maps — is **derived** from that registry.
**Scope.** This is for a provider that reuses an existing wire API
(`openai-completions`, `anthropic-messages`, `google-generative-ai`, …) — the
common case for gateways and API-key providers, since stream dispatch keys on
`model.api`, not `model.provider`. Adding a *new wire protocol* (a new
`KnownApi`) is a separate task that also touches `stream.ts` dispatch,
`api-registry.ts`, and `types.ts`.
## Shape
For the common case, a provider is still **one new def file + one registry line**:
1. **Create `packages/ai/src/registry/<id>.ts`** exporting one
`export const <camelId>Provider = { … } as const satisfies ProviderDefinition;`.
2. **Add it to the `ALL` array** in `packages/ai/src/registry/registry.ts`
(one import + one array entry). `ALL` order is the `/login` list order for
loginable providers.
That is the full change for:
- env-key-only providers,
- providers with a simple inline API-key login flow,
- most OpenAI-compatible gateways.
For a **non-trivial provider-local OAuth flow**, put the implementation in
`packages/ai/src/registry/oauth/<vendor>.ts` and lazy-import it from the def
file. The shared OAuth flow infrastructure it builds on lives in the same
`registry/oauth/` directory.
Either way, descriptors, default-model map, env-key map, login list, and refresh
dispatch all update automatically, and the `KnownProvider` / `OAuthProvider`
unions gain the new id by derivation.
## `ProviderDefinition` fields
See `packages/ai/src/registry/types.ts` for the authoritative,
JSDoc-annotated interface. Presence of a field opts the provider into a derived
structure:
| Field | Effect |
|---|---|
| `id`, `name` | Required. `name` shows in the `/login` list. |
| `defaultModel` | Present ⇒ member of `KnownProvider` (a chat-model provider). |
| `createModelManagerOptions` | Runtime model-discovery factory. Present (and not `specialModelManager`) ⇒ appears in `PROVIDER_DESCRIPTORS`. |
| `allowUnauthenticated` | Runtime creates a model manager even without a key. |
| `dynamicModelsAuthoritative` | Successful discovery replaces bundled models. |
| `catalogDiscovery` | `{ label, envVars, oauthProvider?, allowUnauthenticated? }` for offline catalog generation (`generate-models.ts`). |
| `specialModelManager` | Bespoke runtime factory (`google-antigravity` / `google-gemini-cli` / `openai-codex`); excluded from `PROVIDER_DESCRIPTORS`. |
| `envKeys` | Env-var fallback for `getEnvApiKey`: a var name string or a `() => string \| undefined` resolver. |
| `login` | Interactive login. Present ⇒ member of `OAuthProvider`, shown in `/login`, dispatchable via `AuthStorage.login`. Returns an api-key `string` or `OAuthCredentials`. |
| `refreshToken` | OAuth refresher; omit for static-token providers (the dispatch returns credentials unchanged). |
| `storeCredentialsAs` | Store credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). |
| `callbackPort` | Present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. |
| `pasteCodeFlow` | OAuth flow needs a pasted code/redirect URL ⇒ member of `PASTE_CODE_LOGIN_PROVIDERS`. |
## Conventions
- Use `... as const satisfies ProviderDefinition` so the literal `id` is preserved
for the union derivation.
- `login` / `refreshToken` for simple API-key or validation-based flows can live
directly in the provider def file (export the named login function there so
tests can import it directly).
- `login` / `refreshToken` for heavy provider-local OAuth flows MUST reach the
adjacent `registry/oauth/*` module via a dynamic-import
thunk (`const { loginX } = await import("./oauth/x"); return loginX(cb);`),
keeping those flows out of the eager startup graph.
- All OAuth code lives under `registry/oauth/`: the shared flow infra
(`callback-server`, `pkce`, `google-oauth-shared`, `types`, the runtime API
`index`) plus every provider flow, including the `github-copilot` / `kimi` /
`openai-codex` helpers reused by the streaming and usage layers. The non-OAuth
API-key helpers (`api-key-login`, `api-key-validation`) sit beside the def
files in `registry/`, since they back simple paste-an-API-key logins.
- For a simple OpenAI-compatible gateway, build the manager inline with the
exported `createSimpleOpenAICompletionsOptions(providerId, baseUrl, config)` —
no edits to `openai-compat.ts` required.
- A `ProviderDefinition` may also be registered at runtime by an extension via
`registerOAuthProvider` (the `AuthStorage.login` dispatcher handles built-ins
and extensions through the same path).
+2 -2
View File
@@ -27,7 +27,7 @@ Tool params:
language: "py" | "js";
code: string;
title?: string;
timeout?: number; // seconds, clamped to 1..600, default 30. Inactivity budget — see "Cell timeout".
timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget — see "Cell timeout".
reset?: boolean; // reset this cell's selected runtime before execution
}>;
}
@@ -232,7 +232,7 @@ Output is streamed through `OutputSink` and may be persisted to artifact storage
- **Python backend not available** — Check `eval.py`, `PI_PY`, and that `python`/`python3` is on PATH. If preflight fails and `eval.js` is enabled, use a `js` cell.
- **No Python on PATH** — Install a system Python 3.8+ or place a venv at `~/.omp/python-env`. `omp setup python --check` reports the resolved interpreter.
- **Execution hangs then times out** — Increase tool `timeout` (max 600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request.
- **Execution hangs then times out** — Increase tool `timeout` (max 3600s) if workload is legitimate. For stuck native code, cancellation triggers `SIGINT` first then escalates; the session restarts on the next request.
- **stdin/input prompts in Python code** — `input()` is not supported; pass data programmatically.
- **Working directory errors** — Tool validates `cwd` exists and is a directory before execution.
+15 -13
View File
@@ -38,6 +38,7 @@ interface Rule {
alwaysApply?: boolean;
description?: string;
condition?: string[];
astCondition?: string[];
scope?: string[];
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
_source: SourceMeta;
@@ -183,16 +184,16 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl
1. Drop rules listed in `ttsr.disabledRules`.
2. Drop rules from the `builtin-defaults` provider when `ttsr.builtinRules === false`.
3. Register rules with non-empty `condition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only.
3. Register rules with a non-empty `condition` or `astCondition` into `TtsrManager`; if registration succeeds, the rule is TTSR-only.
4. Put remaining `alwaysApply === true` rules into `alwaysApplyRules`.
5. Put remaining rules with `description` into `rulebookRules`.
### Bucket behavior
- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets.
- **TTSR bucket**: any enabled rule with a non-empty parsed `condition` (regex) or `astCondition` (ast-grep patterns) that `TtsrManager.addRule(...)` accepts. Takes priority over other buckets.
- **Always-apply bucket**: `alwaysApply === true`, not TTSR. Full content injected into system prompt. Resolvable via `rule://`.
- **Rulebook bucket**: must have description, must not be TTSR, must not be `alwaysApply`. Listed in system prompt by name+description; content read on demand via `rule://`.
- A rule with both `condition` and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply.
- A rule with both a trigger condition and `alwaysApply` goes to TTSR only if TTSR registration accepts it; otherwise it can fall through to always-apply.
- A rule with both `alwaysApply` and `description` goes to always-apply only (not rulebook).
## 6. How metadata affects runtime surfaces
@@ -219,10 +220,11 @@ After rule discovery in `createAgentSession` (`sdk.ts`), `bucketRules(...)` appl
- **Full rule content is auto-injected into the system prompt** (before the rulebook rules section).
- Rule is also addressable via `rule://<name>` for re-reading.
### `condition`, `scope`, and `interruptMode`
### `condition`, `astCondition`, `scope`, and `interruptMode`
- `condition` is the current TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing.
- `scope` narrows TTSR matching scope. A condition token that looks like a file glob becomes `tool:edit(<glob>)` and `tool:write(<glob>)` scope entries plus catch-all condition `.*`.
- `condition` is the regex TTSR trigger field; legacy `ttsr_trigger` / `ttsrTrigger` are accepted as fallback inputs during parsing.
- `astCondition` is the ast-grep trigger field: a string or list of structural patterns, kept verbatim (no glob inference). It only matches on edit/write tool streams, where the language is inferred from the file path. A rule may set `condition`, `astCondition`, or both.
- `scope` narrows TTSR matching scope. A `condition` token that looks like a file glob becomes `tool:edit(<glob>)` and `tool:write(<glob>)` scope entries plus catch-all condition `.*`; `astCondition` tokens never trigger this shorthand.
- `interruptMode` can override the global TTSR interrupt mode for the rule.
## 7. System prompt inclusion path
@@ -240,18 +242,18 @@ This is advisory/contextual: prompt text asks the model to read applicable rules
## 8. `rule://` internal URL behavior
`RuleProtocolHandler` is registered with:
`RuleProtocolHandler` resolves against the process-global active-rule snapshot
installed once per top-level session in `sdk.ts`:
```ts
new RuleProtocolHandler({
getRules: () => [...rulebookRules, ...alwaysApplyRules],
});
setActiveRules([...rulebookRules, ...alwaysApplyRules, ...ttsrManager.getRules()]);
```
Implications:
- `rule://<name>` resolves against both **rulebookRules** and **alwaysApplyRules**.
- TTSR-only rules and rules with no description and no `alwaysApply` are not addressable via `rule://`.
- `rule://<name>` resolves against **rulebookRules**, **alwaysApplyRules**, and **registered TTSR rules**.
- TTSR rules are bucketed out before rulebook/always, but `ttsrManager.getRules()` re-adds them to the snapshot so a triggered rule (e.g. a builtin) stays addressable for re-reading.
- Rules with no description, no `alwaysApply`, and no accepted TTSR condition are not addressable via `rule://`.
- Resolution is exact name match.
- Unknown names return error listing available rule names.
- Returned content is raw `rule.content` (frontmatter stripped), content type `text/markdown`.
@@ -260,5 +262,5 @@ Implications:
1. The rule providers currently loaded for `rules` are `native`, `agents`, `cursor`, `windsurf`, `cline`, and embedded `builtin-defaults`; provider files for other tools may parse other config formats but do not register rule loaders.
2. `globs` metadata is surfaced to prompt/UI and is used as a global path gate for TTSR matching, but it is not used to automatically select rulebook rules for `rule://`.
3. Rule selection for `rule://` includes rulebook and always-apply rules, but not TTSR-only rules.
3. Rule selection for `rule://` includes rulebook, always-apply, and registered TTSR rules (so a triggered TTSR rule can be re-read), but not rules that registered no condition and carry neither a description nor `alwaysApply`.
4. Discovery warnings (`loadCapability("rules").warnings`) are produced but `createAgentSession` does not currently surface/log them in this path.
+5 -1
View File
@@ -179,11 +179,15 @@ export default function rmRfBlocker(omp: HookAPI): void {
```ts
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
// Matches common API key patterns: sk-..., pk-..., AKIA..., ghp_..., etc.
// Common API-key shapes. Not exhaustive — providers using bespoke formats
// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.)
// need their own entries.
const SECRET_PATTERNS = [
/\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g,
/\bAKIA[A-Z0-9]{16}\b/g,
/\bghp_[a-zA-Z0-9]{36}\b/g,
// Zhipu / GLM Coding Plan: `<id>.<secret>` (no `sk-` prefix).
/\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g,
/\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g,
];
+10 -3
View File
@@ -43,12 +43,18 @@ const { rulebookRules, alwaysApplyRules } = bucketRules(
Registration is skipped when:
- `rule.condition` is absent or all condition regexes fail to compile
- both `rule.condition` (regex) and `rule.astCondition` (ast-grep patterns) are absent, or every regex condition fails to compile and there are no AST conditions
- a rule with the same `rule.name` was already registered in this manager
- the rule scope excludes all monitored streams
Invalid regex conditions and unreachable scopes are logged as warnings and ignored; session startup continues. If a TTSR rule defines `globs`, those globs are compiled as a global file-path gate for matching.
### AST conditions (`astCondition`)
A rule may carry `astCondition`: a list of [ast-grep](https://ast-grep.github.io/) patterns (OR'd, same as regex `condition`), matched structurally instead of textually. A repeated metavariable inside one pattern requires both occurrences to be equal (`if ($X) clearTimeout($X)` matches but `if ($X) clearTimeout($Y)` does not).
AST conditions only evaluate on **edit/write tool-argument streams** — they need a language, which is inferred from the file extension on the tool's path argument, and they match against the tool's reconstructed source snapshot (`matcherDigest`), not the raw wire delta. Matching is performed in memory by the native `astMatch` engine (no temp files) with Smart strictness. Streams without a usable file path (prose, thinking, path-less tool calls) skip AST conditions entirely. A rule may mix `condition` and `astCondition`; the regex paths keep working on every scope while AST paths apply only to those tool streams.
### Setting caveat
`TtsrSettings.enabled` is loaded into the manager but is not currently checked in runtime gating. If TTSR rules exist, matching still runs.
@@ -69,9 +75,10 @@ When assistant updates arrive and rules exist:
- monitor `text_delta`, `thinking_delta`, and `toolcall_delta`
- append delta into a source/tool scoped manager buffer
- call `checkDelta(delta, matchContext)`
- call `checkDelta(delta, matchContext)` (synchronous regex matching)
- for edit/write tool streams, when `hasAstRules()` is true, `await checkAstSnapshot(snapshot, matchContext)` (asynchronous AST matching)
`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, condition, and repeat policy checks.
`checkDelta()` iterates registered rules and returns all matching rules that pass scope, global path-glob, regex condition, and repeat policy checks. `checkAstSnapshot()` applies the same scope/path/repeat gates, then runs each candidate rule's `astCondition` patterns against the snapshot via the native `astMatch` engine. It is throttled per stream key: an identical consecutive snapshot (common when only non-source arguments change between deltas) is skipped without re-running the matcher. Both paths feed their matches through the same trigger-decision handler.
## 3. Trigger decision and immediate abort path
+9 -9
View File
@@ -20,15 +20,15 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.0",
"@oh-my-pi/hashline": "15.10.4",
"@oh-my-pi/omp-stats": "15.10.4",
"@oh-my-pi/pi-agent-core": "15.10.4",
"@oh-my-pi/pi-ai": "15.10.4",
"@oh-my-pi/pi-coding-agent": "15.10.4",
"@oh-my-pi/pi-mnemopi": "15.10.4",
"@oh-my-pi/pi-natives": "15.10.4",
"@oh-my-pi/pi-tui": "15.10.4",
"@oh-my-pi/pi-utils": "15.10.4",
"@oh-my-pi/hashline": "15.10.5",
"@oh-my-pi/omp-stats": "15.10.5",
"@oh-my-pi/pi-agent-core": "15.10.5",
"@oh-my-pi/pi-ai": "15.10.5",
"@oh-my-pi/pi-coding-agent": "15.10.5",
"@oh-my-pi/pi-mnemopi": "15.10.5",
"@oh-my-pi/pi-natives": "15.10.5",
"@oh-my-pi/pi-tui": "15.10.5",
"@oh-my-pi/pi-utils": "15.10.5",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
+18
View File
@@ -2,6 +2,24 @@
## [Unreleased]
## [15.10.5] - 2026-06-08
### Removed
- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls
### Fixed
- Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly
- Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies
- Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves.
- Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures.
- Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block
- Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying
- Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors
- Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results
- Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay
- Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls
## [15.10.3] - 2026-06-08
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "15.10.4",
"version": "15.10.5",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+313 -173
View File
@@ -23,6 +23,7 @@ import {
type HarmonyDetection,
type HarmonyRecoveredToolCall,
isHarmonyLeakMitigationTarget,
recoverHarmonyToolCall,
signalListLabel,
} from "./harmony-leak";
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
@@ -68,6 +69,76 @@ class HarmonyLeakInterruption extends Error {
}
}
type AssistantContentBlock = AssistantMessage["content"][number];
type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
type CloneableRecord = Record<string, unknown>;
function cloneUnknown(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneUnknown);
if (!value || typeof value !== "object") return value;
const source = value as CloneableRecord;
const out: CloneableRecord = {};
for (const [key, child] of Object.entries(source)) {
out[key] = cloneUnknown(child);
}
return out;
}
function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] {
return cloneUnknown(args) as AssistantToolCallBlock["arguments"];
}
function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
switch (block.type) {
case "text":
return { ...block };
case "thinking":
return { ...block };
case "redactedThinking":
return { ...block };
case "toolCall":
return { ...block, arguments: cloneToolArguments(block.arguments) };
}
}
function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
return {
...message,
content: message.content.map(snapshotAssistantContentBlock),
usage: {
...message.usage,
cost: { ...message.usage.cost },
},
disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
};
}
function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent {
switch (event.type) {
case "start":
return { ...event, partial: snapshotAssistantMessage(event.partial) };
case "text_start":
case "text_delta":
case "text_end":
case "thinking_start":
case "thinking_delta":
case "thinking_end":
case "toolcall_start":
case "toolcall_delta":
return { ...event, partial: snapshotAssistantMessage(event.partial) };
case "toolcall_end":
return {
...event,
toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
partial: snapshotAssistantMessage(event.partial),
};
case "done":
return { ...event, message: snapshotAssistantMessage(event.message) };
case "error":
return { ...event, error: snapshotAssistantMessage(event.error) };
}
}
/**
* Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
* into a structurally valid {@link AgentToolResult}.
@@ -77,7 +148,7 @@ class HarmonyLeakInterruption extends Error {
* (missing `content` array → crash on reload). We coerce at the single boundary where untyped
* results enter the agent loop, so every downstream consumer can rely on the type.
*/
function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malformed: boolean } {
function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; malformed: boolean } {
const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
const rawContent = rawObj?.content;
const details = rawObj && "details" in rawObj ? rawObj.details : {};
@@ -98,8 +169,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
}
const content: AgentToolResult["content"] = [];
let invalidBlocks = 0;
for (const block of rawContent) {
if (!block || typeof block !== "object" || !("type" in block)) continue;
if (!block || typeof block !== "object" || !("type" in block)) {
invalidBlocks++;
continue;
}
if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
} else if (
@@ -108,9 +183,20 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
typeof (block as { mimeType?: unknown }).mimeType === "string"
) {
content.push(block as { type: "image"; data: string; mimeType: string });
} else {
invalidBlocks++;
}
}
return { result: { content, details, ...(explicitError ? { isError: true } : {}) }, malformed: false };
if (invalidBlocks > 0) {
content.push({
type: "text",
text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
});
}
return {
result: { content, details, ...(explicitError || invalidBlocks > 0 ? { isError: true } : {}) },
malformed: invalidBlocks > 0,
};
}
/**
@@ -176,7 +262,7 @@ export function agentLoopContinue(
(async () => {
const newMessages: AgentMessage[] = [];
const currentContext: AgentContext = { ...context };
const currentContext: AgentContext = { ...context, messages: [...context.messages] };
stream.push({ type: "agent_start" });
stream.push({ type: "turn_start" });
@@ -313,22 +399,26 @@ function normalizeMessagesForProvider(
return messages;
}
let changed = false;
const normalized = messages.map(message => {
let hasThinking = false;
for (const message of messages) {
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
for (const block of message.content) {
if (block.type === "thinking") {
hasThinking = true;
break;
}
}
if (hasThinking) break;
}
if (!hasThinking) return messages;
return messages.map(message => {
if (message.role !== "assistant" || !Array.isArray(message.content)) {
return message;
}
const filtered = message.content.filter(block => block.type !== "thinking");
if (filtered.length === message.content.length) {
return message;
}
changed = true;
return { ...message, content: filtered };
return filtered.length === message.content.length ? message : { ...message, content: filtered };
});
return changed ? normalized : messages;
}
export const INTENT_FIELD = "_i";
@@ -445,27 +535,6 @@ interface StepCounter {
count: number;
}
function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : undefined;
}
function cloneAssistantMessageForToolCallCap(message: AssistantMessage): AssistantMessage {
return {
...message,
content: message.content.map(block => {
if (block.type === "toolCall") {
return { ...block, arguments: structuredClone(block.arguments) };
}
return { ...block };
}),
stopReason: "toolUse",
errorMessage: undefined,
errorStatus: undefined,
};
}
/**
* Resolve aside entries at the moment the loop is about to inject them. Each entry
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
@@ -573,6 +642,12 @@ async function runLoopBody(
continue;
}
}
if (recovered) {
message = snapshotAssistantMessage(message);
currentContext.messages.push(message);
stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
}
newMessages.push(message);
let steeringMessagesFromExecution: AgentMessage[] | undefined;
@@ -661,13 +736,24 @@ async function runLoopBody(
status: "skipped",
});
}
if (message.stopReason === "length" && toolResults.length > 0) {
hasMoreToolCalls = true;
}
}
stream.push({ type: "turn_end", message, toolResults });
const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
if (hasMoreToolCalls) {
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
const asides = resolveAsides(await config.getAsideMessages?.());
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
} else {
// Stop boundary: only steering (live user input) forces another turn here. Leave
// asides for the outer drain below so a passive aside can't trigger an extra model
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
pendingMessages = steering;
}
}
// Agent would stop here. Drain non-interrupting asides + follow-up messages.
@@ -761,18 +847,11 @@ async function streamAssistantResponse(
const dynamicReasoning = config.getReasoning?.();
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
const maxToolCallsPerTurn = normalizeMaxToolCallsPerTurn(config.maxToolCallsPerTurn);
const toolCallCapAbortController = maxToolCallsPerTurn === undefined ? undefined : new AbortController();
const requestSignals: AbortSignal[] = [];
if (signal) requestSignals.push(signal);
if (harmonyAbortController) requestSignals.push(harmonyAbortController.signal);
if (toolCallCapAbortController) requestSignals.push(toolCallCapAbortController.signal);
const requestSignal =
requestSignals.length === 0
? undefined
: requestSignals.length === 1
? requestSignals[0]
: AbortSignal.any(requestSignals);
const requestSignal = harmonyAbortController
? signal
? AbortSignal.any([signal, harmonyAbortController.signal])
: harmonyAbortController.signal
: signal;
const effectiveTemperature =
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
@@ -844,27 +923,27 @@ async function streamAssistantResponse(
let partialMessage: AssistantMessage | null = null;
let addedPartial = false;
const completedToolCallIds = new Set<string>();
const responseIterator = response[Symbol.asyncIterator]();
let completedToolCalls = 0;
let cappedMessage: AssistantMessage | undefined;
let capFinalized = false;
const finishCappedAssistantMessage = async (): Promise<AssistantMessage | undefined> => {
if (!cappedMessage) return undefined;
responseIterator.return?.()?.catch(() => {});
if (!capFinalized) {
if (addedPartial) {
context.messages[context.messages.length - 1] = cappedMessage;
} else {
context.messages.push(cappedMessage);
stream.push({ type: "message_start", message: { ...cappedMessage } });
const finishAbortedStream = async (): Promise<AssistantMessage> => {
try {
const cleanup = responseIterator.return?.();
if (cleanup) void cleanup.catch(() => {});
} catch {
// Provider cancellation failures cannot change the committed aborted message.
}
stream.push({ type: "message_end", message: cappedMessage });
await finishChat(cappedMessage);
capFinalized = true;
}
return cappedMessage;
const aborted = emitAbortedAssistantMessage(
partialMessage,
addedPartial,
completedToolCallIds,
context,
config,
stream,
requestSignal,
);
await finishChat(aborted);
return aborted;
};
// Set up a single abort race: register the abort listener once for the whole
@@ -874,16 +953,7 @@ async function streamAssistantResponse(
let detachAbortListener: (() => void) | undefined;
if (requestSignal) {
if (requestSignal.aborted) {
const aborted = emitAbortedAssistantMessage(
partialMessage,
addedPartial,
context,
config,
stream,
requestSignal,
);
await finishChat(aborted);
return aborted;
return await finishAbortedStream();
}
const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
const onAbort = () => resolve(ABORTED);
@@ -898,45 +968,51 @@ async function streamAssistantResponse(
if (abortRacePromise) {
const result = await Promise.race([responseIterator.next(), abortRacePromise]);
if (result === ABORTED) {
if (toolCallCapAbortController?.signal.aborted) {
const capped = await finishCappedAssistantMessage();
if (capped) return capped;
}
responseIterator.return?.()?.catch(() => {});
const aborted = emitAbortedAssistantMessage(
partialMessage,
addedPartial,
context,
config,
stream,
requestSignal,
);
await finishChat(aborted);
return aborted;
return await finishAbortedStream();
}
next = result;
} else {
next = await responseIterator.next();
}
if (requestSignal?.aborted) {
if (toolCallCapAbortController?.signal.aborted) {
const capped = await finishCappedAssistantMessage();
if (capped) return capped;
}
const aborted = emitAbortedAssistantMessage(
partialMessage,
addedPartial,
context,
config,
stream,
requestSignal,
);
await finishChat(aborted);
return aborted;
}
if (next.done) break;
const event = next.value;
if (event.type === "done" || event.type === "error") {
let finalMessage = retainCompletedToolCalls(await response.result(), completedToolCallIds);
if (harmonyMitigationEnabled) {
const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
if (detection) {
const recovered = recoverHarmonyToolCall(finalMessage, detection);
const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection);
if (addedPartial) {
emitDiscardedHarmonyPartial(
partialMessage,
stream,
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
);
context.messages.pop();
addedPartial = false;
}
throw new HarmonyLeakInterruption(detection, removed, recovered);
}
}
finalMessage = snapshotAssistantMessage(finalMessage);
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
context.messages.push(finalMessage);
}
if (!addedPartial) {
stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
}
stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
await finishChat(finalMessage);
return finalMessage;
}
if (requestSignal?.aborted) {
return await finishAbortedStream();
}
// Yield to the event loop periodically to prevent busy-wait
// when the LLM is streaming chunks faster than the loop can rest.
await yieldIfDue();
@@ -946,7 +1022,7 @@ async function streamAssistantResponse(
partialMessage = event.partial;
context.messages.push(partialMessage);
addedPartial = true;
stream.push({ type: "message_start", message: { ...partialMessage } });
stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
break;
case "text_start":
@@ -959,72 +1035,48 @@ async function streamAssistantResponse(
case "toolcall_delta":
case "toolcall_end":
if (partialMessage) {
if (event.type === "toolcall_end") {
completedToolCallIds.add(event.toolCall.id);
}
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;
config.onAssistantMessageEvent?.(partialMessage, event);
if (signal?.aborted) {
continue;
}
stream.push({
type: "message_update",
assistantMessageEvent: event,
message: { ...partialMessage },
assistantMessageEvent: snapshotAssistantMessageEvent(event),
message: snapshotAssistantMessage(partialMessage),
});
if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) {
completedToolCalls++;
if (completedToolCalls >= maxToolCallsPerTurn) {
cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage);
toolCallCapAbortController?.abort();
const capped = await finishCappedAssistantMessage();
if (capped) return capped;
}
}
}
break;
case "done":
case "error": {
const finalMessage = await response.result();
if (harmonyMitigationEnabled) {
const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
if (detection) {
const removed = extractHarmonyRemoved(finalMessage, detection);
if (addedPartial) {
context.messages.pop();
addedPartial = false;
}
throw new HarmonyLeakInterruption(detection, removed);
}
}
if (addedPartial) {
context.messages[context.messages.length - 1] = finalMessage;
} else {
context.messages.push(finalMessage);
}
if (!addedPartial) {
stream.push({ type: "message_start", message: { ...finalMessage } });
}
stream.push({ type: "message_end", message: finalMessage });
await finishChat(finalMessage);
return finalMessage;
}
}
}
} finally {
detachAbortListener?.();
}
const trailing = await response.result();
let trailing = await response.result();
if (harmonyMitigationEnabled) {
const detection = detectHarmonyLeakInAssistantMessage(trailing);
if (detection) {
const recovered = recoverHarmonyToolCall(trailing, detection);
const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
if (addedPartial) {
emitDiscardedHarmonyPartial(
partialMessage,
stream,
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
);
context.messages.pop();
addedPartial = false;
}
throw new HarmonyLeakInterruption(detection, extractHarmonyRemoved(trailing, detection));
throw new HarmonyLeakInterruption(detection, removed, recovered);
}
}
trailing = snapshotAssistantMessage(trailing);
if (addedPartial) {
context.messages[context.messages.length - 1] = trailing;
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
}
await finishChat(trailing);
return trailing;
});
@@ -1038,6 +1090,33 @@ async function streamAssistantResponse(
}
}
function retainCompletedToolCalls(
message: AssistantMessage,
completedToolCallIds: ReadonlySet<string>,
): AssistantMessage {
if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
let changed = false;
const content = message.content.filter(block => {
if (block.type !== "toolCall") return true;
const keep = completedToolCallIds.has(block.id);
if (!keep) changed = true;
return keep;
});
return changed ? { ...message, content } : message;
}
function emitDiscardedHarmonyPartial(
partialMessage: AssistantMessage | null,
stream: EventStream<AgentEvent, AgentMessage[]>,
errorMessage: string,
): void {
if (!partialMessage) return;
stream.push({
type: "message_end",
message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }),
});
}
/** Resolve the human-readable reason an abort carried. A caller that aborts via
* `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
* (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -1053,16 +1132,31 @@ export function abortReasonText(signal: AbortSignal | undefined): string {
return "Request was aborted";
}
/** True when an abort carried a *deliberate*, human-meaningful reason — a string
* reason or a non-`AbortError` `Error` (TTSR rule match, user-interrupt label).
* A bare `abort()` (default `AbortError` `DOMException`) is anonymous and returns
* false. Used to decide whether a mid-stream tool call survives the abort: a
* deliberate interruption is a conscious decision made after the (partial) call
* was observed, so the block is retained and paired with a labeled placeholder;
* an anonymous abort drops incomplete calls whose args may be unsafe to replay. */
function isExplicitAbortReason(signal: AbortSignal | undefined): boolean {
const reason = signal?.reason;
if (typeof reason === "string") return reason.trim().length > 0;
if (reason instanceof Error) return reason.name !== "AbortError" && reason.message.trim().length > 0;
return false;
}
function emitAbortedAssistantMessage(
partialMessage: AssistantMessage | null,
addedPartial: boolean,
completedToolCallIds: ReadonlySet<string>,
context: AgentContext,
config: AgentLoopConfig,
stream: EventStream<AgentEvent, AgentMessage[]>,
requestSignal: AbortSignal | undefined,
): AssistantMessage {
const errorMessage = abortReasonText(requestSignal);
const abortedMessage: AssistantMessage = partialMessage
const base: AssistantMessage = partialMessage
? { ...partialMessage, stopReason: "aborted", errorMessage }
: {
role: "assistant",
@@ -1082,13 +1176,19 @@ function emitAbortedAssistantMessage(
errorMessage,
timestamp: Date.now(),
};
// A deliberate, labeled abort (TTSR rule match, user interrupt) keeps every
// committed tool-call block so the loop pairs it with a placeholder labeled by
// `errorMessage`; an anonymous abort still drops calls that never completed
// (no `toolcall_end`), whose partial args are unsafe to replay.
const retained = isExplicitAbortReason(requestSignal) ? base : retainCompletedToolCalls(base, completedToolCallIds);
const abortedMessage = snapshotAssistantMessage(retained);
if (addedPartial) {
context.messages[context.messages.length - 1] = abortedMessage;
} else {
context.messages.push(abortedMessage);
stream.push({ type: "message_start", message: { ...abortedMessage } });
stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) });
}
stream.push({ type: "message_end", message: abortedMessage });
stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) });
return abortedMessage;
}
@@ -1126,7 +1226,7 @@ async function executeToolCalls(
: steeringAbortController.signal;
const interruptState = { triggered: false };
let steeringMessages: AgentMessage[] | undefined;
let steeringCheck: Promise<void> | null = null;
let steeringCheckTail: Promise<void> = Promise.resolve();
const records = toolCalls.map(toolCall => ({
toolCall,
@@ -1150,21 +1250,17 @@ async function executeToolCalls(
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
return;
}
if (steeringCheck) {
await steeringCheck;
return;
}
steeringCheck = (async () => {
const check = steeringCheckTail.then(async () => {
if (interruptState.triggered) return;
const steering = await getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
interruptState.triggered = true;
steeringAbortController.abort();
}
})().finally(() => {
steeringCheck = null;
});
await steeringCheck;
steeringCheckTail = check.catch(() => {});
await check;
};
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
@@ -1236,6 +1332,16 @@ async function executeToolCalls(
}
}
record.args = argsForExecution;
if (toolSignal.aborted) {
record.skipped = true;
recordSkippedTool(telemetry, {
toolCallId: toolCall.id,
toolName: toolCall.name,
status: "aborted",
});
emitToolResult(record, createToolSignalAbortedResult(toolSignal), true);
return;
}
record.started = true;
stream.push({
type: "tool_execution_start",
@@ -1259,10 +1365,16 @@ async function executeToolCalls(
let result: AgentToolResult<any> = { content: [], details: {} };
let isError = false;
let caughtError: unknown;
let completedToolExecution = false;
await runInActiveSpan(toolSpan, async () => {
try {
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
if (toolSignal.aborted) {
result = createToolSignalAbortedResult(toolSignal);
isError = true;
return;
}
let effectiveArgs: Record<string, unknown>;
try {
@@ -1289,8 +1401,15 @@ async function executeToolCalls(
throw new ToolCallBlockedError(beforeResult.reason);
}
}
// Reflect post-hook args so emitted tool results / afterToolCall see what actually executed.
record.args = effectiveArgs;
if (toolSignal.aborted) {
result = createToolSignalAbortedResult(toolSignal);
isError = true;
return;
}
const executionArgs = transformToolCallArguments
? transformToolCallArguments(effectiveArgs, toolCall.name)
: effectiveArgs;
record.args = executionArgs;
const toolContext = getToolContext
? getToolContext({
@@ -1302,19 +1421,20 @@ async function executeToolCalls(
: undefined;
const rawResult = await tool.execute(
toolCall.id,
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
executionArgs,
toolSignal,
partialResult => {
stream.push({
type: "tool_execution_update",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: effectiveArgs,
args: executionArgs,
partialResult: coerceToolResult(partialResult).result,
});
},
toolContext,
);
completedToolExecution = true;
const coerced = coerceToolResult(rawResult);
result = coerced.result;
if (coerced.malformed || result.isError) isError = true;
@@ -1327,7 +1447,7 @@ async function executeToolCalls(
isError = true;
}
if (afterToolCall) {
if (afterToolCall && (!toolSignal.aborted || completedToolExecution)) {
try {
const after = await afterToolCall(
{
@@ -1341,12 +1461,17 @@ async function executeToolCalls(
toolSignal,
);
if (after) {
result = {
// Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
// code and may return malformed `content` (non-array / invalid blocks), which
// would otherwise be persisted verbatim and corrupt the session — the same
// hazard `coerceToolResult` guards on the execute path.
const coerced = coerceToolResult({
content: after.content ?? result.content,
details: after.details ?? result.details,
isError: after.isError ?? result.isError,
};
isError = after.isError ?? isError;
});
result = coerced.result;
isError = coerced.malformed || (after.isError ?? isError);
}
} catch (e) {
caughtError = e;
@@ -1360,17 +1485,24 @@ async function executeToolCalls(
});
const interrupted = interruptState.triggered;
if (interrupted) {
const abortedDuringExecution = toolSignal.aborted && isError;
if (interrupted && isError) {
// Steering/abort fired AND this tool failed — it was cut off before producing a
// usable result, so report it as skipped.
record.skipped = true;
emitToolResult(record, createSkippedToolResult(), true);
} else {
// No interrupt, or the tool finished (successfully or with a genuine error) before
// the interrupt landed. Keep its real result: a completed tool already ran its side
// effects, so the model must see what actually happened rather than a false "skipped".
emitToolResult(record, result, isError);
}
const firstTextBlock = result.content?.[0];
const errorMessageForSpan =
caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
const status = interrupted
const status =
(interrupted && isError) || abortedDuringExecution
? "aborted"
: caughtError instanceof ToolCallBlockedError
? "blocked"
@@ -1482,6 +1614,14 @@ function createAbortedToolResult(
return toolResultMessage;
}
function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unknown> {
const reason = abortReasonText(signal);
return {
content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }],
details: {},
};
}
function createSkippedToolResult(): AgentToolResult<any> {
return {
content: [{ type: "text", text: "Skipped due to queued user message." }],
-17
View File
@@ -110,12 +110,6 @@ export interface AgentOptions {
*/
interruptMode?: "immediate" | "wait";
/**
* Maximum completed tool calls to accept from one streamed assistant turn before
* executing the batch. Undefined disables batching.
*/
maxToolCallsPerTurn?: number;
/**
* API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic")
*/
@@ -288,7 +282,6 @@ export class Agent {
#steeringMode: "all" | "one-at-a-time";
#followUpMode: "all" | "one-at-a-time";
#interruptMode: "immediate" | "wait";
#maxToolCallsPerTurn?: number;
#sessionId?: string;
#promptCacheKey?: string;
#metadata?: Record<string, unknown>;
@@ -350,7 +343,6 @@ export class Agent {
this.#steeringMode = opts.steeringMode || "one-at-a-time";
this.#followUpMode = opts.followUpMode || "one-at-a-time";
this.#interruptMode = opts.interruptMode || "immediate";
this.#maxToolCallsPerTurn = opts.maxToolCallsPerTurn;
this.streamFn = opts.streamFn || streamSimple;
this.#sessionId = opts.sessionId;
this.#promptCacheKey = opts.promptCacheKey;
@@ -588,14 +580,6 @@ export class Agent {
this.#maxRetryDelayMs = value;
}
get maxToolCallsPerTurn(): number | undefined {
return this.#maxToolCallsPerTurn;
}
set maxToolCallsPerTurn(value: number | undefined) {
this.#maxToolCallsPerTurn = value;
}
get state(): AgentState {
return this.#state;
}
@@ -967,7 +951,6 @@ export class Agent {
serviceTier: this.#serviceTier,
hideThinkingSummary: this.#hideThinkingSummary,
interruptMode: this.#interruptMode,
maxToolCallsPerTurn: this.#maxToolCallsPerTurn,
sessionId: this.#sessionId,
promptCacheKey: this.#promptCacheKey,
metadata: this.#metadataResolver ? undefined : this.#metadata,
+2 -1
View File
@@ -1869,7 +1869,8 @@ export function finishInvokeAgentSpan(
/**
* Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
are caught and logged via `console.warn` — telemetry callbacks NEVER turn a
* are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn`
* when no hook is set) — telemetry callbacks NEVER turn a
* successful agent run into a failed one. Idempotent at the call site via
* {@link AgentRunCollector.markRunEnded}; callers must check that before
* calling this helper.
-8
View File
@@ -47,14 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
*/
interruptMode?: "immediate" | "wait";
/**
* Maximum completed tool calls to accept from one streamed assistant turn before
* cutting the provider stream and executing that batch. The cap is enforced on
* `toolcall_end` so every executed call has complete arguments. Undefined disables
* batching.
*/
maxToolCallsPerTurn?: number;
/**
* Optional session identifier forwarded to LLM providers.
* Used by providers that support session-based caching (e.g., OpenAI Codex).
+123 -130
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { agentLoop, agentLoopContinue, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop";
import { agentLoop, agentLoopContinue, agentLoopDetailed, INTENT_FIELD } from "@oh-my-pi/pi-agent-core/agent-loop";
import type {
AgentContext,
AgentEvent,
@@ -7,10 +7,9 @@ import type {
AgentMessage,
AgentTool,
AgentToolContext,
StreamFn,
ToolCallContext,
} from "@oh-my-pi/pi-agent-core/types";
import type { AssistantMessage, Message, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, AssistantMessageEvent, Message, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import * as z from "zod/v4";
@@ -56,6 +55,24 @@ describe("agentLoop with AgentMessage", () => {
expect(eventTypes).toContain("agent_end");
});
it("returns detailed telemetry when awaiting detailed() directly", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel({ responses: [{ content: ["Hi there!"] }] });
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const { detailed } = agentLoopDetailed([createUserMessage("Hello")], context, config, undefined, mock.stream);
const result = await detailed();
expect(result.messages).toHaveLength(2);
expect(result.telemetry?.stepCount).toBe(1);
expect(result.telemetry?.chats.total).toBe(1);
expect(result.coverage?.modelsUsed).toEqual([mock.model.id]);
});
it("retries when harmony leakage reaches the committed assistant message (openai-codex)", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
@@ -133,7 +150,7 @@ describe("agentLoop with AgentMessage", () => {
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 };
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const controller = new AbortController();
// The mock provider would reject without a configured response; we want the
// agent's abort path to kick in before any event is emitted. Use a raw stream
@@ -157,6 +174,40 @@ describe("agentLoop with AgentMessage", () => {
expect(events.map(event => event.type)).toContain("agent_end");
});
it("does not wait for provider iterator cleanup when aborting a stalled response", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const controller = new AbortController();
let returnCalled = false;
const streamFn = () =>
({
result: () => Promise.withResolvers<AssistantMessage>().promise,
[Symbol.asyncIterator]: () => ({
next: () => Promise.withResolvers<IteratorResult<AssistantMessageEvent>>().promise,
return: () => {
returnCalled = true;
return Promise.withResolvers<IteratorResult<AssistantMessageEvent>>().promise;
},
}),
}) as AssistantMessageEventStream;
const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn);
queueMicrotask(() => controller.abort("stop now"));
const messages = await stream.result();
expect(returnCalled).toBe(true);
const finalMessage = messages[messages.length - 1];
expect(finalMessage.role).toBe("assistant");
if (finalMessage.role !== "assistant") throw new Error("Expected assistant message");
expect(finalMessage.stopReason).toBe("aborted");
expect(finalMessage.errorMessage).toBe("stop now");
});
it("surfaces a custom abort reason on the synthesized aborted message", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
@@ -164,7 +215,7 @@ describe("agentLoop with AgentMessage", () => {
tools: [],
};
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, maxToolCallsPerTurn: 8 };
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const controller = new AbortController();
const streamFn = () => new AssistantMessageEventStream();
@@ -373,118 +424,6 @@ describe("agentLoop with AgentMessage", () => {
}
});
it("cuts a streamed assistant turn after the configured completed tool-call batch", async () => {
const toolSchema = z.object({ value: z.string() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel();
let modelCalls = 0;
let firstRequestSignal: AbortSignal | undefined;
const makeToolCall = (index: number): AssistantMessage["content"][number] => ({
type: "toolCall",
id: `tool-${index}`,
name: "echo",
arguments: { value: String(index) },
});
const makeMessage = (count: number, stopReason: AssistantMessage["stopReason"] = "stop") =>
createAssistantMessage(
Array.from({ length: count }, (_, index) => makeToolCall(index + 1)),
stopReason,
);
const streamFn: StreamFn = (_model, _llmContext, options) => {
modelCalls++;
const stream = new AssistantMessageEventStream();
if (modelCalls > 1) {
queueMicrotask(() => {
const done = createAssistantMessage([{ type: "text", text: "done" }], "stop");
stream.push({ type: "start", partial: done });
stream.push({ type: "text_start", contentIndex: 0, partial: done });
stream.push({ type: "text_delta", contentIndex: 0, delta: "done", partial: done });
stream.push({ type: "text_end", contentIndex: 0, content: "done", partial: done });
stream.push({ type: "done", reason: "stop", message: done });
});
return stream;
}
queueMicrotask(async () => {
firstRequestSignal = options?.signal;
stream.push({ type: "start", partial: makeMessage(0) });
for (let index = 1; index <= 10; index++) {
if (options?.signal?.aborted) {
const aborted = createAssistantMessage([], "aborted");
stream.push({ type: "error", reason: "aborted", error: aborted });
return;
}
const partial = makeMessage(index);
const toolCall = partial.content[index - 1];
if (toolCall?.type !== "toolCall") throw new Error("Expected tool call");
stream.push({ type: "toolcall_start", contentIndex: index - 1, partial });
stream.push({
type: "toolcall_delta",
contentIndex: index - 1,
delta: JSON.stringify(toolCall.arguments),
partial,
});
stream.push({ type: "toolcall_end", contentIndex: index - 1, toolCall, partial });
await Bun.sleep(0);
}
stream.push({ type: "done", reason: "toolUse", message: makeMessage(10, "toolUse") });
});
return stream;
};
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
maxToolCallsPerTurn: 8,
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo many")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
expect(executed).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]);
expect(firstRequestSignal?.aborted).toBe(true);
expect(modelCalls).toBe(2);
const batchedTurn = events.find(
(event): event is Extract<AgentEvent, { type: "turn_end" }> =>
event.type === "turn_end" && event.toolResults.length === 8,
);
expect(batchedTurn).toBeDefined();
if (batchedTurn?.message.role !== "assistant") return;
expect(batchedTurn.message.stopReason).toBe("toolUse");
expect(batchedTurn.message.content.filter(block => block.type === "toolCall")).toHaveLength(8);
expect(batchedTurn.toolResults.map(result => result.toolCallId).sort()).toEqual([
"tool-1",
"tool-2",
"tool-3",
"tool-4",
"tool-5",
"tool-6",
"tool-7",
"tool-8",
]);
});
it("injects and strips intent when intent tracing is enabled", async () => {
const toolSchema = z.object({ value: z.string() });
const executedParams: Record<string, unknown>[] = [];
@@ -630,7 +569,7 @@ describe("agentLoop with AgentMessage", () => {
expect(turnEndEvent.toolResults.map(result => result.toolCallId)).toEqual(["tool-2", "tool-1"]);
});
it("emits an explicit warning toolResult when assistant aborts after issuing tool calls", async () => {
it("drops incomplete tool calls when assistant aborts before toolcall_end", async () => {
const context: AgentContext = {
systemPrompt: ["You are helpful."],
messages: [],
@@ -641,8 +580,10 @@ describe("agentLoop with AgentMessage", () => {
const mock = createMockModel();
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
// Custom stream: emit a partial start with a tool call, abort, then push done.
// The mock provider doesn't model "abort between start and done"; do it inline.
// Custom stream: emit a partial assistant that already contains a tool
// call, then abort before any `toolcall_end` event proves that the args
// completed. The agent must not synthesize a toolResult for that partial
// call; replaying it would preserve unsafe/incomplete arguments.
const streamFn = () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
@@ -669,16 +610,16 @@ describe("agentLoop with AgentMessage", () => {
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "toolResult",
);
expect(toolResultEvent).toBeDefined();
if (toolResultEvent?.message.role !== "toolResult") return;
expect(toolResultEvent.message.isError).toBe(true);
expect(toolResultEvent.message.toolCallId).toBe("tool-1");
expect(toolResultEvent.message.content[0]?.type).toBe("text");
if (toolResultEvent.message.content[0]?.type === "text") {
const text = toolResultEvent.message.content[0].text;
expect(text).toContain("Tool execution was aborted");
expect(text).not.toContain("Tool execution was aborted.:");
}
expect(toolResultEvent).toBeUndefined();
const assistantEnd = events.find(
(e): e is Extract<AgentEvent, { type: "message_end" }> =>
e.type === "message_end" && e.message.role === "assistant",
);
expect(assistantEnd).toBeDefined();
if (assistantEnd?.message.role !== "assistant") return;
expect(assistantEnd.message.stopReason).toBe("aborted");
expect(assistantEnd.message.content.some(block => block.type === "toolCall")).toBe(false);
});
it("should skip remaining tool calls when steering is queued", async () => {
@@ -1188,6 +1129,55 @@ describe("agentLoopContinue with AgentMessage", () => {
}
});
it("runs afterToolCall for a completed result even when the run aborts before the hook", async () => {
const toolSchema = z.object({ value: z.string() });
const controller = new AbortController();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
controller.abort("stop after tool");
return {
content: [{ type: "text", text: `original: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
let hookSawAbortedSignal = false;
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] },
{ content: ["done"] },
],
});
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
afterToolCall: async (_context, signal) => {
hookSawAbortedSignal = signal?.aborted === true;
return { content: [{ type: "text", text: "rewritten after abort" }] };
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo")], context, config, controller.signal, mock.stream);
for await (const event of stream) {
events.push(event);
}
expect(hookSawAbortedSignal).toBe(true);
const toolEnd = events.find(e => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(false);
expect(toolEnd.result.content).toEqual([{ type: "text", text: "rewritten after abort" }]);
}
});
it("surfaces afterToolCall errors as a tool error result", async () => {
const toolSchema = z.object({ value: z.string() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
@@ -1307,6 +1297,9 @@ describe("agentLoopContinue with AgentMessage", () => {
],
stopReason: "length",
},
{
content: ["ok, I will split the write into smaller chunks"],
},
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
@@ -1,7 +1,7 @@
import { describe, expect, it } from "bun:test";
import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context";
import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types";
import type { Message, Tool } from "@oh-my-pi/pi-ai";
import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "../src/append-only-context";
import type { AgentContext, AgentTool } from "../src/types";
// ---------------------------------------------------------------------------
// Helpers
+3 -3
View File
@@ -1,6 +1,4 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import {
createHarmonyAuditEvent,
detectHarmonyLeak,
@@ -9,7 +7,9 @@ import {
isHarmonyLeakMitigationTarget,
recoverHarmonyToolCall,
signalListLabel,
} from "../src/harmony-leak";
} from "@oh-my-pi/pi-agent-core/harmony-leak";
import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import corpus from "./fixtures/harmony-leak-corpus.json" with { type: "json" };
import { createAssistantMessage } from "./helpers";
+12 -11
View File
@@ -503,12 +503,7 @@ describe("aggregateAgentRunSummaries / aggregateAgentRunCoverage", () => {
describe("onRunEnd is non-fatal", () => {
it("swallows thrown errors and still resolves agentLoop().result() normally", async () => {
const tracer = new RecordingTracer();
const warnings: unknown[][] = [];
const realWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args);
};
try {
const warnings: { code: string; message: string }[] = [];
const mock = createMockModel({ responses: [{ content: ["ok"] }] });
const stream = agentLoop(
[createUserMessage("hi")],
@@ -521,6 +516,12 @@ describe("onRunEnd is non-fatal", () => {
onRunEnd: () => {
throw new Error("user code is buggy");
},
// The failure is surfaced through the telemetry-warning channel, not a
// rejection. `console.warn` is only the no-hook fallback inside
// `emitTelemetryWarning`, so capture via the hook for a deterministic assert.
onTelemetryWarning: warning => {
warnings.push({ code: warning.code, message: warning.message });
},
},
},
undefined,
@@ -528,12 +529,12 @@ describe("onRunEnd is non-fatal", () => {
);
const messages = await stream.result();
expect(messages.length).toBe(2);
} finally {
console.warn = realWarn;
}
// The wrapper must surface the failure via console.warn, not via rejection.
// `fireOnRunEnd` can run in `runLoop`'s finally, after `result()` resolves — flush
// the trailing microtasks of the fire-and-forget agent loop before asserting.
await Bun.sleep(5);
expect(warnings.length).toBeGreaterThanOrEqual(1);
expect(String(warnings[0][0])).toContain("onRunEnd");
expect(warnings.some(w => w.code === "on_run_end_failed" && w.message.includes("onRunEnd"))).toBe(true);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { scheduler } from "node:timers/promises";
import { ExponentialYield, yieldIfDue } from "../src/utils/yield";
import { ExponentialYield, yieldIfDue } from "@oh-my-pi/pi-agent-core/utils/yield";
const YIELD_INTERVAL_MS = 50;
const YIELD_CLOCK_STEP_MS = 60_000;
+30 -1
View File
@@ -2,7 +2,36 @@
## [Unreleased]
## [15.10.5] - 2026-06-08
### Breaking Changes
- Renamed the OAuth subpath export `@oh-my-pi/pi-ai/utils/oauth` → `@oh-my-pi/pi-ai/oauth` (and `@oh-my-pi/pi-ai/utils/oauth/*` → `@oh-my-pi/pi-ai/oauth/*`, e.g. `oauth/types`, `oauth/callback-server`, `oauth/openai-codex`) after relocating the OAuth implementation out of `utils/oauth/` into `registry/oauth/`. The high-level OAuth API (`getOAuthProviders`, `refreshOAuthToken`, `getOAuthApiKey`, `registerOAuthProvider`, `unregisterOAuthProviders`, `getOAuthProvider`) and the `OAuth*` types stay exported from the package root, unchanged.
### Changed
- Changed Anthropic retry handling to avoid retrying 4xx responses other than 408 and 429
- Optimized the Anthropic `cch` attestation patch to locate the billing-header placeholder with native `Buffer.indexOf` (memmem) instead of a hand-rolled byte loop. The marker sits ~99% through the body (`messages` serializes before `system`), so the old scan walked almost the entire payload; output bytes are unchanged but the patch is ~7.5x faster (563µs -> 75µs on a 1MB body).
- Refactored provider configuration to a single-source registry (`registry/`, renamed from `provider-registry/` with its `providers/` subdir flattened up). The `KnownProvider`/`OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, `DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the `/login` provider list (`builtInOAuthProviders`), and the `refreshOAuthToken`/`AuthStorage.login` dispatch are all derived from it. Provider defs live directly under `registry/`; thin provider-specific login flows are inlined into the def file, while heavier provider-local OAuth flows and the shared OAuth flow infra (`callback-server`, `pkce`, `google-oauth-shared`, `types`, runtime `index`) now live together under `registry/oauth/` (previously split across `provider-registry/providers/oauth/` and `utils/oauth/`). The non-OAuth API-key paste/validation helpers (`api-key-login`, `api-key-validation`) sit beside the defs in `registry/`. Adding a provider that reuses an existing wire API is now one new provider def plus one registry entry in the common case. Exposes `PROVIDER_REGISTRY`, `getProviderDefinition`, `ProviderDefinition`, and `PASTE_CODE_LOGIN_PROVIDERS`.
### Fixed
- Disabled OpenAI Codex Responses stream obfuscation by sending `stream_options.include_obfuscation=false`, reducing raw WebSocket/SSE debug noise and bandwidth.
- Interrupted OpenAI Codex Responses streams that emit long runs of whitespace-only tool-call argument deltas, preventing degenerate WebSocket/SSE responses from filling the raw stream buffer indefinitely.
- Preserved streaming responses when Anthropic emits unrecognized content_block envelopes by ignoring unknown blocks and continuing to emit known content
- Applied cache control to the most recent tool result block when building Anthropic OAuth payloads without a preceding text block, enabling ephemeral caching for tool-result-only messages
- Kept Anthropic sampling parameters (temperature, top_p, top_k) when thinking is explicitly disabled
- Fixed raw Anthropic SSE handling by parsing event frames with strict JSON parsing and matching event-type validation, surfacing malformed frames as stream errors instead of repairing them
- Fixed Anthropic stream envelope handling to reject duplicate `content_block_start` indexes and block deltas/stops for unopened blocks, preventing malformed envelope states from producing partial output
- Fixed Anthropic image conversion to normalize `image/jpg` to `image/jpeg` and emit a placeholder for unsupported image MIME types
- Fixed Anthropic thinking request preparation by clamping `max_tokens` to provider/model limits and adjusting thinking budgets to a valid value
- Fixed Anthropic request shaping around forced tool choice, unsigned thinking replay, prompt-cache marker placement, non-Anthropic bearer gateways, Foundry TLS loading, and strict tool-schema normalization so malformed or incompatible request payloads are rejected locally or shaped consistently before streaming
- Fixed the Anthropic stream parser shipping a truncated tool call as a completed turn. When a transport drop cut the SSE stream mid-`tool_use` and a transparent reconnect spliced a fresh message envelope onto the same stream, the duplicate `message_start` was deduped but the orphaned tool block — which never received its `content_block_stop` — survived in the assistant message with its seed `{}` (or partially-parsed) arguments. The terminal stop signal from the reconnect then let it flow through as a normal tool call, so e.g. a `read` dispatched with `{}` failed downstream validation (`path: expected string, received undefined`). The parser now treats any tool block left open at stream end as a truncated envelope and routes it through the existing retry/error path instead of emitting bogus arguments.
- Fixed the Zhipu Coding Plan login prompt advertising a misleading `sk-...` placeholder. Zhipu API keys are formatted `<id>.<secret>` (no `sk-` prefix), so the placeholder now matches the actual format instead of suggesting the wrong shape. ([#2106](https://github.com/can1357/oh-my-pi/issues/2106))
- Fixed Moonshot `kimi-k2.6` (and any future `kimi-k2.x`) discovered via `MOONSHOT_API_KEY` stalling on first turn with no output. The `moonshotModelManagerOptions` discovery mapper only marked ids containing `"thinking"` as `reasoning: true`, so dynamic `kimi-k2.6` entries fell through with `reasoning: false`; the openai-completions z.ai branch was then skipped and the request reached Moonshot with no `thinking` parameter at all. Moonshot K2.6 requires an explicit `thinking: {type}` field (the same native-API wire shape #1838 introduced `thinking.keep` for), so the server held the stream silently. The mapper now stamps `reasoning: true`, vision input, and default `thinking` metadata on every `kimi-k2.x` id, restoring the explicit `thinking: {type: "disabled"|"enabled"}` wire body the Moonshot endpoint expects. ([#2113](https://github.com/can1357/oh-my-pi/issues/2113))
## [15.10.4] - 2026-06-08
### Added
- Added `anthropic-client-platform` (`desktop_app`) and `anthropic-client-version` (`1.11187.4`) headers to the Anthropic request fingerprint for OAuth sessions
@@ -20,8 +49,8 @@
- Removed the synthetic `<turn-aborted>` developer guidance note that `transformMessages` injected after an aborted/errored assistant turn (and its `turn-aborted-guidance.md` prompt). The per-call synthetic `"aborted"` tool results already tell the model the turn's tools were terminated, so the extra "verify current state before retrying" note was redundant — and it biased the model toward second-guessing a deliberate user interrupt when the turn was resumed.
- Removed the legacy Anthropic first-user-message skip for `<system-reminder>` blocks now that synthetic reminders no longer travel as user messages.
## [15.10.2] - 2026-06-08
### Added
- Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`.
+15 -7
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "15.10.4",
"version": "15.10.5",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://omp.sh",
"author": "Can Boluk",
@@ -120,13 +120,21 @@
"types": "./src/utils/discovery/*.ts",
"import": "./src/utils/discovery/*.ts"
},
"./utils/oauth": {
"types": "./src/utils/oauth/index.ts",
"import": "./src/utils/oauth/index.ts"
"./oauth": {
"types": "./src/registry/oauth/index.ts",
"import": "./src/registry/oauth/index.ts"
},
"./utils/oauth/*": {
"types": "./src/utils/oauth/*.ts",
"import": "./src/utils/oauth/*.ts"
"./oauth/*": {
"types": "./src/registry/oauth/*.ts",
"import": "./src/registry/oauth/*.ts"
},
"./registry": {
"types": "./src/registry/index.ts",
"import": "./src/registry/index.ts"
},
"./registry/oauth": {
"types": "./src/registry/oauth/index.ts",
"import": "./src/registry/oauth/index.ts"
},
"./utils/schema": {
"types": "./src/utils/schema/index.ts",
+1 -1
View File
@@ -38,10 +38,10 @@ import {
} from "../src/provider-models/openai-compat";
import { getGitLabDuoModels } from "../src/providers/gitlab-duo";
import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants";
import type { OAuthProvider } from "../src/registry/oauth/types";
import type { Model } from "../src/types";
import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity";
import { fetchCodexModels } from "../src/utils/discovery/codex";
import type { OAuthProvider } from "../src/utils/oauth/types";
const packageRoot = path.join(import.meta.dir, "..");
+1 -1
View File
@@ -17,9 +17,9 @@ import {
REMOTE_REFRESH_SENTINEL,
type StoredAuthCredential,
} from "../auth-storage";
import type { OAuthCredentials } from "../registry/oauth/types";
import type { Provider } from "../types";
import type { UsageReport } from "../usage";
import type { OAuthCredentials } from "../utils/oauth/types";
import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client";
import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
+15 -329
View File
@@ -13,6 +13,9 @@ import * as path from "node:path";
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
import type { ApiKeyResolver } from "./auth-retry";
import { isUsageLimitError } from "./rate-limit-utils";
import { getProviderDefinition } from "./registry";
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
import type { Provider } from "./types";
import type {
@@ -32,11 +35,6 @@ import { antigravityUsageProvider } from "./usage/google-antigravity";
import { kimiUsageProvider } from "./usage/kimi";
import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
import { zaiUsageProvider } from "./usage/zai";
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth";
import { loginDeepSeek } from "./utils/oauth/deepseek";
import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
import { loginXiaomi, loginXiaomiTokenPlan } from "./utils/oauth/xiaomi";
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
@@ -1554,345 +1552,33 @@ export class AuthStorage {
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
},
): Promise<void> {
let credentials: OAuthCredentials;
const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
await this.set(provider, newCredential);
};
const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
switch (provider) {
case "anthropic": {
const { loginAnthropic } = await import("./utils/oauth/anthropic");
credentials = await loginAnthropic({
...ctrl,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
});
break;
}
case "xai-oauth": {
const { loginXAIOAuth } = await import("./utils/oauth/xai-oauth");
credentials = await loginXAIOAuth({
...ctrl,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
});
break;
}
case "alibaba-coding-plan": {
const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan");
const apiKey = await loginAlibabaCodingPlan(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "github-copilot": {
const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot");
credentials = await loginGitHubCopilot({
onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
onPrompt: ctrl.onPrompt,
onProgress: ctrl.onProgress,
signal: ctrl.signal,
});
break;
}
case "google-gemini-cli": {
const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli");
credentials = await loginGeminiCli({
...ctrl,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
});
break;
}
case "google-antigravity": {
const { loginAntigravity } = await import("./utils/oauth/google-antigravity");
credentials = await loginAntigravity({
...ctrl,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
});
break;
}
case "openai-codex": {
const { loginOpenAICodex } = await import("./utils/oauth/openai-codex");
credentials = await loginOpenAICodex({
...ctrl,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
});
break;
}
case "openai-codex-device": {
// Device/headless flow — stores credentials under "openai-codex" so the
// provider can pick them up without a separate provider configuration.
const deviceCredentials = await loginOpenAICodexDevice(ctrl);
const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials };
await this.#upsertOAuthCredential("openai-codex", newCredential);
return;
}
case "gitlab-duo": {
const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo");
credentials = await loginGitLabDuo({
...ctrl,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
});
break;
}
case "kimi-code": {
const { loginKimi } = await import("./utils/oauth/kimi");
credentials = await loginKimi(ctrl);
break;
}
case "kilo": {
const { loginKilo } = await import("./utils/oauth/kilo");
credentials = await loginKilo(ctrl);
break;
}
case "cursor": {
const { loginCursor } = await import("./utils/oauth/cursor");
credentials = await loginCursor(
url => ctrl.onAuth({ url }),
ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined,
);
break;
}
case "perplexity": {
const { loginPerplexity } = await import("./utils/oauth/perplexity");
credentials = await loginPerplexity(ctrl);
break;
}
case "huggingface": {
const { loginHuggingface } = await import("./utils/oauth/huggingface");
const apiKey = await loginHuggingface(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "opencode-zen":
case "opencode-go": {
const { loginOpenCode } = await import("./utils/oauth/opencode");
const apiKey = await loginOpenCode(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "lm-studio": {
const { loginLmStudio } = await import("./utils/oauth/lm-studio");
const apiKey = await loginLmStudio(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "ollama": {
const { loginOllama } = await import("./utils/oauth/ollama");
const apiKey = await loginOllama(ctrl);
if (!apiKey) {
return;
}
await saveApiKeyCredential(apiKey);
return;
}
case "ollama-cloud": {
const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud");
const apiKey = await loginOllamaCloud(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "cerebras": {
const { loginCerebras } = await import("./utils/oauth/cerebras");
const apiKey = await loginCerebras(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "deepseek": {
const apiKey = await loginDeepSeek(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "fireworks": {
const { loginFireworks } = await import("./utils/oauth/fireworks");
const apiKey = await loginFireworks(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "firepass": {
const { loginFirepass } = await import("./utils/oauth/firepass");
const apiKey = await loginFirepass(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "wafer-pass": {
const { loginWaferPass } = await import("./utils/oauth/wafer");
const apiKey = await loginWaferPass(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "wafer-serverless": {
const { loginWaferServerless } = await import("./utils/oauth/wafer");
const apiKey = await loginWaferServerless(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "zai": {
const { loginZai } = await import("./utils/oauth/zai");
const apiKey = await loginZai(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "zhipu-coding-plan": {
const { loginZhipuCodingPlan } = await import("./utils/oauth/zhipu");
const apiKey = await loginZhipuCodingPlan(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "qianfan": {
const { loginQianfan } = await import("./utils/oauth/qianfan");
const apiKey = await loginQianfan(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "minimax-code": {
const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code");
const apiKey = await loginMiniMaxCode(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "minimax-code-cn": {
const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code");
const apiKey = await loginMiniMaxCodeCn(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "synthetic": {
const { loginSynthetic } = await import("./utils/oauth/synthetic");
const apiKey = await loginSynthetic(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "tavily": {
const { loginTavily } = await import("./utils/oauth/tavily");
const apiKey = await loginTavily(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "venice": {
const { loginVenice } = await import("./utils/oauth/venice");
const apiKey = await loginVenice(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "litellm": {
const { loginLiteLLM } = await import("./utils/oauth/litellm");
const apiKey = await loginLiteLLM(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "moonshot": {
const { loginMoonshot } = await import("./utils/oauth/moonshot");
const apiKey = await loginMoonshot(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "kagi": {
const { loginKagi } = await import("./utils/oauth/kagi");
const apiKey = await loginKagi(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "nanogpt": {
const { loginNanoGPT } = await import("./utils/oauth/nanogpt");
const apiKey = await loginNanoGPT(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "openrouter": {
const { loginOpenRouter } = await import("./utils/oauth/openrouter");
const apiKey = await loginOpenRouter(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "together": {
const { loginTogether } = await import("./utils/oauth/together");
const apiKey = await loginTogether(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "cloudflare-ai-gateway": {
const { loginCloudflareAiGateway } = await import("./utils/oauth/cloudflare-ai-gateway");
const apiKey = await loginCloudflareAiGateway(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "vercel-ai-gateway": {
const { loginVercelAiGateway } = await import("./utils/oauth/vercel-ai-gateway");
const apiKey = await loginVercelAiGateway(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "vllm": {
const { loginVllm } = await import("./utils/oauth/vllm");
const apiKey = await loginVllm(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "parallel": {
const { loginParallel } = await import("./utils/oauth/parallel");
const apiKey = await loginParallel(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "qwen-portal": {
const { loginQwenPortal } = await import("./utils/oauth/qwen-portal");
const apiKey = await loginQwenPortal(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "nvidia": {
const { loginNvidia } = await import("./utils/oauth/nvidia");
const apiKey = await loginNvidia(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "xiaomi": {
const apiKey = await loginXiaomi(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
case "xiaomi-token-plan-sgp": {
const apiKey = await loginXiaomiTokenPlan(ctrl, "sgp");
await saveApiKeyCredential(apiKey);
return;
}
case "xiaomi-token-plan-ams": {
const apiKey = await loginXiaomiTokenPlan(ctrl, "ams");
await saveApiKeyCredential(apiKey);
return;
}
case "xiaomi-token-plan-cn": {
const apiKey = await loginXiaomiTokenPlan(ctrl, "cn");
await saveApiKeyCredential(apiKey);
return;
}
case "zenmux": {
const { loginZenMux } = await import("./utils/oauth/zenmux");
const apiKey = await loginZenMux(ctrl);
await saveApiKeyCredential(apiKey);
return;
}
default: {
const customProvider = getOAuthProvider(provider);
if (!customProvider) {
// Built-in registry first, then runtime-registered extension providers.
const def = getProviderDefinition(provider) ?? getOAuthProvider(provider);
if (!def?.login) {
throw new Error(`Unknown OAuth provider: ${provider}`);
}
const customLoginResult = await customProvider.login({
onAuth: info => ctrl.onAuth(info),
const result = await def.login({
onAuth: ctrl.onAuth,
onProgress: ctrl.onProgress,
onPrompt: ctrl.onPrompt,
onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
signal: ctrl.signal,
});
if (typeof customLoginResult === "string") {
await saveApiKeyCredential(customLoginResult);
if (typeof result === "string") {
// Some flows (e.g. ollama) return "" to signal that no key was entered.
if (!result) {
return;
}
credentials = customLoginResult;
break;
await saveApiKeyCredential(result);
return;
}
}
const newCredential: OAuthCredential = { type: "oauth", ...credentials };
await this.#upsertOAuthCredential(provider, newCredential);
const newCredential: OAuthCredential = { type: "oauth", ...result };
await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
}
/**
+1 -7
View File
@@ -29,6 +29,7 @@ export * from "./providers/openai-completions";
export * from "./providers/openai-responses";
export * from "./providers/synthetic";
export * from "./rate-limit-utils";
export * from "./registry";
export * from "./stream";
export * from "./types";
export * from "./usage";
@@ -43,13 +44,6 @@ export * from "./usage/zai";
export * from "./utils/anthropic-auth";
export * from "./utils/discovery";
export * from "./utils/event-stream";
export * from "./utils/oauth";
export type {
OAuthCredentials,
OAuthProvider,
OAuthProviderId,
OAuthProviderInfo,
} from "./utils/oauth/types";
export * from "./utils/overflow";
export * from "./utils/retry";
export * from "./utils/schema";
+11 -11
View File
@@ -38413,9 +38413,9 @@
"image"
],
"cost": {
"input": 0.6,
"output": 2.4,
"cacheRead": 0.12,
"input": 0.3,
"output": 1.2,
"cacheRead": 0.06,
"cacheWrite": 0
},
"contextWindow": 512000,
@@ -40159,13 +40159,13 @@
"image"
],
"cost": {
"input": 0.684,
"output": 3.42,
"cacheRead": 0.144,
"input": 0.6799999999999999,
"output": 3.41,
"cacheRead": 0.33999999999999997,
"cacheWrite": 0
},
"contextWindow": 262144,
"maxTokens": 262144,
"maxTokens": 262142,
"thinking": {
"mode": "effort",
"minLevel": "minimal",
@@ -43304,13 +43304,13 @@
"image"
],
"cost": {
"input": 0.684,
"output": 3.42,
"cacheRead": 0.144,
"input": 0.6799999999999999,
"output": 3.41,
"cacheRead": 0.33999999999999997,
"cacheWrite": 0
},
"contextWindow": 262144,
"maxTokens": 262144,
"maxTokens": 262142,
"thinking": {
"mode": "effort",
"minLevel": "minimal",
+36 -357
View File
@@ -1,364 +1,43 @@
/**
* Unified provider descriptors — single source of truth for provider metadata
* used by both runtime model discovery (model-registry.ts) and catalog
* generation (generate-models.ts).
* Provider descriptors and the default-model map, derived from the single-source
* provider registry (`../registry`).
*
* The descriptor/catalog types and guards now live in the registry; they are
* re-exported here for back-compat with `generate-models.ts` and existing
* `@oh-my-pi/pi-ai/provider-models` consumers.
*/
import type { ModelManagerOptions } from "../model-manager";
import type { Api, KnownProvider } from "../types";
import type { OAuthProvider } from "../utils/oauth/types";
import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google";
import { ollamaCloudModelManagerOptions } from "./ollama";
import {
alibabaCodingPlanModelManagerOptions,
anthropicModelManagerOptions,
cerebrasModelManagerOptions,
cloudflareAiGatewayModelManagerOptions,
deepseekModelManagerOptions,
firepassModelManagerOptions,
fireworksModelManagerOptions,
githubCopilotModelManagerOptions,
groqModelManagerOptions,
huggingfaceModelManagerOptions,
kiloModelManagerOptions,
kimiCodeModelManagerOptions,
litellmModelManagerOptions,
lmStudioModelManagerOptions,
mistralModelManagerOptions,
moonshotModelManagerOptions,
nanoGptModelManagerOptions,
nvidiaModelManagerOptions,
ollamaModelManagerOptions,
openaiModelManagerOptions,
opencodeGoModelManagerOptions,
opencodeZenModelManagerOptions,
openrouterModelManagerOptions,
qianfanModelManagerOptions,
qwenPortalModelManagerOptions,
syntheticModelManagerOptions,
togetherModelManagerOptions,
veniceModelManagerOptions,
vercelAiGatewayModelManagerOptions,
vllmModelManagerOptions,
waferPassModelManagerOptions,
waferServerlessModelManagerOptions,
xaiModelManagerOptions,
xaiOAuthModelManagerOptions,
xiaomiModelManagerOptions,
zenmuxModelManagerOptions,
zhipuCodingPlanModelManagerOptions,
} from "./openai-compat";
import { cursorModelManagerOptions, zaiModelManagerOptions } from "./special";
import { PROVIDER_REGISTRY } from "../registry";
import type { ProviderDescriptor } from "../registry/types";
import type { KnownProvider } from "../types";
/** Catalog discovery configuration for providers that support endpoint-based model listing. */
export interface CatalogDiscoveryConfig {
/** Human-readable name for log messages. */
label: string;
/** Environment variables to check for API keys during catalog generation. */
envVars: string[];
/** OAuth provider for credential refresh during catalog generation. */
oauthProvider?: OAuthProvider;
/** When true, catalog discovery proceeds even without credentials. */
allowUnauthenticated?: boolean;
}
export * from "../registry/types";
/** Unified provider descriptor used by both runtime discovery and catalog generation. */
export interface ProviderDescriptor {
providerId: KnownProvider;
createModelManagerOptions(config: { apiKey?: string; baseUrl?: string }): ModelManagerOptions<Api>;
/** Preferred model ID when no explicit selection is made. */
defaultModel: string;
/** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */
allowUnauthenticated?: boolean;
/** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */
dynamicModelsAuthoritative?: boolean;
/** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */
catalogDiscovery?: CatalogDiscoveryConfig;
}
/** A provider descriptor that has catalog discovery configured. */
export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig };
/** Type guard for descriptors with catalog discovery. */
export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor {
return d.catalogDiscovery != null;
}
/** Whether catalog discovery may run without provider credentials. */
export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean {
return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false;
}
function descriptor(
providerId: KnownProvider,
defaultModel: string,
createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"],
options: Pick<ProviderDescriptor, "allowUnauthenticated" | "dynamicModelsAuthoritative"> = {},
): ProviderDescriptor {
return {
providerId,
defaultModel,
/**
* Runtime model-discovery descriptors: every registry provider that exposes a
* standard model-manager factory. Special-managed providers
* (`google-antigravity`/`google-gemini-cli`/`openai-codex`) are built bespoke in
* the coding-agent runtime and are excluded here.
*/
export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = PROVIDER_REGISTRY.flatMap(provider => {
const { createModelManagerOptions } = provider;
if (!createModelManagerOptions || provider.specialModelManager) {
return [];
}
return [
{
providerId: provider.id,
defaultModel: provider.defaultModel ?? "",
createModelManagerOptions,
...options,
};
}
allowUnauthenticated: provider.allowUnauthenticated,
dynamicModelsAuthoritative: provider.dynamicModelsAuthoritative,
catalogDiscovery: provider.catalogDiscovery,
},
];
});
function catalog(
label: string,
envVars: string[],
options: Pick<CatalogDiscoveryConfig, "oauthProvider" | "allowUnauthenticated"> = {},
): CatalogDiscoveryConfig {
return {
label,
envVars,
...options,
};
}
function catalogDescriptor(
providerId: KnownProvider,
defaultModel: string,
createModelManagerOptions: ProviderDescriptor["createModelManagerOptions"],
catalogDiscovery: CatalogDiscoveryConfig,
options: Pick<ProviderDescriptor, "allowUnauthenticated" | "dynamicModelsAuthoritative"> = {},
): ProviderDescriptor {
return {
...descriptor(providerId, defaultModel, createModelManagerOptions, options),
catalogDiscovery,
};
}
/**
* All standard providers. Special providers (google-antigravity, google-gemini-cli,
* openai-codex) are handled separately because they require different config shapes.
*/
export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)),
catalogDescriptor(
"alibaba-coding-plan",
"qwen3.5-plus",
config => alibabaCodingPlanModelManagerOptions(config),
catalog("Alibaba Coding Plan", ["ALIBABA_CODING_PLAN_API_KEY"]),
/** Default model IDs for all known providers, derived from the registry. */
export const DEFAULT_MODEL_PER_PROVIDER: Record<KnownProvider, string> = Object.fromEntries(
PROVIDER_REGISTRY.filter(provider => provider.defaultModel != null).map(
provider => [provider.id, provider.defaultModel] as [string, string],
),
descriptor("openai", "gpt-5.4", config => openaiModelManagerOptions(config)),
descriptor("groq", "openai/gpt-oss-120b", config => groqModelManagerOptions(config)),
catalogDescriptor(
"huggingface",
"deepseek-ai/DeepSeek-R1",
config => huggingfaceModelManagerOptions(config),
catalog("Hugging Face", ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]),
),
catalogDescriptor(
"cerebras",
"zai-glm-4.6",
config => cerebrasModelManagerOptions(config),
catalog("Cerebras", ["CEREBRAS_API_KEY"]),
),
catalogDescriptor(
"fireworks",
"kimi-k2.6",
config => fireworksModelManagerOptions(config),
catalog("Fireworks", ["FIREWORKS_API_KEY"]),
),
// Fire Pass does not expose a /v1/models endpoint — the API returns HTTP 403
// on any catalog-discovery request, so dynamic model listing is not feasible.
//
// The single model `kimi-k2.6-turbo` is seeded via the `prevModelsJson`
// fallback in `generate-models.ts`, which preserves entries from the previous
// catalog snapshot when a provider does not surface them dynamically.
//
// IMPORTANT: Do NOT delete the firepass section from models.json. No
// descriptor here produces that entry dynamically — removing it from
// models.json would permanently drop the model from the catalog with no
// automated mechanism to restore it.
descriptor("firepass", "kimi-k2.6-turbo", config => firepassModelManagerOptions(config)),
catalogDescriptor(
"wafer-pass",
"GLM-5.1",
config => waferPassModelManagerOptions(config),
catalog("Wafer Pass", ["WAFER_PASS_API_KEY"], { oauthProvider: "wafer-pass" }),
),
catalogDescriptor(
"wafer-serverless",
"GLM-5.1",
config => waferServerlessModelManagerOptions(config),
catalog("Wafer Serverless", ["WAFER_SERVERLESS_API_KEY"], { oauthProvider: "wafer-serverless" }),
),
descriptor("xai", "grok-4-fast-non-reasoning", config => xaiModelManagerOptions(config)),
catalogDescriptor(
"xai-oauth",
"grok-4.3",
config => xaiOAuthModelManagerOptions(config),
catalog("xAI Grok OAuth (SuperGrok)", ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], {
oauthProvider: "xai-oauth",
}),
),
catalogDescriptor(
"deepseek",
"deepseek-v4-pro",
config => deepseekModelManagerOptions(config),
catalog("DeepSeek", ["DEEPSEEK_API_KEY"]),
),
descriptor("mistral", "devstral-medium-latest", config => mistralModelManagerOptions(config)),
catalogDescriptor(
"nvidia",
"nvidia/llama-3.1-nemotron-70b-instruct",
config => nvidiaModelManagerOptions(config),
catalog("NVIDIA", ["NVIDIA_API_KEY"]),
),
catalogDescriptor(
"nanogpt",
"openai/gpt-5.4",
config => nanoGptModelManagerOptions(config),
catalog("NanoGPT", ["NANO_GPT_API_KEY"]),
),
descriptor("opencode-zen", "claude-sonnet-4-6", config => opencodeZenModelManagerOptions(config)),
descriptor("opencode-go", "kimi-k2.5", config => opencodeGoModelManagerOptions(config)),
catalogDescriptor(
"openrouter",
"openai/gpt-5.4",
config => openrouterModelManagerOptions(config),
catalog("OpenRouter", ["OPENROUTER_API_KEY"], { allowUnauthenticated: true }),
),
catalogDescriptor(
"kilo",
"anthropic/claude-sonnet-4.5",
config => kiloModelManagerOptions(config),
catalog("Kilo Gateway", ["KILO_API_KEY"], { allowUnauthenticated: true }),
),
catalogDescriptor(
"vercel-ai-gateway",
"anthropic/claude-sonnet-4-6",
config => vercelAiGatewayModelManagerOptions(config),
catalog("Vercel AI Gateway", ["VERCEL_AI_GATEWAY_API_KEY"], { allowUnauthenticated: true }),
),
catalogDescriptor(
"ollama",
"gpt-oss:20b",
config => ollamaModelManagerOptions(config),
catalog("Ollama", ["OLLAMA_API_KEY"]),
{ allowUnauthenticated: true },
),
catalogDescriptor(
"ollama-cloud",
"gpt-oss:120b",
config => ollamaCloudModelManagerOptions(config),
catalog("Ollama Cloud", ["OLLAMA_CLOUD_API_KEY"], { oauthProvider: "ollama-cloud" }),
),
catalogDescriptor(
"cloudflare-ai-gateway",
"claude-sonnet-4-5",
config => cloudflareAiGatewayModelManagerOptions(config),
catalog("Cloudflare AI Gateway", ["CLOUDFLARE_AI_GATEWAY_API_KEY"]),
),
catalogDescriptor(
"kimi-code",
"kimi-k2.5",
config => kimiCodeModelManagerOptions(config),
catalog("Kimi Code", ["KIMI_API_KEY"]),
),
catalogDescriptor(
"qwen-portal",
"coder-model",
config => qwenPortalModelManagerOptions(config),
catalog("Qwen Portal", ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], { oauthProvider: "qwen-portal" }),
),
catalogDescriptor(
"synthetic",
"hf:zai-org/GLM-5.1",
config => syntheticModelManagerOptions(config),
catalog("Synthetic", ["SYNTHETIC_API_KEY"]),
{ dynamicModelsAuthoritative: true },
),
catalogDescriptor(
"venice",
"llama-3.3-70b",
config => veniceModelManagerOptions(config),
catalog("Venice", ["VENICE_API_KEY"], { allowUnauthenticated: true }),
),
catalogDescriptor(
"litellm",
"claude-opus-4-6",
config => litellmModelManagerOptions(config),
catalog("LiteLLM", ["LITELLM_API_KEY"], { allowUnauthenticated: true }),
),
descriptor("lm-studio", "llama-3-8b", config => lmStudioModelManagerOptions(config), { allowUnauthenticated: true }),
catalogDescriptor(
"vllm",
"gpt-oss-20b",
config => vllmModelManagerOptions(config),
catalog("vLLM", ["VLLM_API_KEY"], { allowUnauthenticated: true }),
),
catalogDescriptor(
"moonshot",
"kimi-k2.5",
config => moonshotModelManagerOptions(config),
catalog("Moonshot", ["MOONSHOT_API_KEY"]),
),
catalogDescriptor(
"qianfan",
"deepseek-v3.2",
config => qianfanModelManagerOptions(config),
catalog("Qianfan", ["QIANFAN_API_KEY"]),
),
catalogDescriptor(
"together",
"moonshotai/Kimi-K2.5",
config => togetherModelManagerOptions(config),
catalog("Together", ["TOGETHER_API_KEY"]),
),
catalogDescriptor(
"xiaomi",
"mimo-v2-flash",
config => xiaomiModelManagerOptions(config),
catalog("Xiaomi", ["XIAOMI_API_KEY"]),
),
descriptor("xiaomi-token-plan-sgp", "mimo-v2.5", config =>
xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }),
),
descriptor("xiaomi-token-plan-ams", "mimo-v2.5", config =>
xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }),
),
descriptor("xiaomi-token-plan-cn", "mimo-v2.5", config =>
xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }),
),
catalogDescriptor(
"zenmux",
"anthropic/claude-opus-4.6",
config => zenmuxModelManagerOptions(config),
catalog("ZenMux", ["ZENMUX_API_KEY"]),
),
catalogDescriptor("zai", "glm-5.1", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
catalogDescriptor(
"zhipu-coding-plan",
"glm-5.1",
config => zhipuCodingPlanModelManagerOptions(config),
catalog("Zhipu Coding Plan", ["ZHIPU_API_KEY"]),
),
descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)),
descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)),
descriptor("google-vertex", "gemini-3-pro-preview", config => googleVertexModelManagerOptions(config), {
allowUnauthenticated: true,
}),
catalogDescriptor(
"cursor",
"claude-sonnet-4-6",
config => cursorModelManagerOptions(config),
catalog("Cursor", ["CURSOR_API_KEY"], { oauthProvider: "cursor" }),
),
] as const;
/** Default model IDs for all known providers, built from descriptors + special providers. */
export const DEFAULT_MODEL_PER_PROVIDER: Record<KnownProvider, string> = {
...Object.fromEntries(PROVIDER_DESCRIPTORS.map(d => [d.providerId, d.defaultModel])),
// Providers not in PROVIDER_DESCRIPTORS (special auth or no standard discovery)
"alibaba-coding-plan": "qwen3.5-plus",
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
"google-antigravity": "gemini-3-pro-high",
"google-gemini-cli": "gemini-2.5-pro",
"google-vertex": "gemini-3-pro-preview",
minimax: "MiniMax-M2.5",
"minimax-code": "MiniMax-M2.5",
"minimax-code-cn": "MiniMax-M2.5",
"openai-codex": "gpt-5.4",
"gitlab-duo": "duo-chat-sonnet-4-5",
} as Record<KnownProvider, string>;
) as Record<KnownProvider, string>;
@@ -0,0 +1,11 @@
/**
* Fallback context-window / max-output-token values for models discovered
* without limit metadata.
*
* Kept in a dependency-free leaf module (rather than `openai-compat.ts`) so the
* model-discovery helpers in `utils/discovery/*` can import them without pulling
* the package root barrel (`@oh-my-pi/pi-ai`) into the model-manager init graph,
* which would otherwise form an import cycle through the provider registry.
*/
export const UNK_CONTEXT_WINDOW = 222_222;
export const UNK_MAX_TOKENS = 8_888;
@@ -1,6 +1,7 @@
import { Effort } from "../effort";
import type { ModelManagerOptions } from "../model-manager";
import { getBundledModels } from "../models";
import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
import type { Api, Model, Provider, ThinkingConfig } from "../types";
import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
import {
@@ -9,8 +10,8 @@ import {
type OpenAICompatibleModelRecord,
} from "../utils/discovery/openai-compatible";
import { toFireworksPublicModelId } from "../utils/fireworks-model-id";
import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references";
import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants";
const MODELS_DEV_URL = "https://models.dev/api.json";
const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
@@ -441,7 +442,7 @@ function isLikelyNanoGptTextModelId(id: string): boolean {
type SimpleProviderConfig = { apiKey?: string; baseUrl?: string };
function createSimpleOpenAICompletionsOptions(
export function createSimpleOpenAICompletionsOptions(
providerId: Parameters<typeof getBundledModels>[0],
defaultBaseUrl: string,
config?: SimpleProviderConfig,
@@ -1916,12 +1917,23 @@ export function moonshotModelManagerOptions(
const reference = references.get(defaults.id);
const model = mapWithBundledReference(entry, defaults, reference);
const id = model.id.toLowerCase();
const isThinking = id.includes("thinking");
const isVision = id.includes("vision") || id.includes("vl") || id.includes("k2.5");
// Moonshot's K2.x family (K2.5, K2.6, kimi-k2-thinking, …) is reasoning-capable
// and vision-capable on the native API. Without these flags the openai-completions
// path skips the z.ai-format `thinking` block, and Moonshot K2.6 stalls on first
// turn because its endpoint expects an explicit `thinking: {type}` (#2113). Match
// the bundled K2.5 metadata for every K2.x id we discover.
const isKimiK2Reasoning = id.includes("thinking") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id);
const isVision =
id.includes("vision") || id.includes("vl") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id);
return {
...model,
reasoning: isThinking || model.reasoning,
reasoning: isKimiK2Reasoning || model.reasoning,
input: isVision ? ["text", "image"] : model.input,
thinking:
model.thinking ??
(isKimiK2Reasoning
? { mode: "effort", minLevel: Effort.Minimal, maxLevel: Effort.High }
: undefined),
};
},
}),
@@ -2385,8 +2397,7 @@ export function anthropicModelManagerOptions(
// Models.dev provider descriptors for generate-models.ts
// ---------------------------------------------------------------------------
export const UNK_CONTEXT_WINDOW = 222_222;
export const UNK_MAX_TOKENS = 8_888;
export { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants";
/** Describes how to map models.dev API data for a single provider. */
export interface ModelsDevProviderDescriptor {
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
import type { Message } from "../types";
import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
/**
* Infer whether the current request to Copilot is user-initiated or agent-initiated.
* Accepts `unknown[]` because providers may pass pre-converted message shapes.
+1 -1
View File
@@ -9,9 +9,9 @@
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
*/
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
import type { Api, Context, Model } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { getKimiCommonHeaders } from "../utils/oauth/kimi";
import {
type OpenAIAnthropicApiFormat,
type OpenAIAnthropicShimOptions,
@@ -142,6 +142,12 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]);
// Provider/model failure mode: Codex can keep a response alive by streaming
// whitespace-only function-call argument deltas forever. Those frames count as
// transport activity, so idle timers never fire; cap the run before raw debug
// buffers and partial JSON grow without semantic progress.
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT = 256;
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT = 16 * 1024;
function isCodexStreamProgressEvent(event: unknown): boolean {
if (isOpenAIResponsesProgressEvent(event)) return true;
@@ -235,6 +241,20 @@ interface CodexStreamRuntime {
providerRetryAttempt: number;
sawTerminalEvent: boolean;
canSafelyReplayWebsocketOverSse: boolean;
whitespaceToolCallArgumentsDelta?: CodexWhitespaceToolCallArgumentsDeltaState;
}
interface CodexWhitespaceToolCallArgumentsDeltaState {
itemId: string;
outputIndex?: number;
consecutiveEvents: number;
consecutiveChars: number;
firstSequenceNumber?: number;
lastSequenceNumber?: number;
}
interface CodexWhitespaceToolCallArgumentsDeltaInterruption {
message: string;
}
interface CodexStreamProcessingContext {
@@ -945,9 +965,88 @@ function createCodexStreamRuntime(initial: {
providerRetryAttempt: 0,
sawTerminalEvent: false,
canSafelyReplayWebsocketOverSse: true,
whitespaceToolCallArgumentsDelta: undefined,
};
}
function resetWhitespaceToolCallArgumentsDelta(runtime: CodexStreamRuntime): void {
runtime.whitespaceToolCallArgumentsDelta = undefined;
}
function isJsonWhitespaceOnly(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x20) {
return false;
}
}
return true;
}
function readOptionalInteger(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.trunc(value);
}
function observeWhitespaceToolCallArgumentsDelta(
runtime: CodexStreamRuntime,
rawEvent: Record<string, unknown>,
delta: string,
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
if (!isJsonWhitespaceOnly(delta)) {
resetWhitespaceToolCallArgumentsDelta(runtime);
return undefined;
}
const itemId =
typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0
? rawEvent.item_id
: (runtime.currentItem?.id ?? "");
const outputIndex = readOptionalInteger(rawEvent.output_index);
const sequenceNumber = readOptionalInteger(rawEvent.sequence_number);
let state = runtime.whitespaceToolCallArgumentsDelta;
if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) {
state = {
itemId,
outputIndex,
consecutiveEvents: 0,
consecutiveChars: 0,
firstSequenceNumber: sequenceNumber,
};
runtime.whitespaceToolCallArgumentsDelta = state;
}
state.consecutiveEvents += 1;
state.consecutiveChars += delta.length;
state.lastSequenceNumber = sequenceNumber;
if (
state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT &&
state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT
) {
return undefined;
}
const itemLabel = itemId ? ` for item ${itemId}` : "";
const sequenceLabel =
state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined
? ""
: `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`;
return {
message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`,
};
}
function interruptWhitespaceToolCallArgumentsDelta(
requestSetup: CodexRequestSetup,
runtime: CodexStreamRuntime,
interruption: CodexWhitespaceToolCallArgumentsDeltaInterruption,
): never {
const error = new Error(interruption.message);
requestSetup.requestAbortController.abort(error);
runtime.websocketState?.connection?.close("degenerate-tool-call");
throw error;
}
async function processCodexResponseStream(
context: CodexStreamProcessingContext,
runtime: CodexStreamRuntime,
@@ -984,8 +1083,9 @@ function handleCodexStreamEvent(args: {
runtime: CodexStreamRuntime;
rawEvent: Record<string, unknown>;
firstTokenTime?: number;
requestSetup: CodexRequestSetup;
}): number | undefined {
const { model, output, stream, runtime, rawEvent } = args;
const { model, output, stream, runtime, rawEvent, requestSetup } = args;
const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
if (!eventType) return args.firstTokenTime;
@@ -994,6 +1094,7 @@ function handleCodexStreamEvent(args: {
let firstTokenTime = args.firstTokenTime;
if (eventType === "response.output_item.added") {
resetWhitespaceToolCallArgumentsDelta(runtime);
if (!firstTokenTime) firstTokenTime = Date.now();
const item = rawEvent.item as CodexEventItem;
runtime.currentItem = item;
@@ -1055,11 +1156,13 @@ function handleCodexStreamEvent(args: {
}
if (eventType === "response.function_call_arguments.delta") {
handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
const interruption = handleToolCallArgumentsDelta(runtime, rawEvent, stream, output, blockIndex);
if (interruption) interruptWhitespaceToolCallArgumentsDelta(requestSetup, runtime, interruption);
return firstTokenTime;
}
if (eventType === "response.function_call_arguments.done") {
resetWhitespaceToolCallArgumentsDelta(runtime);
handleToolCallArgumentsDone(runtime.currentItem, runtime.currentBlock, rawEvent);
return firstTokenTime;
}
@@ -1075,6 +1178,7 @@ function handleCodexStreamEvent(args: {
}
if (eventType === "response.output_item.done") {
resetWhitespaceToolCallArgumentsDelta(runtime);
handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex);
return firstTokenTime;
}
@@ -1206,15 +1310,18 @@ function handleMessageTextDelta(
}
function handleToolCallArgumentsDelta(
currentItem: CodexEventItem | null,
currentBlock: CodexOutputBlock | null,
runtime: CodexStreamRuntime,
rawEvent: Record<string, unknown>,
stream: AssistantMessageEventStream,
output: AssistantMessage,
blockIndex: () => number,
): void {
if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return;
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
const currentItem = runtime.currentItem;
const currentBlock = runtime.currentBlock;
if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return undefined;
const delta = (rawEvent as { delta?: string }).delta || "";
const interruption = observeWhitespaceToolCallArgumentsDelta(runtime, rawEvent, delta);
if (interruption) return interruption;
currentBlock.partialJson += delta;
const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0);
if (throttled) {
@@ -1222,6 +1329,7 @@ function handleToolCallArgumentsDelta(
currentBlock.lastParseLen = throttled.parsedLen;
}
stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
return undefined;
}
function handleToolCallArgumentsDone(
@@ -13,6 +13,8 @@ import packageJson from "../../package.json" with { type: "json" };
import type { Effort } from "../effort";
import { getSupportedEfforts } from "../model-thinking";
import { calculateCost } from "../models";
import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
import { getEnvApiKey } from "../stream";
import {
type AssistantMessage,
@@ -53,8 +55,6 @@ import {
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseStreamingJson, parseStreamingJsonThrottled } 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 } from "../utils/retry";
import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
@@ -6,6 +6,7 @@ import type {
ResponseInput,
ResponseStreamEvent,
} from "openai/resources/responses/responses";
import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot";
import { getEnvApiKey } from "../stream";
import type {
AssistantMessage,
@@ -39,7 +40,6 @@ import {
getOpenAIStreamIdleTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { notifyProviderResponse } from "../utils/provider-response";
import { callWithCopilotModelRetry } from "../utils/retry";
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
@@ -1,27 +1,12 @@
/**
* Alibaba Coding Plan login flow.
*
* Alibaba Coding Plan provides OpenAI-compatible models via https://coding-intl.dashscope.aliyuncs.com/v1.
*
* This is not OAuth - it's a simple API key flow:
* 1. Open browser to Alibaba Cloud DashScope API key settings
* 2. User copies their API key
* 3. User pastes the API key into the CLI
*/
import { alibabaCodingPlanModelManagerOptions } from "../provider-models/openai-compat";
import { validateOpenAICompatibleApiKey } from "./api-key-validation";
import type { OAuthController } from "./types";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const AUTH_URL = "https://modelstudio.console.alibabacloud.com/";
const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
const VALIDATION_MODEL = "qwen3.5-plus";
/**
* Login to Alibaba Coding Plan.
*
* Opens browser to API keys page, prompts user to paste their API key.
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
*/
export async function loginAlibabaCodingPlan(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new Error("Alibaba Coding Plan login requires onPrompt callback");
@@ -57,3 +42,13 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
return trimmed;
}
export const alibabaCodingPlanProvider = {
id: "alibaba-coding-plan",
name: "Alibaba Coding Plan",
defaultModel: "qwen3.5-plus",
createModelManagerOptions: (config: ModelManagerConfig) => alibabaCodingPlanModelManagerOptions(config),
catalogDiscovery: { label: "Alibaba Coding Plan", envVars: ["ALIBABA_CODING_PLAN_API_KEY"] },
envKeys: "ALIBABA_CODING_PLAN_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginAlibabaCodingPlan(cb),
} as const satisfies ProviderDefinition;
@@ -0,0 +1,23 @@
import { $env } from "@oh-my-pi/pi-utils";
import type { ProviderDefinition } from "./types";
export const amazonBedrockProvider = {
id: "amazon-bedrock",
name: "Amazon Bedrock",
defaultModel: "us.anthropic.claude-opus-4-6-v1",
// Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains.
envKeys: () => {
const hasEcsCredentials =
!!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN;
if (
$env.AWS_PROFILE ||
($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) ||
$env.AWS_BEARER_TOKEN_BEDROCK ||
hasEcsCredentials ||
hasWebIdentity
) {
return "<authenticated>";
}
},
} as const satisfies ProviderDefinition;
+29
View File
@@ -0,0 +1,29 @@
import { $pickenv } from "@oh-my-pi/pi-utils";
import { anthropicModelManagerOptions } from "../provider-models/openai-compat";
import { isFoundryEnabled } from "../utils/foundry";
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const anthropicProvider = {
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
defaultModel: "claude-opus-4-6",
createModelManagerOptions: (config: ModelManagerConfig) => anthropicModelManagerOptions(config),
// Foundry mode optionally switches Anthropic auth to enterprise gateway credentials.
envKeys: () =>
isFoundryEnabled()
? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY")
: $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"),
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginAnthropic } = await import("./oauth/anthropic");
return loginAnthropic(cb);
},
refreshToken: async (credentials: OAuthCredentials) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { refreshAnthropicToken } = await import("./oauth/anthropic");
return refreshAnthropicToken(credentials.refresh);
},
callbackPort: 54545,
pasteCodeFlow: true,
} as const satisfies ProviderDefinition;
@@ -6,8 +6,12 @@
* optionally validate it, and return the trimmed key.
*/
import { validateApiKeyAgainstModelsEndpoint, validateOpenAICompatibleApiKey } from "./api-key-validation";
import type { OAuthController } from "./types";
import {
validateAnthropicCompatibleApiKey,
validateApiKeyAgainstModelsEndpoint,
validateOpenAICompatibleApiKey,
} from "./api-key-validation";
import type { OAuthController } from "./oauth/types";
type ChatCompletionsValidation = {
kind: "chat-completions";
@@ -16,6 +20,13 @@ type ChatCompletionsValidation = {
model: string;
};
type AnthropicMessagesValidation = {
kind: "anthropic-messages";
provider: string;
baseUrl: string;
model: string;
};
type ModelsEndpointValidation = {
kind: "models-endpoint";
provider: string;
@@ -34,7 +45,7 @@ export type ApiKeyLoginConfig = {
/** Placeholder string for the prompt (e.g. "sk-...", "csk-..."). */
placeholder: string;
/** Validation strategy, or `null` to skip validation. */
validation: ChatCompletionsValidation | ModelsEndpointValidation | null;
validation: ChatCompletionsValidation | AnthropicMessagesValidation | ModelsEndpointValidation | null;
};
export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthController) => Promise<string> {
@@ -72,6 +83,14 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
model: config.validation.model,
signal: options.signal,
});
} else if (config.validation.kind === "anthropic-messages") {
await validateAnthropicCompatibleApiKey({
provider: config.validation.provider,
apiKey: trimmed,
baseUrl: config.validation.baseUrl,
model: config.validation.model,
signal: options.signal,
});
} else {
await validateApiKeyAgainstModelsEndpoint({
provider: config.validation.provider,
@@ -5,6 +5,13 @@ type OpenAICompatibleValidationOptions = {
model: string;
signal?: AbortSignal;
};
type AnthropicCompatibleValidationOptions = {
provider: string;
apiKey: string;
baseUrl: string;
model: string;
signal?: AbortSignal;
};
type ModelListValidationOptions = {
provider: string;
@@ -15,6 +22,11 @@ type ModelListValidationOptions = {
const VALIDATION_TIMEOUT_MS = 15_000;
function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
}
/**
* Validate an API key against an OpenAI-compatible chat completions endpoint.
*
@@ -56,6 +68,46 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa
throw new Error(message);
}
/**
* Validate an API key against an Anthropic-compatible messages endpoint.
*/
export async function validateAnthropicCompatibleApiKey(options: AnthropicCompatibleValidationOptions): Promise<void> {
const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
const baseUrl = normalizeAnthropicCompatibleBaseUrl(options.baseUrl);
const response = await fetch(`${baseUrl}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"x-api-key": options.apiKey,
},
body: JSON.stringify({
model: options.model,
messages: [{ role: "user", content: "ping" }],
max_tokens: 1,
}),
signal,
});
if (response.ok) {
return;
}
let details = "";
try {
details = (await response.text()).trim();
} catch {
// ignore body parse errors, status is enough
}
const message = details
? `${options.provider} API key validation failed (${response.status}): ${details}`
: `${options.provider} API key validation failed (${response.status})`;
throw new Error(message);
}
/**
* Validate an API key against a provider models endpoint.
*
+28
View File
@@ -0,0 +1,28 @@
import { cerebrasModelManagerOptions } from "../provider-models/openai-compat";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const loginCerebras = createApiKeyLogin({
providerLabel: "Cerebras",
authUrl: "https://cloud.cerebras.ai/platform/",
instructions: "Copy your API key from the Cerebras dashboard",
promptMessage: "Paste your Cerebras API key",
placeholder: "csk-...",
validation: {
kind: "chat-completions",
provider: "Cerebras",
baseUrl: "https://api.cerebras.ai/v1",
model: "gpt-oss-120b",
},
});
export const cerebrasProvider = {
id: "cerebras",
name: "Cerebras",
defaultModel: "zai-glm-4.6",
createModelManagerOptions: (config: ModelManagerConfig) => cerebrasModelManagerOptions(config),
catalogDiscovery: { label: "Cerebras", envVars: ["CEREBRAS_API_KEY"] },
envKeys: "CEREBRAS_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginCerebras(cb),
} as const satisfies ProviderDefinition;
@@ -1,15 +1,6 @@
/**
* Cloudflare AI Gateway login flow.
*
* Cloudflare AI Gateway proxies upstream model providers.
*
* This is not OAuth - it's a simple API key flow:
* 1. Open Cloudflare AI Gateway docs/dashboard
* 2. User copies their Cloudflare AI Gateway token/API key
* 3. User pastes the API key into the CLI
*/
import type { OAuthController } from "./types";
import { cloudflareAiGatewayModelManagerOptions } from "../provider-models/openai-compat";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/";
@@ -46,3 +37,13 @@ export async function loginCloudflareAiGateway(options: OAuthController): Promis
return trimmed;
}
export const cloudflareAiGatewayProvider = {
id: "cloudflare-ai-gateway",
name: "Cloudflare AI Gateway",
defaultModel: "claude-sonnet-4-5",
createModelManagerOptions: (config: ModelManagerConfig) => cloudflareAiGatewayModelManagerOptions(config),
catalogDiscovery: { label: "Cloudflare AI Gateway", envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"] },
envKeys: "CLOUDFLARE_AI_GATEWAY_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginCloudflareAiGateway(cb),
} as const satisfies ProviderDefinition;
+25
View File
@@ -0,0 +1,25 @@
import { cursorModelManagerOptions } from "../provider-models/special";
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const cursorProvider = {
id: "cursor",
name: "Cursor (Claude, GPT, etc.)",
defaultModel: "claude-sonnet-4-6",
createModelManagerOptions: (config: ModelManagerConfig) => cursorModelManagerOptions(config),
catalogDiscovery: { label: "Cursor", envVars: ["CURSOR_API_KEY"], oauthProvider: "cursor" },
envKeys: "CURSOR_ACCESS_TOKEN",
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginCursor } = await import("./oauth/cursor");
return loginCursor(
url => cb.onAuth({ url }),
cb.onProgress ? () => cb.onProgress?.("Waiting for browser authentication...") : undefined,
);
},
refreshToken: async (credentials: OAuthCredentials) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { refreshCursorToken } = await import("./oauth/cursor");
return refreshCursorToken(credentials.refresh);
},
} as const satisfies ProviderDefinition;
@@ -1,13 +1,7 @@
/**
* DeepSeek login flow (API key paste against https://api.deepseek.com).
*
* Validation hits `GET /v1/models` so it authenticates the key without
* depending on a specific model being enabled on the account. The previous
* implementation issued a chat-completion against `deepseek-v4-pro`, which
* 404s for accounts without that preview model even when the key is valid.
*/
import { deepseekModelManagerOptions } from "../provider-models/openai-compat";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthController, OAuthPrompt } from "./types";
import type { OAuthController, OAuthLoginCallbacks, OAuthPrompt } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const innerLogin = createApiKeyLogin({
providerLabel: "DeepSeek",
@@ -22,17 +16,10 @@ const innerLogin = createApiKeyLogin({
},
});
/**
* Normalize a pasted DeepSeek API key.
*
* Users frequently copy keys out of `curl` snippets that include the
* `Authorization: Bearer …` prefix. Strip it so validation does not fail
* with a confusing 401, and reject obviously empty input early.
*/
export function normalizeDeepSeekApiKey(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return trimmed; // let the shared factory throw the canonical "API key is required"
return trimmed;
}
const stripped = trimmed.replace(/^bearer\b\s*/i, "");
if (!stripped) {
@@ -51,3 +38,13 @@ export const loginDeepSeek = async (options: OAuthController): Promise<string> =
: options;
return innerLogin(wrapped);
};
export const deepseekProvider = {
id: "deepseek",
name: "DeepSeek",
defaultModel: "deepseek-v4-pro",
createModelManagerOptions: (config: ModelManagerConfig) => deepseekModelManagerOptions(config),
catalogDiscovery: { label: "DeepSeek", envVars: ["DEEPSEEK_API_KEY"] },
envKeys: "DEEPSEEK_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginDeepSeek(cb),
} as const satisfies ProviderDefinition;
+9
View File
@@ -0,0 +1,9 @@
import { PROVIDER_REGISTRY } from "./registry";
/**
* Providers whose OAuth flow needs a pasted code/redirect URL rather than a
* local callback server. Consumed by the coding-agent login UX.
*/
export const PASTE_CODE_LOGIN_PROVIDERS: ReadonlySet<string> = new Set(
PROVIDER_REGISTRY.filter(p => p.pasteCodeFlow).map(p => p.id),
);
@@ -1,3 +1,8 @@
import { firepassModelManagerOptions } from "../provider-models/openai-compat";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
/**
* Fire Pass login flow.
*
@@ -7,8 +12,6 @@
* pings the chat completions endpoint with the router id directly.
* See https://docs.fireworks.ai/firepass.
*/
import { createApiKeyLogin } from "./api-key-login";
export const loginFirepass = createApiKeyLogin({
providerLabel: "Fire Pass",
authUrl: "https://app.fireworks.ai/settings/users/api-keys",
@@ -22,3 +25,12 @@ export const loginFirepass = createApiKeyLogin({
model: "accounts/fireworks/routers/kimi-k2p6-turbo",
},
});
export const firepassProvider = {
id: "firepass",
name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)",
defaultModel: "kimi-k2.6-turbo",
createModelManagerOptions: (config: ModelManagerConfig) => firepassModelManagerOptions(config),
envKeys: "FIREPASS_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginFirepass(cb),
} as const satisfies ProviderDefinition;
+27
View File
@@ -0,0 +1,27 @@
import { fireworksModelManagerOptions } from "../provider-models/openai-compat";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const loginFireworks = createApiKeyLogin({
providerLabel: "Fireworks",
authUrl: "https://app.fireworks.ai/settings/users/api-keys",
instructions: "Create or copy your Fireworks API key",
promptMessage: "Paste your Fireworks API key",
placeholder: "fw_...",
validation: {
kind: "models-endpoint",
provider: "Fireworks",
modelsUrl: "https://api.fireworks.ai/inference/v1/models",
},
});
export const fireworksProvider = {
id: "fireworks",
name: "Fireworks",
defaultModel: "kimi-k2.6",
createModelManagerOptions: (config: ModelManagerConfig) => fireworksModelManagerOptions(config),
catalogDiscovery: { label: "Fireworks", envVars: ["FIREWORKS_API_KEY"] },
envKeys: "FIREWORKS_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginFireworks(cb),
} as const satisfies ProviderDefinition;
@@ -0,0 +1,26 @@
import { githubCopilotModelManagerOptions } from "../provider-models/openai-compat";
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const githubCopilotProvider = {
id: "github-copilot",
name: "GitHub Copilot",
defaultModel: "gpt-4o",
createModelManagerOptions: (config: ModelManagerConfig) => githubCopilotModelManagerOptions(config),
envKeys: "COPILOT_GITHUB_TOKEN",
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginGitHubCopilot } = await import("./oauth/github-copilot");
return loginGitHubCopilot({
onAuth: (url, instructions) => cb.onAuth({ url, instructions }),
onPrompt: cb.onPrompt,
onProgress: cb.onProgress,
signal: cb.signal,
});
},
refreshToken: async (credentials: OAuthCredentials) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { refreshGitHubCopilotToken } = await import("./oauth/github-copilot");
return refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
},
} as const satisfies ProviderDefinition;
+21
View File
@@ -0,0 +1,21 @@
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const gitlabDuoProvider = {
id: "gitlab-duo",
name: "GitLab Duo",
defaultModel: "duo-chat-sonnet-4-5",
envKeys: "GITLAB_TOKEN",
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginGitLabDuo } = await import("./oauth/gitlab-duo");
return loginGitLabDuo(cb);
},
refreshToken: async (credentials: OAuthCredentials) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { refreshGitLabDuoToken } = await import("./oauth/gitlab-duo");
return refreshGitLabDuoToken(credentials);
},
callbackPort: 8080,
pasteCodeFlow: true,
} as const satisfies ProviderDefinition;
@@ -0,0 +1,23 @@
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const googleAntigravityProvider = {
id: "google-antigravity",
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
defaultModel: "gemini-3-pro-high",
specialModelManager: true,
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginAntigravity } = await import("./oauth/google-antigravity");
return loginAntigravity(cb);
},
refreshToken: async (credentials: OAuthCredentials) => {
if (!credentials.projectId) {
throw new Error("Antigravity credentials missing projectId");
}
const { refreshAntigravityToken } = await import("./oauth/google-antigravity");
return refreshAntigravityToken(credentials.refresh, credentials.projectId);
},
callbackPort: 51121,
pasteCodeFlow: true,
} as const satisfies ProviderDefinition;
@@ -0,0 +1,23 @@
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const googleGeminiCliProvider = {
id: "google-gemini-cli",
name: "Google Cloud Code Assist (Gemini CLI)",
defaultModel: "gemini-2.5-pro",
specialModelManager: true,
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginGeminiCli } = await import("./oauth/google-gemini-cli");
return loginGeminiCli(cb);
},
refreshToken: async (credentials: OAuthCredentials) => {
if (!credentials.projectId) {
throw new Error("Google Cloud credentials missing projectId");
}
const { refreshGoogleCloudToken } = await import("./oauth/google-gemini-cli");
return refreshGoogleCloudToken(credentials.refresh, credentials.projectId);
},
callbackPort: 8085,
pasteCodeFlow: true,
} as const satisfies ProviderDefinition;
+42
View File
@@ -0,0 +1,42 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $env } from "@oh-my-pi/pi-utils";
import { googleVertexModelManagerOptions } from "../provider-models/google";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(): boolean {
if (cachedVertexAdcCredentialsExists === null) {
const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS;
if (gacPath) {
cachedVertexAdcCredentialsExists = fs.existsSync(gacPath);
} else {
cachedVertexAdcCredentialsExists = fs.existsSync(
path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"),
);
}
}
return cachedVertexAdcCredentialsExists;
}
export const googleVertexProvider = {
id: "google-vertex",
name: "Google Vertex AI",
defaultModel: "gemini-3-pro-preview",
createModelManagerOptions: (config: ModelManagerConfig) => googleVertexModelManagerOptions(config),
allowUnauthenticated: true,
// Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials.
envKeys: () => {
if ($env.GOOGLE_CLOUD_API_KEY) {
return $env.GOOGLE_CLOUD_API_KEY;
}
const hasCredentials = hasVertexAdcCredentials();
const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT);
const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION);
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
}
},
} as const satisfies ProviderDefinition;
+10
View File
@@ -0,0 +1,10 @@
import { googleModelManagerOptions } from "../provider-models/google";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const googleProvider = {
id: "google",
name: "Google Gemini",
defaultModel: "gemini-2.5-pro",
createModelManagerOptions: (config: ModelManagerConfig) => googleModelManagerOptions(config),
envKeys: "GEMINI_API_KEY",
} as const satisfies ProviderDefinition;
+10
View File
@@ -0,0 +1,10 @@
import { groqModelManagerOptions } from "../provider-models/openai-compat";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const groqProvider = {
id: "groq",
name: "Groq",
defaultModel: "openai/gpt-oss-120b",
createModelManagerOptions: (config: ModelManagerConfig) => groqModelManagerOptions(config),
envKeys: "GROQ_API_KEY",
} as const satisfies ProviderDefinition;
@@ -1,29 +1,14 @@
/**
* Hugging Face Inference login flow.
*
* Hugging Face Inference Providers expose an OpenAI-compatible endpoint via
* https://router.huggingface.co/v1.
*
* This is an API key flow:
* 1. Open browser to Hugging Face token settings
* 2. User creates/copies a token with Inference Providers permission
* 3. User pastes the token into the CLI
*/
import { $pickenv } from "@oh-my-pi/pi-utils";
import { huggingfaceModelManagerOptions } from "../provider-models/openai-compat";
import { validateOpenAICompatibleApiKey } from "./api-key-validation";
import type { OAuthController } from "./types";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const AUTH_URL =
"https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained";
const API_BASE_URL = "https://router.huggingface.co/v1";
const VALIDATION_MODEL = "openai/gpt-oss-120b";
/**
* Login to Hugging Face Inference Providers.
*
* Opens browser to token settings, prompts user to paste their API key.
* Returns the API key directly (not OAuthCredentials - this isn't OAuth).
*/
export async function loginHuggingface(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new Error("Hugging Face login requires onPrompt callback");
@@ -60,3 +45,13 @@ export async function loginHuggingface(options: OAuthController): Promise<string
return trimmed;
}
export const huggingfaceProvider = {
id: "huggingface",
name: "Hugging Face Inference",
defaultModel: "deepseek-ai/DeepSeek-R1",
createModelManagerOptions: (config: ModelManagerConfig) => huggingfaceModelManagerOptions(config),
catalogDiscovery: { label: "Hugging Face", envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] },
envKeys: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"),
login: (cb: OAuthLoginCallbacks) => loginHuggingface(cb),
} as const satisfies ProviderDefinition;
+4
View File
@@ -0,0 +1,4 @@
export * from "./derived";
export * from "./oauth";
export * from "./registry";
export * from "./types";
@@ -1,14 +1,5 @@
/**
* Kagi login flow.
*
* Kagi web search uses an API key from the account settings page.
* This is an API key flow:
* 1. Open browser to Kagi API settings
* 2. User copies API key
* 3. User pastes key into CLI
*/
import type { OAuthController } from "./types";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
const AUTH_URL = "https://kagi.com/settings/api";
@@ -45,3 +36,10 @@ export async function loginKagi(options: OAuthController): Promise<string> {
return trimmed;
}
export const kagiProvider = {
id: "kagi",
name: "Kagi",
envKeys: "KAGI_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginKagi(cb),
} as const satisfies ProviderDefinition;
@@ -1,4 +1,6 @@
import type { OAuthController, OAuthCredentials } from "./types";
import { kiloModelManagerOptions } from "../provider-models/openai-compat";
import type { OAuthController, OAuthCredentials } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const KILO_DEVICE_AUTH_BASE_URL = "https://api.kilo.ai/api/device-auth";
const POLL_INTERVAL_MS = 5000;
@@ -15,9 +17,6 @@ interface KiloDeviceAuthPollResponse {
token?: string;
}
/**
* Login with Kilo Gateway OAuth (device code flow).
*/
export async function loginKilo(callbacks: OAuthController): Promise<OAuthCredentials> {
const initiateResponse = await fetch(`${KILO_DEVICE_AUTH_BASE_URL}/codes`, {
method: "POST",
@@ -85,3 +84,13 @@ export async function loginKilo(callbacks: OAuthController): Promise<OAuthCreden
throw new Error("Authentication timed out. Please try again.");
}
export const kiloProvider = {
id: "kilo",
name: "Kilo Gateway",
defaultModel: "anthropic/claude-sonnet-4.5",
createModelManagerOptions: (config: ModelManagerConfig) => kiloModelManagerOptions(config),
catalogDiscovery: { label: "Kilo Gateway", envVars: ["KILO_API_KEY"], allowUnauthenticated: true },
envKeys: "KILO_API_KEY",
login: loginKilo,
} as const satisfies ProviderDefinition;
+21
View File
@@ -0,0 +1,21 @@
import { kimiCodeModelManagerOptions } from "../provider-models/openai-compat";
import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const kimiCodeProvider = {
id: "kimi-code",
name: "Kimi Code",
defaultModel: "kimi-k2.5",
createModelManagerOptions: (config: ModelManagerConfig) => kimiCodeModelManagerOptions(config),
catalogDiscovery: { label: "Kimi Code", envVars: ["KIMI_API_KEY"] },
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginKimi } = await import("./oauth/kimi");
return loginKimi(cb);
},
refreshToken: async (credentials: OAuthCredentials) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { refreshKimiToken } = await import("./oauth/kimi");
return refreshKimiToken(credentials.refresh);
},
} as const satisfies ProviderDefinition;
@@ -1,15 +1,6 @@
/**
* LiteLLM login flow.
*
* LiteLLM is an OpenAI-compatible proxy that routes requests to many upstream providers.
*
* This is not OAuth - it's a simple API key flow:
* 1. Open browser to LiteLLM docs/dashboard
* 2. User copies their LiteLLM API key
* 3. User pastes the API key into the CLI
*/
import type { OAuthController } from "./types";
import { litellmModelManagerOptions } from "../provider-models/openai-compat";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy";
@@ -45,3 +36,13 @@ export async function loginLiteLLM(options: OAuthController): Promise<string> {
return trimmed;
}
export const litellmProvider = {
id: "litellm",
name: "LiteLLM",
defaultModel: "claude-opus-4-6",
createModelManagerOptions: (config: ModelManagerConfig) => litellmModelManagerOptions(config),
catalogDiscovery: { label: "LiteLLM", envVars: ["LITELLM_API_KEY"], allowUnauthenticated: true },
envKeys: "LITELLM_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginLiteLLM(cb),
} as const satisfies ProviderDefinition;
+35
View File
@@ -0,0 +1,35 @@
import { lmStudioModelManagerOptions } from "../provider-models/openai-compat";
import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
const PROVIDER_ID = "lm-studio";
export const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
export async function loginLmStudio(options: OAuthController): Promise<string> {
if (!options.onPrompt) {
throw new Error(`${PROVIDER_ID} login requires onPrompt callback`);
}
const apiKey = await options.onPrompt({
message: "Optional: Paste LM Studio API key (to customize endpoint URL, set LM_STUDIO_BASE_URL env var)",
placeholder: DEFAULT_LOCAL_TOKEN,
allowEmpty: true,
});
if (options.signal?.aborted) {
throw new Error("Login cancelled");
}
const trimmed = apiKey.trim();
return trimmed || DEFAULT_LOCAL_TOKEN;
}
export const lmStudioProvider = {
id: "lm-studio",
name: "LM Studio (Local OpenAI-compatible)",
defaultModel: "llama-3-8b",
createModelManagerOptions: (config: ModelManagerConfig) => lmStudioModelManagerOptions(config),
allowUnauthenticated: true,
envKeys: "LM_STUDIO_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginLmStudio(cb),
} as const satisfies ProviderDefinition;
@@ -0,0 +1,14 @@
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const minimaxCodeCnProvider = {
id: "minimax-code-cn",
name: "MiniMax Coding Plan (China)",
defaultModel: "MiniMax-M2.5",
envKeys: "MINIMAX_CODE_CN_API_KEY",
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginMiniMaxCodeCn } = await import("./oauth/minimax-code");
return loginMiniMaxCodeCn(cb);
},
} as const satisfies ProviderDefinition;
+14
View File
@@ -0,0 +1,14 @@
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
export const minimaxCodeProvider = {
id: "minimax-code",
name: "MiniMax Coding Plan (International)",
defaultModel: "MiniMax-M2.5",
envKeys: "MINIMAX_CODE_API_KEY",
login: async (cb: OAuthLoginCallbacks) => {
// Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
const { loginMiniMaxCode } = await import("./oauth/minimax-code");
return loginMiniMaxCode(cb);
},
} as const satisfies ProviderDefinition;
+8
View File
@@ -0,0 +1,8 @@
import type { ProviderDefinition } from "./types";
export const minimaxProvider = {
id: "minimax",
name: "MiniMax",
defaultModel: "MiniMax-M2.5",
envKeys: "MINIMAX_API_KEY",
} as const satisfies ProviderDefinition;
+10
View File
@@ -0,0 +1,10 @@
import { mistralModelManagerOptions } from "../provider-models/openai-compat";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const mistralProvider = {
id: "mistral",
name: "Mistral",
defaultModel: "devstral-medium-latest",
createModelManagerOptions: (config: ModelManagerConfig) => mistralModelManagerOptions(config),
envKeys: "MISTRAL_API_KEY",
} as const satisfies ProviderDefinition;
+27
View File
@@ -0,0 +1,27 @@
import { moonshotModelManagerOptions } from "../provider-models/openai-compat";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ModelManagerConfig, ProviderDefinition } from "./types";
export const loginMoonshot = createApiKeyLogin({
providerLabel: "Moonshot",
authUrl: "https://platform.moonshot.ai/console/api-keys",
instructions: "Copy your API key from the Moonshot dashboard",
promptMessage: "Paste your Moonshot API key",
placeholder: "sk-...",
validation: {
kind: "models-endpoint",
provider: "moonshot",
modelsUrl: "https://api.moonshot.ai/v1/models",
},
});
export const moonshotProvider = {
id: "moonshot",
name: "Moonshot (Kimi API)",
defaultModel: "kimi-k2.5",
createModelManagerOptions: (config: ModelManagerConfig) => moonshotModelManagerOptions(config),
catalogDiscovery: { label: "Moonshot", envVars: ["MOONSHOT_API_KEY"] },
envKeys: "MOONSHOT_API_KEY",
login: (cb: OAuthLoginCallbacks) => loginMoonshot(cb),
} as const satisfies ProviderDefinition;

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