diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index b767e973a..e8c1d6be5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -133,6 +133,7 @@ tdiant tjboudreaux tsagi2045 turbomolli +unravl usr-bin-roygbiv vmcall voidchecksum diff --git a/Cargo.lock b/Cargo.lock index 5cd6d3ef9..0ba9c6e2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,6 +1115,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dns-lookup" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" +dependencies = [ + "cfg-if", + "libc", + "socket2", + "windows-sys 0.60.2", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -2604,9 +2616,9 @@ dependencies = [ [[package]] name = "napi" -version = "3.10.3" +version = "3.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93" +checksum = "59b7fbd5f12adbf51ddec954d4ef9cecb3542a9d53bce2fe0653696c4cd06d73" dependencies = [ "bitflags 2.13.0", "ctor", @@ -2626,9 +2638,9 @@ checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" -version = "3.5.9" +version = "3.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727" +checksum = "b0fe526e81c105d3640516fcde83909dd1afe757c0d7a15af58830b5bc0fb9a1" dependencies = [ "convert_case", "ctor", @@ -2640,9 +2652,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "5.1.1" +version = "5.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba" +checksum = "514281397bcddd9ea9a876c7a21a57bff2374237a000ca9a64ea0211ec1993e2" dependencies = [ "convert_case", "proc-macro2", @@ -2653,9 +2665,9 @@ dependencies = [ [[package]] name = "napi-sys" -version = "3.2.2" +version = "3.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +checksum = "73e43cf2eb0bd1bf95a43c07c076ebd2da5d1e015a71c3d201faeffffcc0ecac" dependencies = [ "libloading", ] @@ -3022,6 +3034,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse_datetime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413775a7eac2261d2211a79d10ef275e5b6f7b527eec42ad09adce2ffa92b6e5" +dependencies = [ + "jiff", + "num-traits", + "winnow 0.7.15", +] + [[package]] name = "pcre2" version = "0.2.11" @@ -3241,7 +3264,7 @@ dependencies = [ [[package]] name = "pi-ast" -version = "16.4.5" +version = "16.4.8" dependencies = [ "anyhow", "ast-grep-core", @@ -3310,7 +3333,7 @@ dependencies = [ [[package]] name = "pi-iso" -version = "16.4.5" +version = "16.4.8" dependencies = [ "async-trait", "libc", @@ -3322,7 +3345,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "16.4.5" +version = "16.4.8" dependencies = [ "anyhow", "arboard", @@ -3375,7 +3398,7 @@ dependencies = [ [[package]] name = "pi-shell" -version = "16.4.5" +version = "16.4.8" dependencies = [ "anyhow", "brush-builtins", @@ -3392,6 +3415,7 @@ dependencies = [ "parking_lot", "pi-uutils-ctx", "pi-walker", + "pi_uu_diff", "pi_uu_grep", "regex", "serde", @@ -3406,28 +3430,44 @@ dependencies = [ "uu_cat", "uu_comm", "uu_cut", + "uu_date", "uu_dirname", "uu_find", "uu_head", + "uu_hostname", + "uu_ln", "uu_ls", "uu_md5sum", "uu_mkdir", + "uu_mktemp", "uu_mv", + "uu_nproc", "uu_paste", + "uu_printenv", + "uu_readlink", + "uu_realpath", "uu_rm", "uu_sed", + "uu_seq", "uu_sha1sum", "uu_sha224sum", "uu_sha256sum", "uu_sha384sum", "uu_sha512sum", "uu_sort", + "uu_stat", + "uu_tac", "uu_tail", "uu_tee", + "uu_touch", "uu_tr", + "uu_truncate", + "uu_uname", "uu_uniq", "uu_wc", + "uu_whoami", "uu_xargs", + "uu_yes", "windows-sys 0.61.2", "winreg 0.56.0", "xxhash-rust", @@ -3442,7 +3482,7 @@ dependencies = [ [[package]] name = "pi-walker" -version = "16.4.5" +version = "16.4.8" dependencies = [ "dashmap", "globset", @@ -3453,6 +3493,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pi_uu_diff" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "similar 3.1.1", + "tempfile", +] + [[package]] name = "pi_uu_grep" version = "0.8.0" @@ -3484,6 +3535,16 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "platform-info" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9368d62437c8cbb7c31ee37fd8c08a7d390e09a3ff75698a674953f46705ffcb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "png" version = "0.18.1" @@ -4474,7 +4535,7 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4495,7 +4556,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -4504,7 +4565,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -5281,7 +5342,9 @@ name = "uu_base64" version = "0.8.0" dependencies = [ "clap", + "parking_lot", "pi-uutils-ctx", + "tempfile", "uu_base32", "uucore 0.8.0", ] @@ -5338,6 +5401,21 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_date" +version = "0.8.0" +dependencies = [ + "clap", + "jiff", + "parking_lot", + "parse_datetime", + "pi-uutils-ctx", + "regex", + "rustix", + "tempfile", + "uucore 0.8.0", +] + [[package]] name = "uu_dirname" version = "0.8.0" @@ -5359,6 +5437,7 @@ dependencies = [ "filetime", "nix 0.29.0", "onig", + "parking_lot", "pi-uutils-ctx", "pi-walker", "regex", @@ -5377,6 +5456,31 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_hostname" +version = "0.8.0" +dependencies = [ + "clap", + "dns-lookup", + "hostname", + "parking_lot", + "pi-uutils-ctx", + "uucore 0.8.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_ln" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "tempfile", + "thiserror 2.0.18", + "uucore 0.8.0", +] + [[package]] name = "uu_ls" version = "0.8.0" @@ -5412,6 +5516,19 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_mktemp" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "rand 0.10.2", + "tempfile", + "thiserror 2.0.18", + "uucore 0.8.0", +] + [[package]] name = "uu_mv" version = "0.8.0" @@ -5427,6 +5544,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uu_nproc" +version = "0.8.0" +dependencies = [ + "clap", + "libc", + "parking_lot", + "pi-uutils-ctx", + "uucore 0.8.0", +] + [[package]] name = "uu_paste" version = "0.8.0" @@ -5436,6 +5564,38 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_printenv" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "uucore 0.8.0", +] + +[[package]] +name = "uu_readlink" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "tempfile", + "uucore 0.8.0", +] + +[[package]] +name = "uu_realpath" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "tempfile", + "uucore 0.8.0", +] + [[package]] name = "uu_rm" version = "0.8.0" @@ -5464,6 +5624,20 @@ dependencies = [ "uucore 0.9.0", ] +[[package]] +name = "uu_seq" +version = "0.8.0" +dependencies = [ + "bigdecimal", + "clap", + "num-bigint", + "num-traits", + "parking_lot", + "pi-uutils-ctx", + "thiserror 2.0.18", + "uucore 0.8.0", +] + [[package]] name = "uu_sha1sum" version = "0.8.0" @@ -5536,6 +5710,33 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_stat" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "tempfile", + "thiserror 2.0.18", + "uucore 0.8.0", +] + +[[package]] +name = "uu_tac" +version = "0.8.0" +dependencies = [ + "clap", + "memchr", + "memmap2", + "parking_lot", + "pi-uutils-ctx", + "regex", + "tempfile", + "thiserror 2.0.18", + "uucore 0.8.0", +] + [[package]] name = "uu_tail" version = "0.8.0" @@ -5544,9 +5745,12 @@ dependencies = [ "libc", "memchr", "notify", + "parking_lot", "pi-uutils-ctx", "rstest", "rustix", + "tempfile", + "uu_tac", "uucore 0.8.0", "windows-sys 0.61.2", ] @@ -5560,6 +5764,24 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_touch" +version = "0.8.0" +dependencies = [ + "clap", + "filetime", + "jiff", + "libc", + "parking_lot", + "parse_datetime", + "pi-uutils-ctx", + "rustix", + "tempfile", + "thiserror 2.0.18", + "uucore 0.8.0", + "windows-sys 0.61.2", +] + [[package]] name = "uu_tr" version = "0.8.0" @@ -5571,6 +5793,28 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_truncate" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "tempfile", + "uucore 0.8.0", +] + +[[package]] +name = "uu_uname" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "platform-info", + "uucore 0.8.0", +] + [[package]] name = "uu_uniq" version = "0.8.0" @@ -5594,6 +5838,17 @@ dependencies = [ "uucore 0.8.0", ] +[[package]] +name = "uu_whoami" +version = "0.8.0" +dependencies = [ + "clap", + "parking_lot", + "pi-uutils-ctx", + "uucore 0.8.0", + "windows-sys 0.61.2", +] + [[package]] name = "uu_xargs" version = "0.8.0" @@ -5605,6 +5860,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "uu_yes" +version = "0.8.0" +dependencies = [ + "clap", + "itertools", + "parking_lot", + "pi-uutils-ctx", + "uucore 0.8.0", +] + [[package]] name = "uucore" version = "0.0.30" @@ -6330,6 +6596,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index e70a113de..c603960b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/vendor/brush-core", "crates/vendor/brush-builtins"] resolver = "3" [workspace.package] -version = "16.4.5" +version = "16.4.8" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/Dockerfile b/Dockerfile index ec15e9fcc..bc4c884a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -184,11 +184,9 @@ RUN bun install --frozen-lockfile --ignore-scripts # hoisted node_modules that `bun install` just produced. COPY . /pi/ -# Regenerate the docs index and tool views that `--ignore-scripts` skipped -# above. The root package.json's `prepare` script normally handles these on a -# vanilla install. -RUN bun --cwd=packages/coding-agent run gen:docs \ - && bun --cwd=/pi/packages/coding-agent run gen:tool-views +# Regenerate the tool views that `--ignore-scripts` skipped above. The root +# package.json's `prepare` script normally handles these on a vanilla install. +RUN bun --cwd=packages/coding-agent run gen:tool-views ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/omp"] CMD ["--help"] diff --git a/README.md b/README.md index 49a3fb43f..a9d03c0f5 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM ### Four knobs that make routing useful - **Custom providers** — Declare anything that speaks `openai-completions`, `openai-responses`, `openai-codex-responses`, `azure-openai-responses`, `anthropic-messages`, `google-generative-ai`, or `google-vertex` in `~/.omp/agent/models.yml`. -- **Fallback chains** — Per-role chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown. +- **Fallback chains** — Per-role or per-model chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown. - **Path-scoped models** — Scope `enabledModels` and `disabledProviders` entries to a `path:` prefix to pin a different model set on one repo without touching the global config. Scoped entries cover the path and everything under it. - **Round-robin credentials** — Stack API keys per provider and the runtime rotates with session affinity and per-credential backoff. Useful when one key would burn its quota by lunch. diff --git a/bun.lock b/bun.lock index 626703d4d..ed47a4f51 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -39,7 +39,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -55,7 +55,7 @@ }, "packages/catalog": { "name": "@oh-my-pi/pi-catalog", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "@bufbuild/protobuf": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -69,7 +69,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "16.4.5", + "version": "16.4.8", "bin": { "omp": "src/cli.ts", }, @@ -136,9 +136,41 @@ "@types/react-dom": "catalog:", }, }, + "packages/harbor-manager": { + "name": "@oh-my-pi/harbor-manager", + "version": "0.0.1", + "bin": { + "harbor-manager": "src/server.ts", + }, + "dependencies": { + "@oh-my-pi/hashline": "catalog:", + "@oh-my-pi/pi-agent-core": "catalog:", + "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-coding-agent": "catalog:", + "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/typescript-edit-benchmark": "workspace:*", + "clsx": "^2.1.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "diff": "catalog:", + "motion": "^12.15.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.3.0", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.4", + "vite": "catalog:", + }, + }, "packages/hashline": { "name": "@oh-my-pi/hashline", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "diff": "catalog:", "lru-cache": "catalog:", @@ -149,7 +181,7 @@ }, "packages/mnemopi": { "name": "@oh-my-pi/pi-mnemopi", - "version": "16.4.5", + "version": "16.4.8", "bin": { "mnemopi": "src/cli.ts", }, @@ -175,7 +207,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "16.4.5", + "version": "16.4.8", "devDependencies": { "@napi-rs/cli": "catalog:", "@types/bun": "catalog:", @@ -183,7 +215,7 @@ }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-natives": "catalog:", @@ -196,7 +228,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "16.4.5", + "version": "16.4.8", "bin": { "omp-stats": "./src/index.ts", }, @@ -222,7 +254,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "16.4.5", + "version": "16.4.8", "bin": { "omp-swarm": "src/cli.ts", }, @@ -236,19 +268,9 @@ "@oh-my-pi/pi-coding-agent": "^16", }, }, - "packages/terminal-bench": { - "name": "@oh-my-pi/terminal-bench", - "version": "0.0.1", - "bin": { - "tb2": "src/runner.ts", - }, - "devDependencies": { - "@types/bun": "catalog:", - }, - }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", @@ -263,9 +285,6 @@ "packages/typescript-edit-benchmark": { "name": "@oh-my-pi/typescript-edit-benchmark", "version": "0.0.1", - "bin": { - "typescript-edit-benchmark": "src/index.ts", - }, "dependencies": { "@babel/generator": "catalog:", "@babel/parser": "catalog:", @@ -289,7 +308,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "16.4.5", + "version": "16.4.8", "dependencies": { "@oh-my-pi/pi-natives": "catalog:", "handlebars": "catalog:", @@ -302,7 +321,7 @@ }, "packages/wire": { "name": "@oh-my-pi/pi-wire", - "version": "16.4.5", + "version": "16.4.8", "devDependencies": { "@types/bun": "catalog:", }, @@ -342,18 +361,18 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "16.4.5", - "@oh-my-pi/omp-stats": "16.4.5", - "@oh-my-pi/pi-agent-core": "16.4.5", - "@oh-my-pi/pi-ai": "16.4.5", - "@oh-my-pi/pi-catalog": "16.4.5", - "@oh-my-pi/pi-coding-agent": "16.4.5", - "@oh-my-pi/pi-mnemopi": "16.4.5", - "@oh-my-pi/pi-natives": "16.4.5", - "@oh-my-pi/pi-tui": "16.4.5", - "@oh-my-pi/pi-utils": "16.4.5", - "@oh-my-pi/pi-wire": "16.4.5", - "@oh-my-pi/snapcompact": "16.4.5", + "@oh-my-pi/hashline": "16.4.8", + "@oh-my-pi/omp-stats": "16.4.8", + "@oh-my-pi/pi-agent-core": "16.4.8", + "@oh-my-pi/pi-ai": "16.4.8", + "@oh-my-pi/pi-catalog": "16.4.8", + "@oh-my-pi/pi-coding-agent": "16.4.8", + "@oh-my-pi/pi-mnemopi": "16.4.8", + "@oh-my-pi/pi-natives": "16.4.8", + "@oh-my-pi/pi-tui": "16.4.8", + "@oh-my-pi/pi-utils": "16.4.8", + "@oh-my-pi/pi-wire": "16.4.8", + "@oh-my-pi/snapcompact": "16.4.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", @@ -454,6 +473,10 @@ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], @@ -496,11 +519,11 @@ "@huggingface/blake3-jit": ["@huggingface/blake3-jit@0.0.2", "", {}, "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w=="], - "@huggingface/hub": ["@huggingface/hub@2.13.2", "", { "dependencies": { "@huggingface/tasks": "^0.21.13", "@huggingface/xetchunk-wasm": "^0.1.0" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-I1Pbn9UcBHCt0M/SbZkKzYr3JVnbJp4YIFv74eVRH2Y22yT9Jpm6DtfM74tcjNLbC7SyFZ433znUw0szdFd4Fg=="], + "@huggingface/hub": ["@huggingface/hub@2.13.3", "", { "dependencies": { "@huggingface/tasks": "^0.21.24", "@huggingface/xetchunk-wasm": "^0.1.0" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-qAEbZ0x1vUQIUSComJ5EPgII+eI3nJB3gTPNTzjir6Kk/aCHHvPfCNMreS4iUg6gT17YDkvt7XBTndzh7617KQ=="], "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], - "@huggingface/tasks": ["@huggingface/tasks@0.21.20", "", {}, "sha512-pGw7pQI8TYSbrOVfNRdrYeISDEYOvQzOCEBceYjEKOH2wOmooc6eGfbYRhAALDgNKZCSISPYYFszRxHta3FaNw=="], + "@huggingface/tasks": ["@huggingface/tasks@0.21.25", "", {}, "sha512-u71rRx80Ynzy2oEDpRTSgOLyrzAV86gtnt4ajGHHI+1SQmsqmIUyzZG4Wi0TavDKdRsTa5QC/REDeJ3cFvkxvA=="], "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], @@ -740,6 +763,8 @@ "@oh-my-pi/collab-web": ["@oh-my-pi/collab-web@workspace:packages/collab-web"], + "@oh-my-pi/harbor-manager": ["@oh-my-pi/harbor-manager@workspace:packages/harbor-manager"], + "@oh-my-pi/hashline": ["@oh-my-pi/hashline@workspace:packages/hashline"], "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@workspace:packages/stats"], @@ -766,8 +791,6 @@ "@oh-my-pi/swarm-extension": ["@oh-my-pi/swarm-extension@workspace:packages/swarm-extension"], - "@oh-my-pi/terminal-bench": ["@oh-my-pi/terminal-bench@workspace:packages/terminal-bench"], - "@oh-my-pi/typescript-edit-benchmark": ["@oh-my-pi/typescript-edit-benchmark@workspace:packages/typescript-edit-benchmark"], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -850,7 +873,7 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], @@ -900,6 +923,14 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -928,6 +959,8 @@ "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], @@ -1000,6 +1033,8 @@ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], @@ -1026,6 +1061,24 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -1106,6 +1159,8 @@ "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1150,6 +1205,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], @@ -1224,13 +1281,13 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@1.24.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="], - "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="], "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], @@ -1254,6 +1311,12 @@ "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], + + "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mupdf": ["mupdf@1.28.0", "", {}, "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg=="], @@ -1264,7 +1327,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], @@ -1306,7 +1369,7 @@ "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], - "prettier": ["prettier@3.9.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -1322,6 +1385,8 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], @@ -1400,6 +1465,8 @@ "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -1446,7 +1513,7 @@ "vali-date": ["vali-date@1.0.0", "", {}, "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg=="], - "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], + "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], @@ -1536,7 +1603,7 @@ "cli-progress/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "cli-truncate/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1572,9 +1639,11 @@ "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "wrap-ansi/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/crates/pi-ast/src/ops.rs b/crates/pi-ast/src/ops.rs index 26935fb1e..2149f52cd 100644 --- a/crates/pi-ast/src/ops.rs +++ b/crates/pi-ast/src/ops.rs @@ -100,21 +100,130 @@ pub fn compile_pattern( strictness: &MatchStrictness, lang: SupportLang, ) -> Result { - let mut compiled = if let Some(selector) = selector.map(str::trim).filter(|s| !s.is_empty()) { + let selector = selector.map(str::trim).filter(|s| !s.is_empty()); + let mut compiled = if let Some(selector) = selector { Pattern::contextual(pattern, selector, lang) + .map_err(|err| anyhow!("Invalid pattern: {err}"))? } else { - Pattern::try_new(pattern, lang) - } - .map_err(|err| anyhow!("Invalid pattern: {err}"))?; + match Pattern::try_new(pattern, lang) { + Ok(compiled) => compiled, + // A fragment like `"key": $V` parses to multiple root nodes and is + // rejected as `MultipleNode`; auto-wrap it in a single-node context + // before giving up. Any other error, or a failed fallback, keeps the + // original message so genuinely-bad patterns behave as before. + Err(err @ PatternError::MultipleNode(_)) => { + match compile_wrapped_fallback(pattern, strictness, lang) { + Some(compiled) => return Ok(compiled), + None => return Err(anyhow!("Invalid pattern: {err}")), + } + }, + Err(err) => return Err(anyhow!("Invalid pattern: {err}")), + } + }; compiled.strictness = strictness.clone(); Ok(compiled) } +/// Language-specific wrapper template used to turn a multi-node fragment into a +/// single selectable node. `None` for languages without a template — those keep +/// the original `MultipleNode` error. +const fn wrapper_template(lang: SupportLang) -> Option<(&'static str, &'static str, &'static str)> { + // (prefix, suffix, selector-kind); the fragment is spliced between + // prefix/suffix. + match lang { + SupportLang::Json => Some(("{", "}", "pair")), + _ => None, + } +} + +/// Retry a fragment that failed as `MultipleNode` by wrapping it in a minimal +/// valid context and selecting the node kind that spans it. Returns the +/// compiled pattern (with `strictness` applied) or `None` if this language has +/// no template or the wrapped form still fails to compile. +fn compile_wrapped_fallback( + pattern: &str, + strictness: &MatchStrictness, + lang: SupportLang, +) -> Option { + let (prefix, suffix, selector) = wrapper_template(lang)?; + // JSON only accepts a bare `$V` inside a string, so quote value-position + // metavars; ast-grep still reads the quoted `"$V"` as capture `V`. + let prepared = if lang == SupportLang::Json { + quote_bare_metavars(pattern) + } else { + pattern.to_string() + }; + let context = format!("{prefix} {prepared} {suffix}"); + let mut compiled = Pattern::contextual(&context, selector, lang).ok()?; + compiled.strictness = strictness.clone(); + Some(compiled) +} + +/// Wrap bare `$NAME` / `$$$NAME` metavars in double quotes so a JSON wrapper +/// parses. Metavars already inside a string literal (including `"$V"`) are left +/// untouched; a quote toggles in/out of string context. +fn quote_bare_metavars(pattern: &str) -> String { + let bytes = pattern.as_bytes(); + let mut out = String::with_capacity(pattern.len() + 4); + let mut in_string = false; + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'"' && (index == 0 || bytes[index - 1] != b'\\') { + in_string = !in_string; + out.push('"'); + index += 1; + continue; + } + if byte == b'$' && !in_string { + // Consume `$`, an optional `$$` ellipsis, then the identifier. + let start = index; + index += 1; + if bytes[index..].starts_with(b"$$") { + index += 2; + } + while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + out.push('"'); + out.push_str(&pattern[start..index]); + out.push('"'); + continue; + } + // Copy this byte's full UTF-8 char so multi-byte content is preserved. + let char_end = next_char_boundary(bytes, index); + out.push_str(&pattern[index..char_end]); + index = char_end; + } + out +} + +/// Byte index of the end of the UTF-8 character starting at `index`. +fn next_char_boundary(bytes: &[u8], index: usize) -> usize { + let mut end = index + 1; + while end < bytes.len() && (bytes[end] & 0b1100_0000) == 0b1000_0000 { + end += 1; + } + end +} + pub fn compile_search_patterns( pattern: &str, language: SupportLang, ) -> Result, PatternError> { - let mut compiled = vec![Pattern::try_new(pattern, language)?]; + let mut compiled = match Pattern::try_new(pattern, language) { + Ok(compiled) => vec![compiled], + // Multi-node fragments (e.g. `"key": $V`) get the same auto-wrap fallback + // as the edit path; other errors propagate unchanged. + Err(err @ PatternError::MultipleNode(_)) => { + match compile_wrapped_fallback(pattern, &MatchStrictness::Smart, language) { + Some(compiled) => vec![compiled], + None => return Err(err), + } + }, + Err(err) => return Err(err), + }; if language == SupportLang::Rust { let trimmed = pattern.trim_end(); if let Some(contextual) = compile_rust_contextual_pattern(trimmed) { @@ -187,7 +296,20 @@ pub fn rewrite_source( pub fn apply_edits(content: &str, edits: &[Edit]) -> Result { let mut sorted: Vec<&Edit> = edits.iter().collect(); - sorted.sort_by_key(|edit| edit.position); + sorted.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then(a.deleted_length.cmp(&b.deleted_length)) + .then(a.inserted_text.cmp(&b.inserted_text)) + }); + // Byte-identical edits (same span, same replacement) are one deterministic + // edit: multiple patterns matching the same node collapse instead of + // tripping the overlap check. Only divergent overlaps are ambiguous. + sorted.dedup_by(|a, b| { + a.position == b.position + && a.deleted_length == b.deleted_length + && a.inserted_text == b.inserted_text + }); let mut prev_end = 0usize; for edit in &sorted { if edit.position < prev_end { @@ -299,4 +421,15 @@ mod tests { ]; assert!(apply_edits(source, &edits).is_err()); } + + #[test] + fn apply_edits_dedupes_identical_edits() { + let source = "abcdef"; + let edits = vec![ + Edit:: { position: 1, deleted_length: 3, inserted_text: b"x".to_vec() }, + Edit:: { position: 1, deleted_length: 3, inserted_text: b"x".to_vec() }, + ]; + let output = apply_edits(source, &edits).expect("identical edits should collapse to one"); + assert_eq!(output, "axef"); + } } diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index d7ed96af6..4eec13320 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -1065,12 +1065,23 @@ fn ast_edit_blocking( 'patterns: for (_pattern, rewrite, compiled) in &compiled_rules { for matched in ast.root().find_all(compiled.clone()) { ct.heartbeat()?; + let edit = matched.replace_by(rewrite.as_str()); + // Multiple rules matching the same node with the same output are one + // deterministic edit; list and count it once instead of staging a + // duplicate that trips the apply-time overlap check. + let duplicate = file_changes.iter().any(|entry: &PendingFileChange| { + entry.edit.position == edit.position + && entry.edit.deleted_length == edit.deleted_length + && entry.edit.inserted_text == edit.inserted_text + }); + if duplicate { + continue; + } if changes.len() + file_changes.len() >= max_replacements as usize { limit_reached = true; reached_max_replacements = true; break 'patterns; } - let edit = matched.replace_by(rewrite.as_str()); let range = matched.range(); let start = matched.start_pos(); let end = matched.end_pos(); @@ -1334,6 +1345,58 @@ mod tests { assert!(apply_edits(source, &edits).is_err()); } + #[test] + fn dedupes_byte_identical_edits() { + let source = "abcdef"; + let edits = vec![ + Edit:: { position: 1, deleted_length: 3, inserted_text: b"x".to_vec() }, + Edit:: { position: 1, deleted_length: 3, inserted_text: b"x".to_vec() }, + ]; + let output = apply_edits(source, &edits).expect("identical edits should collapse to one"); + assert_eq!(output, "axef"); + } + + #[test] + fn ast_edit_dedupes_identical_matches_across_rules() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after UNIX_EPOCH") + .as_nanos(); + let root = std::env::temp_dir().join(format!("pi-ast-dedupe-{unique}")); + fs::create_dir_all(&root).expect("temp dedupe dir should be created"); + let tree = TempTree { root }; + let file_path = tree.root.join("a.ts"); + fs::write(&file_path, "const b = foo(bar);\n").expect("temp file a.ts should be written"); + + // Both rules match the same call node and produce the byte-identical + // replacement; the deterministic edit must apply once, not error as an + // ambiguous overlap. + let mut rewrites = HashMap::new(); + rewrites.insert("foo($X)".to_string(), "qux($X)".to_string()); + rewrites.insert("foo(bar)".to_string(), "qux(bar)".to_string()); + + let result = ast_edit_blocking( + task::CancelToken::default(), + Some(rewrites), + Some("ts".to_string()), + Some(tree.root.to_string_lossy().into_owned()), + None, + None, + None, + Some(false), + None, + None, + None, + ) + .expect("identical duplicate matches should apply cleanly"); + + assert_eq!(result.total_replacements, 1, "duplicate match must be counted once"); + assert_eq!( + fs::read_to_string(&file_path).expect("a.ts should be readable"), + "const b = qux(bar);\n", + ); + } + fn make_apply_failure_tree() -> TempTree { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 881d589e0..2ef74efb6 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -248,7 +248,7 @@ fn create_windows_napi_tokio_runtime() -> Option { /// 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 = "__piNativesV16_4_5")] +#[napi(js_name = "__piNativesV16_4_8")] pub const fn pi_natives_version_sentinel() {} /// Native module entry point: install crash diagnostics before any tool can diff --git a/crates/pi-shell/Cargo.toml b/crates/pi-shell/Cargo.toml index adad30dd9..f62d2d3b3 100644 --- a/crates/pi-shell/Cargo.toml +++ b/crates/pi-shell/Cargo.toml @@ -54,6 +54,23 @@ uu_sha512sum = { path = "../vendor/uu-sha512sum" } uu_b2sum = { path = "../vendor/uu-b2sum" } uu_basename = { path = "../vendor/uu-basename" } uu_dirname = { path = "../vendor/uu-dirname" } +uu_readlink = { path = "../vendor/uu-readlink" } +uu_realpath = { path = "../vendor/uu-realpath" } +uu_touch = { path = "../vendor/uu-touch" } +uu_stat = { path = "../vendor/uu-stat" } +uu_date = { path = "../vendor/uu-date" } +uu_mktemp = { path = "../vendor/uu-mktemp" } +uu_seq = { path = "../vendor/uu-seq" } +uu_yes = { path = "../vendor/uu-yes" } +uu_printenv = { path = "../vendor/uu-printenv" } +uu_ln = { path = "../vendor/uu-ln" } +uu_truncate = { path = "../vendor/uu-truncate" } +uu_tac = { path = "../vendor/uu-tac" } +uu_nproc = { path = "../vendor/uu-nproc" } +uu_uname = { path = "../vendor/uu-uname" } +uu_whoami = { path = "../vendor/uu-whoami" } +uu_hostname = { path = "../vendor/uu-hostname" } +pi_uu_diff = { path = "../pi-uu-diff" } uu_cut = { path = "../vendor/uu-cut" } uu_tee = { path = "../vendor/uu-tee" } uu_tr = { path = "../vendor/uu-tr" } diff --git a/crates/pi-shell/src/coreutils.rs b/crates/pi-shell/src/coreutils.rs index a1372f404..caf7c064c 100644 --- a/crates/pi-shell/src/coreutils.rs +++ b/crates/pi-shell/src/coreutils.rs @@ -219,6 +219,23 @@ uutil_builtin!(pub fn sha512sum_builtin => uu_sha512sum::run); uutil_builtin!(pub fn b2sum_builtin => uu_b2sum::run); uutil_builtin!(pub fn basename_builtin => uu_basename::run); uutil_builtin!(pub fn dirname_builtin => uu_dirname::run); +uutil_builtin!(pub fn readlink_builtin => uu_readlink::run); +uutil_builtin!(pub fn realpath_builtin => uu_realpath::run); +uutil_builtin!(pub fn touch_builtin => uu_touch::run); +uutil_builtin!(pub fn stat_builtin => uu_stat::run); +uutil_builtin!(pub fn date_builtin => uu_date::run); +uutil_builtin!(pub fn mktemp_builtin => uu_mktemp::run); +uutil_builtin!(pub fn seq_builtin => uu_seq::run); +uutil_builtin!(pub fn yes_builtin => uu_yes::run); +uutil_builtin!(pub fn printenv_builtin => uu_printenv::run); +uutil_builtin!(pub fn ln_builtin => uu_ln::run); +uutil_builtin!(pub fn truncate_builtin => uu_truncate::run); +uutil_builtin!(pub fn tac_builtin => uu_tac::run); +uutil_builtin!(pub fn nproc_builtin => uu_nproc::run); +uutil_builtin!(pub fn uname_builtin => uu_uname::run); +uutil_builtin!(pub fn whoami_builtin => uu_whoami::run); +uutil_builtin!(pub fn hostname_builtin => uu_hostname::run); +uutil_builtin!(pub fn diff_builtin => pi_uu_diff::run); uutil_builtin!(pub fn cut_builtin => uu_cut::run); uutil_builtin!(pub fn tee_builtin => uu_tee::run); uutil_builtin!(pub fn tr_builtin => uu_tr::run); diff --git a/crates/pi-shell/src/lib.rs b/crates/pi-shell/src/lib.rs index 2f3f5929f..204724703 100644 --- a/crates/pi-shell/src/lib.rs +++ b/crates/pi-shell/src/lib.rs @@ -4,6 +4,7 @@ mod fd; pub mod minimizer; pub mod process; pub mod shell; +mod which; #[cfg(windows)] pub mod windows; diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 46e454e8f..f2254a258 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -632,6 +632,23 @@ async fn create_session_for_run( shell.register_builtin("b2sum", crate::coreutils::b2sum_builtin()); shell.register_builtin("basename", crate::coreutils::basename_builtin()); shell.register_builtin("dirname", crate::coreutils::dirname_builtin()); + shell.register_builtin("readlink", crate::coreutils::readlink_builtin()); + shell.register_builtin("realpath", crate::coreutils::realpath_builtin()); + shell.register_builtin("touch", crate::coreutils::touch_builtin()); + shell.register_builtin("stat", crate::coreutils::stat_builtin()); + shell.register_builtin("date", crate::coreutils::date_builtin()); + shell.register_builtin("mktemp", crate::coreutils::mktemp_builtin()); + shell.register_builtin("seq", crate::coreutils::seq_builtin()); + shell.register_builtin("yes", crate::coreutils::yes_builtin()); + shell.register_builtin("printenv", crate::coreutils::printenv_builtin()); + shell.register_builtin("truncate", crate::coreutils::truncate_builtin()); + shell.register_builtin("tac", crate::coreutils::tac_builtin()); + shell.register_builtin("nproc", crate::coreutils::nproc_builtin()); + shell.register_builtin("uname", crate::coreutils::uname_builtin()); + shell.register_builtin("whoami", crate::coreutils::whoami_builtin()); + shell.register_builtin("hostname", crate::coreutils::hostname_builtin()); + shell.register_builtin("which", crate::which::which_builtin()); + shell.register_builtin("diff", crate::coreutils::diff_builtin()); shell.register_builtin("cut", crate::coreutils::cut_builtin()); shell.register_builtin("tee", crate::coreutils::tee_builtin()); shell.register_builtin("tr", crate::coreutils::tr_builtin()); @@ -647,6 +664,8 @@ async fn create_session_for_run( if !uutils_env_disabled(config, "PI_DISABLE_MV_BUILTIN") { shell.register_builtin("mv", crate::coreutils::mv_builtin()); } + // ln can clobber existing files via -f; gate it with the destructive set. + shell.register_builtin("ln", crate::coreutils::ln_builtin()); } } diff --git a/crates/pi-shell/src/which.rs b/crates/pi-shell/src/which.rs new file mode 100644 index 000000000..253a414e7 --- /dev/null +++ b/crates/pi-shell/src/which.rs @@ -0,0 +1,252 @@ +//! In-process `which` builtin backed by brush's PATH-search helpers. +//! +//! Follows which(1) (GNU/debianutils) semantics: each name operand is looked +//! up in the shell's `PATH`; the first match is printed (all matches with +//! `-a`). Lookup failures are silent; the exit status is 0 when every name +//! was found and 1 when any name was missing. + +use std::{ + ffi::OsString, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use brush_core::{ + Error, + builtins::{BoxFuture, ContentOptions, ContentType, Registration}, + commands::{CommandArg, ExecutionContext}, + extensions::ShellExtensions, + openfiles::{OpenFile, OpenFiles, null}, + pathsearch, + results::ExecutionResult, + sys, +}; +use clap::{Parser, error::ErrorKind}; + +#[derive(Parser, Debug)] +#[command(name = "which", about = "Locate a command's executable in the shell's PATH")] +struct WhichCli { + /// Print all matching executables in PATH, not just the first. + #[arg(short = 'a', long = "all")] + all: bool, + + /// Command names to locate. + #[arg(value_name = "name")] + names: Vec, +} + +/// Creates the `which` shell builtin registration. +pub fn which_builtin() -> Registration { + fn execute( + context: ExecutionContext<'_, SE>, + args: Vec, + ) -> BoxFuture<'_, Result> { + Box::pin(std::future::ready(Ok(run_which(context, args)))) + } + + Registration { + execute_func: execute::, + content_func: which_content, + disabled: false, + special_builtin: false, + declaration_builtin: false, + transparent_background_wrapper: false, + } +} + +fn run_which( + context: ExecutionContext<'_, SE>, + args: Vec, +) -> ExecutionResult { + let mut stdout = context + .try_fd(OpenFiles::STDOUT_FD) + .unwrap_or_else(null_sink); + let mut stderr = context + .try_fd(OpenFiles::STDERR_FD) + .unwrap_or_else(null_sink); + let cwd = context.shell.working_dir().to_path_buf(); + let path_var = context + .shell + .env_str("PATH") + .map(std::borrow::Cow::into_owned) + .unwrap_or_default(); + let argv: Vec = args + .iter() + .map(|arg| OsString::from(arg.to_string())) + .collect(); + + let cli = match WhichCli::try_parse_from(argv) { + Ok(cli) => cli, + Err(err) => { + let rendered = err.to_string(); + let code = match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + let _ = write!(stdout, "{rendered}"); + 0 + }, + _ => { + let _ = write!(stderr, "{rendered}"); + 2 + }, + }; + return ExecutionResult::new(code); + }, + }; + + let mut all_found = true; + for name in &cli.names { + let matches = find_matches(name, &path_var, &cwd, cli.all); + if matches.is_empty() { + // which(1) reports missing names via the exit status only. + all_found = false; + } + for path in matches { + let _ = writeln!(stdout, "{}", path.display()); + } + } + + ExecutionResult::new(u8::from(!all_found)) +} + +/// Collects the executable matches for a single `which` name operand. +/// +/// A name containing a path separator is checked directly against `cwd` +/// (yielding at most one match); otherwise each `PATH` entry — with relative +/// and empty entries resolved against `cwd` — is probed in `PATH` order. +/// Returns only the first match unless `all` is set. Windows `PATHEXT` +/// resolution is handled by [`brush_core::sys::fs::resolve_executable`]. +fn find_matches(name: &str, path_var: &str, cwd: &Path, all: bool) -> Vec { + if sys::fs::contains_path_separator(name) { + let candidate = cwd.join(name); + if candidate.is_dir() { + return Vec::new(); + } + return sys::fs::resolve_executable(candidate).into_iter().collect(); + } + + let dirs = sys::fs::split_paths(path_var).map(|dir| { + if dir.as_os_str().is_empty() { + // POSIX: an empty PATH entry names the current directory. + cwd.to_path_buf() + } else if dir.is_relative() { + cwd.join(dir) + } else { + dir + } + }); + + let mut found = pathsearch::search_for_executable(dirs, name); + if all { + found.collect() + } else { + found.next().into_iter().collect() + } +} + +fn null_sink() -> OpenFile { + null().unwrap_or_else(|_| OpenFile::from(io::stdout())) +} + +#[allow( + clippy::unnecessary_wraps, + reason = "signature must match brush's CommandContentFunc fn pointer" +)] +fn which_content( + _name: &str, + _content_type: ContentType, + _options: &ContentOptions, +) -> Result { + Ok("which: which [-a] name [name ...]\n".to_string()) +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use std::{ + env, fs, + os::unix::fs::PermissionsExt, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::find_matches; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + + /// Creates a fresh, canonicalized temp directory (macOS `/var` is a + /// symlink; canonicalizing keeps constructed and probed paths identical). + fn temp_root(tag: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let root = env::temp_dir().join(format!( + "pi-shell-which-{tag}-{}-{}-{}", + std::process::id(), + nanos, + COUNTER.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&root).expect("temp dir should be created"); + fs::canonicalize(&root).expect("temp dir should canonicalize") + } + + fn place_file(dir: &std::path::Path, name: &str, executable: bool) -> PathBuf { + let path = dir.join(name); + fs::write(&path, b"#!/bin/sh\n").expect("file should be written"); + let mode = if executable { 0o755 } else { 0o644 }; + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .expect("permissions should be set"); + path + } + + #[test] + fn finds_only_executable_files() { + let dir = temp_root("exec-only"); + let tool = place_file(&dir, "tool", true); + place_file(&dir, "blob", false); + let path_var = dir.display().to_string(); + + assert_eq!(find_matches("tool", &path_var, &dir, false), vec![tool]); + assert!(find_matches("blob", &path_var, &dir, false).is_empty()); + assert!(find_matches("missing", &path_var, &dir, false).is_empty()); + } + + #[test] + fn all_flag_returns_matches_in_path_order() { + let dir_a = temp_root("all-a"); + let dir_b = temp_root("all-b"); + let tool_a = place_file(&dir_a, "tool", true); + let tool_b = place_file(&dir_b, "tool", true); + let path_var = format!("{}:{}", dir_a.display(), dir_b.display()); + let cwd = temp_root("all-cwd"); + + assert_eq!(find_matches("tool", &path_var, &cwd, true), vec![tool_a.clone(), tool_b]); + // Without -a only the first PATH entry's match is returned. + assert_eq!(find_matches("tool", &path_var, &cwd, false), vec![tool_a]); + } + + #[test] + fn name_with_separator_resolves_against_cwd() { + let cwd = temp_root("slash"); + let bin = cwd.join("bin"); + fs::create_dir_all(&bin).expect("bin dir should be created"); + let tool = place_file(&bin, "tool", true); + place_file(&bin, "blob", false); + + // PATH is irrelevant for names containing a separator. + assert_eq!(find_matches("bin/tool", "", &cwd, false), vec![tool]); + assert!(find_matches("bin/blob", "", &cwd, false).is_empty()); + // A directory is never a match, even with execute bits set. + assert!(find_matches("./bin", "", &cwd, false).is_empty()); + } + + #[test] + fn relative_path_entries_resolve_against_cwd() { + let cwd = temp_root("rel-entry"); + let bin = cwd.join("bin"); + fs::create_dir_all(&bin).expect("bin dir should be created"); + let tool = place_file(&bin, "tool", true); + + assert_eq!(find_matches("tool", "bin", &cwd, false), vec![tool]); + } +} diff --git a/crates/pi-uu-diff/Cargo.toml b/crates/pi-uu-diff/Cargo.toml new file mode 100644 index 000000000..4d9bed570 --- /dev/null +++ b/crates/pi-uu-diff/Cargo.toml @@ -0,0 +1,21 @@ +# diff implemented from scratch on top of the `similar` diffing library, with +# I/O and path resolution routed through pi-uutils-ctx so it can run in-process +# as a shell builtin. Entry point: `pi_uu_diff::run`. +[package] +name = "pi_uu_diff" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "diff ~ similar-backed file comparison (in-process shell builtin)" + +[lib] +path = "src/lib.rs" + +[dependencies] +clap = { version = "4", features = ["wrap_help"] } +pi-uutils-ctx = { path = "../pi-uutils-ctx" } +similar = "3.1.0" + +[dev-dependencies] +parking_lot.workspace = true +tempfile = "3" diff --git a/crates/pi-uu-diff/src/lib.rs b/crates/pi-uu-diff/src/lib.rs new file mode 100644 index 000000000..330e8833e --- /dev/null +++ b/crates/pi-uu-diff/src/lib.rs @@ -0,0 +1,608 @@ +//! `diff` implemented as an in-process shell builtin on top of the `similar` +//! diffing library. All I/O and path resolution is routed through +//! `pi-uutils-ctx` so the builtin writes to the command's redirected file +//! descriptors and resolves relative paths against the shell's working +//! directory, while operands are printed as typed. +//! +//! Scope: unified output only (`-u` is accepted and implied, `-U N` controls +//! the context size), `-q/--brief`, `-N/--new-file` (absent files compare as +//! empty), binary detection, `-` for the context stdin, and unconditional +//! recursive directory comparison (`Only in : ` lines plus +//! `diff -r A/x B/x`-headed per-pair diffs). +//! +//! Entry point: [`run`]. It never calls `std::process::exit`; clap +//! help/usage/error output is rendered to the context streams and an exit code +//! is returned following the GNU convention (0 = identical, 1 = differences +//! found, 2 = trouble). + +use std::{ + collections::BTreeSet, + ffi::{OsStr, OsString}, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use similar::TextDiff; + +const OPT_UNIFIED_FLAG: &str = "unified-flag"; +const OPT_UNIFIED: &str = "unified"; +const OPT_BRIEF: &str = "brief"; +const OPT_RECURSIVE: &str = "recursive"; +const OPT_NEW_FILE: &str = "new-file"; +const OPT_COLOR: &str = "color"; +const ARG_FILES: &str = "files"; + +/// In-process builtin entry point. Parses the arguments directly, renders clap +/// help/usage/version to the context streams, and maps errors to the GNU diff +/// exit-code convention, so it is safe to run inside the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 2; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match diff_main(&matches) { + Ok(code) => code, + Err(msg) => { + let _ = writeln!(pi_uutils_ctx::stderr(), "diff: {msg}"); + 2 + }, + } +} + +pub fn uu_app() -> Command { + Command::new("diff") + .version(concat!("diff (pi-uu-diff) ", env!("CARGO_PKG_VERSION"))) + .about("Compare files line by line.") + .override_usage(format_usage("diff [OPTION]... FILE1 FILE2")) + .infer_long_args(true) + .arg( + Arg::new(OPT_UNIFIED_FLAG) + .short('u') + .help("output 3 lines of unified context (the default output format)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_UNIFIED) + .short('U') + .long(OPT_UNIFIED) + .value_name("NUM") + .help("output NUM lines of unified context") + .value_parser(clap::value_parser!(usize)), + ) + .arg( + Arg::new(OPT_BRIEF) + .short('q') + .long(OPT_BRIEF) + .help("report only when files differ") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_RECURSIVE) + .short('r') + .long(OPT_RECURSIVE) + .help("recursively compare subdirectories (always on for directories)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_NEW_FILE) + .short('N') + .long(OPT_NEW_FILE) + .help("treat absent files as empty") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_COLOR) + .long(OPT_COLOR) + .value_name("WHEN") + .num_args(0..=1) + .require_equals(true) + .default_missing_value("auto") + .help("accepted for compatibility; output is never colorized"), + ) + .arg( + Arg::new(ARG_FILES) + .required(true) + .num_args(2) + .value_parser(clap::value_parser!(OsString)) + .value_hint(clap::ValueHint::AnyPath), + ) +} + +#[derive(Clone, Copy)] +struct Options { + context: usize, + brief: bool, + new_file: bool, +} + +/// A classified operand: what the name as typed refers to on disk after +/// resolution against the scope working directory. +enum Operand { + /// The context stdin (`-`). + Stdin, + /// A regular (or other non-directory) file at the resolved path. + File(PathBuf), + /// A directory at the resolved path. + Dir(PathBuf), + /// A missing file tolerated by `-N` and compared as empty. + Absent, +} + +fn diff_main(matches: &ArgMatches) -> Result { + let files: Vec<&OsString> = matches.get_many::(ARG_FILES).unwrap().collect(); + let opts = Options { + context: matches.get_one::(OPT_UNIFIED).copied().unwrap_or(3), + brief: matches.get_flag(OPT_BRIEF), + new_file: matches.get_flag(OPT_NEW_FILE), + }; + + let (mut name_a, mut name_b) = (PathBuf::from(files[0]), PathBuf::from(files[1])); + let mut op_a = classify(&name_a, opts.new_file)?; + let mut op_b = classify(&name_b, opts.new_file)?; + + // GNU: comparing a directory with a non-directory compares + // / with the other operand. + let a_is_dir = matches!(op_a, Operand::Dir(_)); + let b_is_dir = matches!(op_b, Operand::Dir(_)); + if a_is_dir != b_is_dir { + if matches!(op_a, Operand::Stdin) || matches!(op_b, Operand::Stdin) { + return Err("cannot compare '-' to a directory".to_string()); + } + if a_is_dir { + name_a = descend(&name_a, &name_b)?; + op_a = classify(&name_a, opts.new_file)?; + } else { + name_b = descend(&name_b, &name_a)?; + op_b = classify(&name_b, opts.new_file)?; + } + } + + let differed = if let (Operand::Dir(res_a), Operand::Dir(res_b)) = (&op_a, &op_b) { + diff_dirs(&name_a, res_a, &name_b, res_b, opts)? + } else { + let bytes_a = read_operand(&op_a, &name_a)?; + let bytes_b = read_operand(&op_b, &name_b)?; + diff_pair(&name_a, &bytes_a, &name_b, &bytes_b, opts, None)? + }; + Ok(i32::from(differed)) +} + +/// Replaces a directory operand with `/` for the GNU +/// dir-vs-file comparison form. +fn descend(dir: &Path, other: &Path) -> Result { + let base = other + .file_name() + .ok_or_else(|| format!("cannot compare {} to a directory", other.display()))?; + Ok(dir.join(base)) +} + +fn classify(name: &Path, new_file: bool) -> Result { + if name.as_os_str() == OsStr::new("-") { + return Ok(Operand::Stdin); + } + // Resolve the operand against the shell working directory; `name` is kept + // for display (GNU prints operands as typed). + let resolved = pi_uutils_ctx::resolve(name); + match fs::metadata(&resolved) { + Ok(meta) if meta.is_dir() => Ok(Operand::Dir(resolved)), + Ok(_) => Ok(Operand::File(resolved)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound && new_file => Ok(Operand::Absent), + Err(err) => Err(format!("{}: {}", name.display(), io_msg(&err))), + } +} + +fn read_operand(op: &Operand, name: &Path) -> Result, String> { + match op { + Operand::Stdin => { + let mut buf = Vec::new(); + pi_uutils_ctx::stdin() + .read_to_end(&mut buf) + .map_err(|err| format!("-: {}", io_msg(&err)))?; + Ok(buf) + }, + Operand::File(resolved) => { + fs::read(resolved).map_err(|err| format!("{}: {}", name.display(), io_msg(&err))) + }, + Operand::Dir(_) => unreachable!("directories are handled by diff_dirs"), + Operand::Absent => Ok(Vec::new()), + } +} + +/// Diffs one pair of already-read inputs, writing to the context stdout. +/// `prefix` is the `diff -r A/x B/x` line emitted before per-pair output in +/// directory mode. Returns whether the inputs differed. +fn diff_pair( + name_a: &Path, + bytes_a: &[u8], + name_b: &Path, + bytes_b: &[u8], + opts: Options, + prefix: Option<&str>, +) -> Result { + if bytes_a == bytes_b { + return Ok(false); + } + let mut out = pi_uutils_ctx::stdout(); + let (label_a, label_b) = (name_a.display().to_string(), name_b.display().to_string()); + if opts.brief { + writeln!(out, "Files {label_a} and {label_b} differ").map_err(|e| io_msg(&e))?; + return Ok(true); + } + if is_binary(bytes_a) || is_binary(bytes_b) { + writeln!(out, "Binary files {label_a} and {label_b} differ").map_err(|e| io_msg(&e))?; + return Ok(true); + } + if let Some(line) = prefix { + writeln!(out, "{line}").map_err(|e| io_msg(&e))?; + } + let old = String::from_utf8_lossy(bytes_a); + let new = String::from_utf8_lossy(bytes_b); + let diff = TextDiff::from_lines(old.as_ref(), new.as_ref()); + write!( + out, + "{}", + diff + .unified_diff() + .context_radius(opts.context) + .header(&label_a, &label_b) + ) + .map_err(|e| io_msg(&e))?; + Ok(true) +} + +/// Recursively compares two directories over the sorted union of their +/// entries, GNU `diff -r` style. Returns whether any difference was found. +fn diff_dirs( + name_a: &Path, + res_a: &Path, + name_b: &Path, + res_b: &Path, + opts: Options, +) -> Result { + let mut names: BTreeSet = BTreeSet::new(); + for (dir_name, dir_res) in [(name_a, res_a), (name_b, res_b)] { + let entries = fs::read_dir(dir_res) + .map_err(|err| format!("{}: {}", dir_name.display(), io_msg(&err)))?; + for entry in entries { + let entry = entry.map_err(|err| format!("{}: {}", dir_name.display(), io_msg(&err)))?; + names.insert(entry.file_name()); + } + } + + let mut differed = false; + for name in names { + if pi_uutils_ctx::is_cancelled() { + return Err("interrupted".to_string()); + } + let (child_name_a, child_res_a) = (name_a.join(&name), res_a.join(&name)); + let (child_name_b, child_res_b) = (name_b.join(&name), res_b.join(&name)); + let meta_a = fs::metadata(&child_res_a).ok(); + let meta_b = fs::metadata(&child_res_b).ok(); + match (meta_a.as_ref(), meta_b.as_ref()) { + (Some(ma), Some(mb)) if ma.is_dir() && mb.is_dir() => { + differed |= diff_dirs(&child_name_a, &child_res_a, &child_name_b, &child_res_b, opts)?; + }, + (Some(ma), Some(mb)) if ma.is_dir() != mb.is_dir() => { + let (dir, file) = if ma.is_dir() { + (&child_name_a, &child_name_b) + } else { + (&child_name_b, &child_name_a) + }; + writeln!( + pi_uutils_ctx::stdout(), + "File {} is a directory while file {} is a regular file", + dir.display(), + file.display() + ) + .map_err(|e| io_msg(&e))?; + differed = true; + }, + (Some(_), Some(_)) => { + let bytes_a = fs::read(&child_res_a) + .map_err(|err| format!("{}: {}", child_name_a.display(), io_msg(&err)))?; + let bytes_b = fs::read(&child_res_b) + .map_err(|err| format!("{}: {}", child_name_b.display(), io_msg(&err)))?; + let prefix = format!("diff -r {} {}", child_name_a.display(), child_name_b.display()); + differed |= + diff_pair(&child_name_a, &bytes_a, &child_name_b, &bytes_b, opts, Some(&prefix))?; + }, + (Some(meta), None) | (None, Some(meta)) => { + let in_a = meta_b.is_none(); + if opts.new_file && meta.is_file() { + // -N: compare the present file against an empty absent one. + let (present_name, present_res) = if in_a { + (&child_name_a, &child_res_a) + } else { + (&child_name_b, &child_res_b) + }; + let bytes = fs::read(present_res) + .map_err(|err| format!("{}: {}", present_name.display(), io_msg(&err)))?; + let prefix = + format!("diff -r {} {}", child_name_a.display(), child_name_b.display()); + let (ba, bb): (&[u8], &[u8]) = if in_a { (&bytes, &[]) } else { (&[], &bytes) }; + differed |= diff_pair(&child_name_a, ba, &child_name_b, bb, opts, Some(&prefix))?; + } else { + let present_dir = if in_a { name_a } else { name_b }; + writeln!( + pi_uutils_ctx::stdout(), + "Only in {}: {}", + present_dir.display(), + Path::new(&name).display() + ) + .map_err(|e| io_msg(&e))?; + differed = true; + } + }, + (None, None) => {}, + } + } + Ok(differed) +} + +/// NUL byte within the first 8 KiB marks the input as binary, matching the +/// heuristic GNU diff applies to decide between text and binary output. +fn is_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8192).any(|&b| b == 0) +} + +/// Renders an I/O error without the Rust-specific ` (os error N)` suffix so +/// messages read like GNU diff's (`diff: x: No such file or directory`). +fn io_msg(err: &std::io::Error) -> String { + let msg = err.to_string(); + match msg.find(" (os error") { + Some(idx) => msg[..idx].to_string(), + None => msg, + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_with(cwd: PathBuf, stdin: &[u8], args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::Cursor::new(stdin.to_vec())), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("diff") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + run_with(cwd, b"", args) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[test] + fn identical_files_print_nothing_and_exit_zero() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "one\ntwo\n").unwrap(); + fs::write(root.join("b.txt"), "one\ntwo\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["a.txt", "b.txt"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + } + + /// Relative operands must resolve against the scope cwd (a tempdir), not + /// the process cwd — the pi-specific contract. + #[test] + fn differing_files_emit_unified_diff_with_typed_headers() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap(); + fs::write(root.join("b.txt"), "one\nTWO\nthree\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["a.txt", "b.txt"]); + assert_eq!(code, 1); + assert_eq!(stderr, ""); + assert!(stdout.starts_with("--- a.txt\n+++ b.txt\n@@ "), "got: {stdout}"); + assert!(stdout.contains("\n-two\n"), "got: {stdout}"); + assert!(stdout.contains("\n+TWO\n"), "got: {stdout}"); + // Context lines around the change (default -U 3). + assert!(stdout.contains("\n one\n"), "got: {stdout}"); + assert!(stdout.contains("\n three\n"), "got: {stdout}"); + } + + #[test] + fn unified_zero_drops_context_lines() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap(); + fs::write(root.join("b.txt"), "one\nTWO\nthree\n").unwrap(); + + let (code, stdout, _) = run_in(root, vec!["-U", "0", "a.txt", "b.txt"]); + assert_eq!(code, 1); + assert!(!stdout.contains("\n one\n"), "got: {stdout}"); + assert!(!stdout.contains("\n three\n"), "got: {stdout}"); + assert!(stdout.contains("\n-two\n"), "got: {stdout}"); + assert!(stdout.contains("\n+TWO\n"), "got: {stdout}"); + } + + #[test] + fn brief_reports_one_line_per_differing_pair() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "x\n").unwrap(); + fs::write(root.join("b.txt"), "y\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-q", "a.txt", "b.txt"]); + assert_eq!(code, 1); + assert_eq!(stdout, "Files a.txt and b.txt differ\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn compat_flags_are_accepted_and_ignored() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "x\n").unwrap(); + fs::write(root.join("b.txt"), "y\n").unwrap(); + + let (code, stdout, stderr) = + run_in(root, vec!["-u", "-r", "--color=always", "a.txt", "b.txt"]); + assert_eq!(code, 1); + assert_eq!(stderr, ""); + // Plain unified output, no ANSI escapes. + assert!(stdout.starts_with("--- a.txt\n+++ b.txt\n"), "got: {stdout}"); + assert!(!stdout.contains('\u{1b}'), "got: {stdout}"); + } + + #[test] + fn binary_inputs_report_binary_difference() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.bin"), b"aa\x00bb").unwrap(); + fs::write(root.join("b.bin"), b"aa\x00cc").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["a.bin", "b.bin"]); + assert_eq!(code, 1); + assert_eq!(stdout, "Binary files a.bin and b.bin differ\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn missing_operand_file_is_trouble() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "x\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["a.txt", "nope.txt"]); + assert_eq!(code, 2); + assert_eq!(stdout, ""); + assert_eq!(stderr, "diff: nope.txt: No such file or directory\n"); + } + + #[test] + fn missing_second_operand_is_usage_error() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["only-one"]); + assert_eq!(code, 2); + assert_eq!(stdout, ""); + assert!(stderr.contains("required"), "got: {stderr}"); + } + + #[test] + fn new_file_treats_missing_operand_as_empty() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "one\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-N", "nope.txt", "a.txt"]); + assert_eq!(code, 1); + assert_eq!(stderr, ""); + assert!(stdout.starts_with("--- nope.txt\n+++ a.txt\n"), "got: {stdout}"); + assert!(stdout.contains("\n+one\n"), "got: {stdout}"); + } + + #[test] + fn dash_reads_context_stdin() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a.txt"), "one\ntwo\n").unwrap(); + + let (code, stdout, stderr) = run_with(root.clone(), b"one\ntwo\n", vec!["a.txt", "-"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + + let (code, stdout, _) = run_with(root, b"one\nTWO\n", vec!["a.txt", "-"]); + assert_eq!(code, 1); + assert!(stdout.starts_with("--- a.txt\n+++ -\n"), "got: {stdout}"); + } + + #[test] + fn directories_diff_recursively_with_only_in_lines() { + let (_dir, root) = canonical_tempdir(); + let (a, b) = (root.join("a"), root.join("b")); + fs::create_dir_all(a.join("sub")).unwrap(); + fs::create_dir_all(b.join("sub")).unwrap(); + fs::write(a.join("common.txt"), "same\n").unwrap(); + fs::write(b.join("common.txt"), "same\n").unwrap(); + fs::write(a.join("only.txt"), "left\n").unwrap(); + fs::write(b.join("other.txt"), "right\n").unwrap(); + fs::write(a.join("sub/inner.txt"), "old\n").unwrap(); + fs::write(b.join("sub/inner.txt"), "new\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["a", "b"]); + assert_eq!(code, 1); + assert_eq!(stderr, ""); + assert!(stdout.contains("Only in a: only.txt\n"), "got: {stdout}"); + assert!(stdout.contains("Only in b: other.txt\n"), "got: {stdout}"); + assert!( + stdout.contains( + "diff -r a/sub/inner.txt b/sub/inner.txt\n--- a/sub/inner.txt\n+++ b/sub/inner.txt\n" + ), + "got: {stdout}" + ); + assert!(stdout.contains("\n-old\n"), "got: {stdout}"); + assert!(stdout.contains("\n+new\n"), "got: {stdout}"); + // Identical common.txt must not appear at all. + assert!(!stdout.contains("common.txt"), "got: {stdout}"); + } + + #[test] + fn identical_directories_exit_zero() { + let (_dir, root) = canonical_tempdir(); + let (a, b) = (root.join("a"), root.join("b")); + fs::create_dir_all(&a).unwrap(); + fs::create_dir_all(&b).unwrap(); + fs::write(a.join("f.txt"), "same\n").unwrap(); + fs::write(b.join("f.txt"), "same\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-r", "a", "b"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("Compare files line by line")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-base32/src/base_common.rs b/crates/vendor/uu-base32/src/base_common.rs index 827edec5c..bafbb3e9b 100644 --- a/crates/vendor/uu-base32/src/base_common.rs +++ b/crates/vendor/uu-base32/src/base_common.rs @@ -104,9 +104,10 @@ pub fn base_app(about: String, usage: String) -> Command { uucore::clap_localization::configure_localized_command(cmd) // Format arguments. .arg( + // pi-uutils: macOS base64 accepts `-D` as a non-visible decode alias. Arg::new(options::DECODE) .short('d') - .visible_short_alias('D') + .short_alias('D') .long(options::DECODE) .help("decode data") .action(ArgAction::SetTrue) diff --git a/crates/vendor/uu-base64/Cargo.toml b/crates/vendor/uu-base64/Cargo.toml index c366aba53..e50146565 100644 --- a/crates/vendor/uu-base64/Cargo.toml +++ b/crates/vendor/uu-base64/Cargo.toml @@ -16,3 +16,7 @@ clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } uucore = { version = "0.8.0", features = ["encoding"] } uu_base32 = { path = "../uu-base32" } pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-base64/src/base64.rs b/crates/vendor/uu-base64/src/base64.rs index b3753e9f6..eddfb8681 100644 --- a/crates/vendor/uu-base64/src/base64.rs +++ b/crates/vendor/uu-base64/src/base64.rs @@ -49,3 +49,85 @@ pub fn uu_app() -> Command { ) .name("base64") } + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + ffi::OsString, + io::{Cursor, Write}, + path::PathBuf, + sync::Arc, + }; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::run; + + fn run_in(cwd: PathBuf, input: &[u8], args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(Cursor::new(input.to_vec())), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + let argv: Vec = std::iter::once("base64") + .chain(args) + .map(OsString::from) + .collect(); + let code = pi_uutils_ctx::scope(io, || run(argv)); + + ( + code, + String::from_utf8(stdout_buf.lock().clone()).unwrap(), + String::from_utf8(stderr_buf.lock().clone()).unwrap(), + ) + } + + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = std::fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[test] + fn macos_decode_alias_round_trips_and_gnu_alias_still_works() { + let (_dir, cwd) = canonical_tempdir(); + let (code, encoded, stderr) = run_in(cwd.clone(), b"hello", vec![]); + assert_eq!(code, 0); + assert_eq!(encoded, "aGVsbG8=\n"); + assert_eq!(stderr, ""); + + let (code, decoded, stderr) = run_in(cwd.clone(), encoded.as_bytes(), vec!["-D"]); + assert_eq!(code, 0); + assert_eq!(decoded, "hello"); + assert_eq!(stderr, ""); + + let (code, decoded, stderr) = run_in(cwd, encoded.as_bytes(), vec!["-d"]); + assert_eq!(code, 0); + assert_eq!(decoded, "hello"); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-date/Cargo.toml b/crates/vendor/uu-date/Cargo.toml new file mode 100644 index 000000000..0d23dea93 --- /dev/null +++ b/crates/vendor/uu-date/Cargo.toml @@ -0,0 +1,36 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/date), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. The set-date +# capability (and its libc/windows-sys settime dependencies) is removed +# entirely, along with the fluent/icu localization stack. See src/date.rs for +# the patch markers (`pi-uutils:` comments). +[package] +name = "uu_date" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "date ~ (uutils) display the current time (vendored + patched for in-process embedding)" + +[lib] +path = "src/date.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +jiff = { version = "0.2", features = [ + "tzdb-bundle-platform", + "tzdb-zoneinfo", + "tzdb-concatenated", +] } +parse_datetime = "0.14" +regex = "1.11" +uucore = { version = "0.8.0", features = ["parser"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +# pi-uutils: rustix is kept only for `clock_getres` (--resolution); the +# `clock_settime` user is gone with the set-date capability. +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["time"] } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-date/LICENSE b/crates/vendor/uu-date/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-date/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-date/src/date.rs b/crates/vendor/uu-date/src/date.rs new file mode 100644 index 000000000..bd19ce1b2 --- /dev/null +++ b/crates/vendor/uu-date/src/date.rs @@ -0,0 +1,1658 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime +// datetimes getres AWST ACST AEST foobarbaz + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. The set-date capability (`--set`, clock_settime / +// SetSystemTime) is removed entirely — a builtin must never mutate the host +// system clock — and `--set` now reports "setting the date is not supported by +// this builtin". The fluent/icu localization stack is dropped (`translate!` +// strings are literalized with the en-US locale text, the i18n-datetime +// feature is not vendored, and the locale.rs default-format probe — which +// calls the process-global setlocale(3) — is replaced by upstream's 24-hour +// fallback format). File operands (`--file`, `--reference`) resolve against +// the shell working directory via `pi_uutils_ctx::resolve` AT THE CALL SITE +// while the original operands are kept for display/error messages, stdio is +// routed through `pi_uutils_ctx`, and the entry point no longer calls +// `std::process::exit`. Time-zone handling stays process-global: jiff reads +// the host TZ environment variable and tzdb (same behavior as upstream). + +mod format_modifiers; + +use std::{ + borrow::Cow, + collections::HashMap, + ffi::OsString, + fs::File, + io::{BufRead, BufReader, BufWriter, Read, Write}, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use jiff::{ + Timestamp, Zoned, + fmt::strtime::{self, BrokenDownTime, Config, PosixCustom}, + tz::{Offset, TimeZone, TimeZoneDatabase}, +}; +use pi_uutils_ctx::format_usage; +use uucore::{ + display::Quotable, + error::{FromIo, UResult, USimpleError}, + parser::shortcut_value_parser::ShortcutValueParser, +}; + +// Options +const DATE: &str = "date"; +const HOURS: &str = "hours"; +const MINUTES: &str = "minutes"; +const SECONDS: &str = "seconds"; +const NS: &str = "ns"; + +const OPT_DATE: &str = "date"; +const OPT_FORMAT: &str = "format"; +const OPT_FILE: &str = "file"; +const OPT_DEBUG: &str = "debug"; +const OPT_ISO_8601: &str = "iso-8601"; +const OPT_RESOLUTION: &str = "resolution"; +const OPT_RFC_EMAIL: &str = "rfc-email"; +const OPT_RFC_822: &str = "rfc-822"; +const OPT_RFC_2822: &str = "rfc-2822"; +const OPT_RFC_3339: &str = "rfc-3339"; +const OPT_SET: &str = "set"; +const OPT_REFERENCE: &str = "reference"; +const OPT_UNIVERSAL: &str = "universal"; +const OPT_UNIVERSAL_2: &str = "utc"; + +/// Settings for this program, parsed from the command line +// pi-uutils: the upstream `set_to` field is gone with the set-date capability. +struct Settings { + utc: bool, + format: Format, + date_source: DateSource, + debug: bool, +} + +/// Options for parsing dates +#[derive(Clone, Copy)] +struct DebugOptions { + /// Enable debug output + debug: bool, + /// Warn when midnight is used without explicit time specification + warn_midnight: bool, +} + +impl DebugOptions { + fn new(debug: bool, warn_midnight: bool) -> Self { + Self { debug, warn_midnight } + } +} + +/// Various ways of displaying the date +enum Format { + Iso8601(Iso8601Format), + Rfc5322, + Rfc3339(Rfc3339Format), + Resolution, + Custom(String), + Default, +} + +/// Various places that dates can come from +enum DateSource { + Now, + File(PathBuf), + FileMtime(PathBuf), + Stdin, + Human(String), + Resolution, +} + +enum Iso8601Format { + Date, + Hours, + Minutes, + Seconds, + Ns, +} + +impl From<&str> for Iso8601Format { + fn from(s: &str) -> Self { + match s { + HOURS => Self::Hours, + MINUTES => Self::Minutes, + SECONDS => Self::Seconds, + NS => Self::Ns, + DATE => Self::Date, + // Note: This is caught by clap via `possible_values` + _ => unreachable!(), + } + } +} + +enum Rfc3339Format { + Date, + Seconds, + Ns, +} + +impl From<&str> for Rfc3339Format { + fn from(s: &str) -> Self { + match s { + DATE => Self::Date, + SECONDS => Self::Seconds, + NS => Self::Ns, + // Should be caught by clap + _ => panic!("Invalid format: {s}"), + } + } +} + +/// Indicates whether parsing a military timezone causes the date to remain the +/// same, roll back to the previous day, or advance to the next day. +/// This can occur when applying a military timezone with an optional hour +/// offset crosses midnight in either direction. +#[derive(PartialEq, Debug)] +enum DayDelta { + /// The date does not change + Same, + /// The date rolls back to the previous day. + Previous, + /// The date advances to the next day. + Next, +} + +/// Escape invalid UTF-8 bytes in GNU-compatible octal notation. +/// +/// Converts bytes to a string with printable ASCII characters preserved +/// and non-printable/invalid UTF-8 bytes escaped as `\NNN` octal sequences. +/// +/// This matches GNU date's behavior for invalid input. +/// +/// # Arguments +/// * `bytes` - The byte sequence to escape +/// +/// # Returns +/// A string with invalid bytes escaped in octal notation +/// +/// # Example +/// ```ignore +/// let invalid = b"\xb0"; +/// assert_eq!(escape_invalid_bytes(invalid), "\\260"); +/// ``` +fn escape_invalid_bytes(bytes: &[u8]) -> String { + let escaped = bytes + .iter() + .flat_map(|&b| { + // Preserve printable ASCII except backslash + if (0x20..0x7f).contains(&b) && b != b'\\' { + vec![b] + } else { + // Escape as octal: \NNN + format!("\\{b:03o}").into_bytes() + } + }) + .collect::>(); + String::from_utf8_lossy(&escaped).into_owned() +} + +/// Strip parenthesized comments from a date string. +/// +/// GNU date removes balanced parentheses and their content, treating them as +/// comments. If parentheses are unbalanced, everything from the unmatched '(' +/// onwards is ignored. +/// +/// Examples: +/// - "2026(comment)-01-05" -> "2026-01-05" +/// - "1(ignore comment to eol" -> "1" +/// - "(" -> "" +/// - "((foo)2026-01-05)" -> "" +fn strip_parenthesized_comments(input: &str) -> Cow<'_, str> { + if !input.contains('(') { + return Cow::Borrowed(input); + } + + let mut result = String::with_capacity(input.len()); + let mut depth = 0; + + for c in input.chars() { + match c { + '(' => { + depth += 1; + }, + ')' if depth > 0 => { + depth -= 1; + }, + _ if depth == 0 => { + result.push(c); + }, + _ => {}, + } + } + + Cow::Owned(result) +} + +/// Parse military timezone with optional hour offset. +/// Pattern: single letter (a-z except j) optionally followed by 1-2 digits. +/// Returns Some(total_hours_in_utc) or None if pattern doesn't match. +/// +/// Military timezone mappings: +/// - A-I: UTC+1 to UTC+9 (J is skipped for local time) +/// - K-M: UTC+10 to UTC+12 +/// - N-Y: UTC-1 to UTC-12 +/// - Z: UTC+0 +/// +/// The hour offset from digits is added to the base military timezone offset. +/// Examples: "m" -> 12 (noon UTC), "m9" -> 21 (9pm UTC), "a5" -> 4 (4am UTC +/// next day) +fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { + if s.is_empty() || s.len() > 3 { + return None; + } + + let mut chars = s.chars(); + let letter = chars.next()?.to_ascii_lowercase(); + + // Check if first character is a letter (a-z, except j which is handled + // separately) + if !letter.is_ascii_lowercase() || letter == 'j' { + return None; + } + + // Parse optional digits (1-2 digits for hour offset) + let additional_hours: i32 = if let Some(rest) = chars.as_str().chars().next() { + if !rest.is_ascii_digit() { + return None; + } + chars.as_str().parse().ok()? + } else { + 0 + }; + + // Map military timezone letter to UTC offset + let tz_offset = match letter { + 'a'..='i' => (letter as i32 - 'a' as i32) + 1, // A=+1, B=+2, ..., I=+9 + 'k'..='m' => (letter as i32 - 'k' as i32) + 10, // K=+10, L=+11, M=+12 + 'n'..='y' => -((letter as i32 - 'n' as i32) + 1), // N=-1, O=-2, ..., Y=-12 + 'z' => 0, // Z=+0 + _ => return None, + }; + + let day_delta = match additional_hours - tz_offset { + h if h < 0 => DayDelta::Previous, + h if h >= 24 => DayDelta::Next, + _ => DayDelta::Same, + }; + + // Calculate total hours: midnight (0) + tz_offset + additional_hours + // Midnight in timezone X converted to UTC + let hours_from_midnight = (0 - tz_offset + additional_hours).rem_euclid(24); + + Some((hours_from_midnight, day_delta)) +} + +/// pi-uutils: BSD `date` compatibility (macOS muscle memory). +/// +/// BSD `date -r SECONDS` formats an epoch, whereas GNU `-r FILE` formats a +/// file's mtime. We rewrite only an all-digit `-r` value for which no file +/// exists in the shell working directory, preserving GNU's meaningful file +/// invocation. BSD-only `-v` and `-j` are unambiguous, so they always select +/// this compatibility path. The rewrite deliberately rejects BSD forms with +/// no equivalent in the vendored GNU parser instead of producing wrong output. +/// +/// Returns `None` when the invocation is not BSD-shaped, `Some(Err(_))` when +/// it is BSD-shaped but cannot be represented by GNU date. +fn rewrite_bsd_invocation(argv: &[OsString]) -> Option, String>> { + let toks: Vec> = argv.iter().map(|arg| arg.to_string_lossy()).collect(); + let mut detected = false; + let mut epoch_reference = false; + let mut i = 1; + + while i < toks.len() { + let token = toks[i].as_ref(); + if token == "--" { + break; + } + match token { + // These GNU options take their next token as a value, including a + // value that begins with `-`; it must not be mistaken for BSD -j/-v. + "-d" | "--date" | "-f" | "--file" | "-s" | "--set" => { + i += 2; + continue; + }, + "-r" => { + if let Some(value) = toks.get(i + 1) + && is_bsd_epoch_reference(value) + && std::fs::symlink_metadata(pi_uutils_ctx::resolve(Path::new(&argv[i + 1]))) + .is_err_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + detected = true; + epoch_reference = true; + } + i += 2; + continue; + }, + _ => {}, + } + + if short_option_contains_bsd_flag(token) { + detected = true; + } + i += 1; + } + + detected.then(|| bsd_to_gnu_argv(argv, &toks, epoch_reference)) +} + +/// Recognizes BSD-only short flags without treating an attached GNU option +/// value (for example, the `-j` in `date -d-j`) as an option. +fn short_option_contains_bsd_flag(token: &str) -> bool { + let Some(cluster) = token.strip_prefix('-') else { + return false; + }; + if cluster.is_empty() || cluster.starts_with('-') { + return false; + } + + for flag in cluster.chars() { + match flag { + // The rest of this cluster is a GNU option value. + 'd' | 'f' | 'r' | 's' | 'I' => return false, + 'j' | 'v' => return true, + _ => {}, + } + } + false +} + +fn is_bsd_epoch_reference(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Parses a detected BSD invocation and produces the equivalent GNU argv. +fn bsd_to_gnu_argv( + argv: &[OsString], + toks: &[Cow<'_, str>], + epoch_reference: bool, +) -> Result, String> { + let mut adjustments = Vec::new(); + let mut has_no_set = false; + let mut i = 1; + while i < toks.len() { + let token = toks[i].as_ref(); + if token == "--" { + break; + } + match token { + "-d" | "--date" | "-s" | "--set" => { + i += 2; + continue; + }, + "-f" => { + if has_no_set { + return Err("BSD 'date -j -f' parse mode is not supported; use -d STRING".into()); + } + i += 2; + continue; + }, + "--file" => { + i += 2; + continue; + }, + "-r" => { + i += 2; + continue; + }, + "-j" => has_no_set = true, + _ if token.starts_with("-v") => { + let value = token.strip_prefix("-v").unwrap(); + if value.is_empty() { + return Err("BSD -v requires a signed adjustment such as -v-1d".into()); + } + adjustments.push(parse_bsd_adjustment(value)?); + }, + _ => {}, + } + i += 1; + } + + if !has_no_set { + // `-j` might have appeared later than `-f`; detect it without changing + // the parsing rules above. + has_no_set = toks.iter().skip(1).any(|token| token.as_ref() == "-j"); + } + if has_no_set && has_bsd_file_parse_mode(toks) { + return Err("BSD 'date -j -f' parse mode is not supported; use -d STRING".into()); + } + if adjustments.len() > 1 { + return Err("multiple BSD -v adjustments are not supported".into()); + } + if !adjustments.is_empty() && epoch_reference { + return Err("BSD -v with -r EPOCH is not supported".into()); + } + + let mut rewritten = Vec::with_capacity(argv.len() + adjustments.len()); + rewritten.push(argv[0].clone()); + i = 1; + while i < argv.len() { + let token = toks[i].as_ref(); + if token == "--" { + rewritten.extend_from_slice(&argv[i..]); + break; + } + if token == "-j" { + i += 1; + continue; + } + if token.starts_with("-v") { + i += 1; + continue; + } + if token == "-r" + && toks + .get(i + 1) + .is_some_and(|value| is_bsd_epoch_reference(value)) + && std::fs::symlink_metadata(pi_uutils_ctx::resolve(Path::new(&argv[i + 1]))) + .is_err_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + rewritten.push(OsString::from("-d")); + rewritten.push(OsString::from(format!("@{}", toks[i + 1]))); + i += 2; + continue; + } + rewritten.push(argv[i].clone()); + i += 1; + } + + if let Some(adjustment) = adjustments.pop() { + rewritten.push(OsString::from("-d")); + rewritten.push(OsString::from(adjustment)); + } + Ok(rewritten) +} + +/// Finds an actual short GNU `-f` option rather than a value passed to `-d`. +fn has_bsd_file_parse_mode(toks: &[Cow<'_, str>]) -> bool { + let mut i = 1; + while i < toks.len() { + match toks[i].as_ref() { + "--" => return false, + "-d" | "--date" | "-s" | "--set" | "-r" | "--reference" => i += 2, + "-f" => return true, + _ => i += 1, + } + } + false +} + +fn parse_bsd_adjustment(value: &str) -> Result { + let (sign, value) = value + .chars() + .next() + .map(|sign| (sign, &value[sign.len_utf8()..])) + .ok_or_else(|| "BSD -v requires a signed adjustment such as -v-1d".to_string())?; + if !matches!(sign, '+' | '-') { + return Err("BSD -v field-set adjustments are not supported (use -v+N/-v-N)".into()); + } + let Some(unit) = value.chars().last() else { + return Err("BSD -v requires a signed adjustment such as -v-1d".into()); + }; + let number = &value[..value.len() - unit.len_utf8()]; + if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("BSD -v requires a signed adjustment such as -v-1d".into()); + } + let unit = match unit { + 'y' => "year", + 'm' => "month", + 'w' => "week", + 'd' => "day", + 'H' => "hour", + 'M' => "minute", + 'S' => "second", + _ => return Err(format!("BSD -v unit '{unit}' is not supported")), + }; + let plural = if number == "1" { "" } else { "s" }; + if sign == '-' { + Ok(format!("{number} {unit}{plural} ago")) + } else { + Ok(format!("{number} {unit}{plural}")) + } +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + // pi-uutils: translate unambiguous BSD date forms before GNU clap parsing; + // see `rewrite_bsd_invocation`. + let argv = match rewrite_bsd_invocation(&argv) { + None => argv, + Some(Ok(rewritten)) => rewritten, + Some(Err(msg)) => { + let _ = writeln!(pi_uutils_ctx::stderr(), "date: {msg}"); + return 1; + }, + }; + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match date_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "date: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +#[allow(clippy::cognitive_complexity)] +fn date_main(matches: &ArgMatches) -> UResult<()> { + // pi-uutils: the set-date capability is removed — a shell builtin must + // never mutate the host system clock, so `--set` fails up front instead of + // parsing the operand and calling clock_settime(2)/SetSystemTime. + if matches.get_one::(OPT_SET).is_some() { + return Err(USimpleError::new(1, "setting the date is not supported by this builtin")); + } + + let date_source = if let Some(date_os) = matches.get_one::(OPT_DATE) { + // Convert OsString to String, handling invalid UTF-8 with GNU-compatible error + let date = date_os.to_str().ok_or_else(|| { + let bytes = date_os.as_encoded_bytes(); + let escaped_str = escape_invalid_bytes(bytes); + USimpleError::new(1, format!("invalid date '{escaped_str}'")) + })?; + DateSource::Human(date.into()) + } else if let Some(file) = matches.get_one::(OPT_FILE) { + match file.as_ref() { + "-" => DateSource::Stdin, + _ => DateSource::File(file.into()), + } + } else if let Some(file) = matches.get_one::(OPT_REFERENCE) { + DateSource::FileMtime(file.into()) + } else if matches.get_flag(OPT_RESOLUTION) { + DateSource::Resolution + } else { + DateSource::Now + }; + + // Check for extra operands (multiple positional arguments) + if let Some(formats) = matches.get_many::(OPT_FORMAT) { + let format_args: Vec<&String> = formats.collect(); + if format_args.len() > 1 { + return Err(USimpleError::new(1, format!("extra operand '{}'", format_args[1]))); + } + } + + let format = if let Some(form) = matches.get_one::(OPT_FORMAT) { + if !form.starts_with('+') { + // if an optional Format String was found but the user has not provided an input + // date GNU prints an invalid date Error + if !matches!(date_source, DateSource::Human(_)) { + return Err(USimpleError::new(1, format!("invalid date '{form}'"))); + } + // If the user did provide an input date with the --date flag and the Format + // String is not starting with '+' GNU prints the missing '+' error message + return Err(USimpleError::new( + 1, + format!( + "the argument {form} lacks a leading '+';\nwhen using an option to specify \ + date(s), any non-option\nargument must be a format string beginning with '+'" + ), + )); + } + let form = form[1..].to_string(); + Format::Custom(form) + } else if let Some(fmt) = matches + .get_many::(OPT_ISO_8601) + .map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into()) + { + Format::Iso8601(fmt) + } else if matches.get_flag(OPT_RFC_EMAIL) { + Format::Rfc5322 + } else if let Some(fmt) = matches + .get_one::(OPT_RFC_3339) + .map(|s| s.as_str().into()) + { + Format::Rfc3339(fmt) + } else if matches.get_flag(OPT_RESOLUTION) { + Format::Resolution + } else { + Format::Default + }; + + let utc = matches.get_flag(OPT_UNIVERSAL); + let debug_mode = matches.get_flag(OPT_DEBUG); + + // Get the current time, either in the local time zone or UTC. + // pi-uutils: time-zone handling stays process-global — jiff reads the host + // TZ environment variable and system tzdb here, as upstream does. + let now = if utc { + Timestamp::now().to_zoned(TimeZone::UTC) + } else { + Zoned::now() + }; + + let settings = Settings { utc, format, date_source, debug: debug_mode }; + + // Iterate over all dates - whether it's a single date or a file. + let dates: Box> = match &settings.date_source { + DateSource::Human(input) => { + // GNU compatibility (Comments in parentheses) + let input = strip_parenthesized_comments(input); + let input = input.trim(); + + // GNU compatibility (Empty string): + // An empty string (or whitespace-only) should be treated as midnight today. + let is_empty_or_whitespace = input.is_empty(); + + // GNU compatibility (Military timezone 'J'): + // 'J' is reserved for local time in military timezones. + // GNU date accepts it and treats it as midnight today (00:00:00). + let is_military_j = input.eq_ignore_ascii_case("j"); + + // GNU compatibility (Military timezone with optional hour offset): + // Single letter (a-z except j) optionally followed by 1-2 digits. + // Letter represents midnight in that military timezone (UTC offset). + // Digits represent additional hours to add. + // Examples: "m" -> noon UTC (12:00); "m9" -> 21:00 UTC; "a5" -> 04:00 UTC + let military_tz_with_offset = parse_military_timezone_with_offset(input); + + // GNU compatibility (Pure numbers in date strings): + // - Manual: https://www.gnu.org/software/coreutils/manual/html_node/Pure-numbers-in-date-strings.html + // - Semantics: a pure decimal number denotes today's time-of-day (HH or HHMM). + // Examples: "0"/"00" => 00:00 today; "7"/"07" => 07:00 today; "0700" => 07:00 + // today. + // For all other forms, fall back to the general parser. + let is_pure_digits = + !input.is_empty() && input.len() <= 4 && input.chars().all(|c| c.is_ascii_digit()); + + let date = if is_empty_or_whitespace || is_military_j { + // Treat empty string or 'J' as midnight today (00:00:00) in local time + let date_part = + strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); + let offset = if settings.utc { + String::from("+00:00") + } else { + strtime::format("%:z", &now).unwrap_or_default() + }; + let composed = if offset.is_empty() { + format!("{date_part} 00:00") + } else { + format!("{date_part} 00:00 {offset}") + }; + if settings.debug { + let _ = writeln!( + pi_uutils_ctx::stderr(), + "date: warning: using midnight as starting time: 00:00:00" + ); + } + parse_date(composed, &now, DebugOptions::new(settings.debug, false)) + } else if let Some((total_hours, day_delta)) = military_tz_with_offset { + // Military timezone with optional hour offset + // Convert to UTC time: midnight + military_tz_offset + additional_hours + + // When calculating a military timezone with an optional hour offset, midnight + // may be crossed in either direction. `day_delta` indicates whether the + // date remains the same, moves to the previous day, or advances to the next + // day. Changing day can result in error, this closure will help handle + // these errors gracefully. + let format_date_with_epoch_fallback = |date: Result| -> String { + date + .and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")) + }; + let date_part = match day_delta { + DayDelta::Same => format_date_with_epoch_fallback(Ok(now.clone())), + DayDelta::Next => format_date_with_epoch_fallback(now.tomorrow()), + DayDelta::Previous => format_date_with_epoch_fallback(now.yesterday()), + }; + let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); + parse_date(composed, &now, DebugOptions::new(settings.debug, false)) + } else if is_pure_digits { + // Derive HH and MM from the input + let (hh_opt, mm_opt) = if input.len() <= 2 { + (input.parse::().ok(), Some(0u32)) + } else { + let (h, m) = input.split_at(input.len() - 2); + (h.parse::().ok(), m.parse::().ok()) + }; + + if let (Some(hh), Some(mm)) = (hh_opt, mm_opt) { + // Compose a concrete datetime string for today with zone offset. + // Use the already-determined 'now' and settings.utc to select offset. + let date_part = + strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); + // If -u, force +00:00; otherwise use the local offset of 'now'. + let offset = if settings.utc { + String::from("+00:00") + } else { + strtime::format("%:z", &now).unwrap_or_default() + }; + let composed = if offset.is_empty() { + format!("{date_part} {hh:02}:{mm:02}") + } else { + format!("{date_part} {hh:02}:{mm:02} {offset}") + }; + parse_date(composed, &now, DebugOptions::new(settings.debug, false)) + } else { + // Fallback on parse failure of digits + parse_date(input, &now, DebugOptions::new(settings.debug, true)) + } + } else { + parse_date(input, &now, DebugOptions::new(settings.debug, true)) + }; + + let iter = std::iter::once(date); + Box::new(iter) + }, + // pi-uutils: `-f -` reads the context stdin, not the process stdin. + DateSource::Stdin => parse_dates_from_reader( + pi_uutils_ctx::stdin(), + &now, + DebugOptions::new(settings.debug, true), + ), + DateSource::File(path) => { + // pi-uutils: resolve the DATEFILE operand against the shell working + // directory; `path` is kept for display. + let resolved = pi_uutils_ctx::resolve(path); + if resolved.is_dir() { + return Err(USimpleError::new( + 2, + format!("expected file, got directory {}", path.quote()), + )); + } + let file = + File::open(&resolved).map_err_context(|| path.as_os_str().maybe_quote().to_string())?; + parse_dates_from_reader(file, &now, DebugOptions::new(settings.debug, true)) + }, + DateSource::FileMtime(path) => { + // pi-uutils: resolve the --reference FILE against the shell working + // directory; `path` is kept for display. + let metadata = std::fs::metadata(pi_uutils_ctx::resolve(path)) + .map_err_context(|| path.as_os_str().maybe_quote().to_string())?; + let mtime = metadata.modified()?; + let ts = Timestamp::try_from(mtime) + .map_err(|_| USimpleError::new(1, "cannot set date".to_string()))?; + // pi-uutils: process-global TZ lookup, as upstream. + let date = ts.to_zoned(TimeZone::try_system().unwrap_or(TimeZone::UTC)); + let iter = std::iter::once(Ok(date)); + Box::new(iter) + }, + DateSource::Resolution => { + let resolution = get_clock_resolution(); + // pi-uutils: process-global TZ lookup, as upstream. + let date = resolution.to_zoned(TimeZone::system()); + let iter = std::iter::once(Ok(date)); + Box::new(iter) + }, + DateSource::Now => { + let iter = std::iter::once(Ok(now.clone())); + Box::new(iter) + }, + }; + + let format_string = make_format_string(&settings); + // pi-uutils: buffered context stdout instead of the process stdout. + let mut stdout = BufWriter::new(pi_uutils_ctx::stdout()); + + // Format all the dates + let config = Config::new().custom(PosixCustom::new()).lenient(true); + for date in dates { + // pi-uutils: a DATEFILE/stdin stream can be arbitrarily long; observe + // host cancellation between lines. + if pi_uutils_ctx::is_cancelled() { + break; + } + match date { + Ok(date) => { + let date = if settings.utc { + date.with_time_zone(TimeZone::UTC) + } else { + date + }; + match format_date(&date, format_string, &config) { + Ok(s) => writeln!(stdout, "{s}") + .map_err(|e| USimpleError::new(1, format!("write error: {e}")))?, + Err(e) => { + let _ = stdout.flush(); + return Err(USimpleError::new( + 1, + format!("invalid format '{format_string}' ({e})"), + )); + }, + } + }, + Err((input, _err)) => { + let _ = stdout.flush(); + // pi-uutils: upstream `show!` — report the bad line to the + // context stderr, record the failure exit code, and keep + // processing the remaining lines. + let _ = writeln!(pi_uutils_ctx::stderr(), "date: invalid date '{input}'"); + pi_uutils_ctx::set_exit_code(1); + }, + } + } + + stdout + .flush() + .map_err(|e| USimpleError::new(1, format!("write error: {e}")))?; + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("date") + .version(uucore::crate_version!()) + .about("Print or set the system date and time") + // pi-uutils: the localized usage blob's FORMAT reference table moved to + // `after_help` below; the usage proper is just the two command lines. + .override_usage(format_usage( + "date [OPTION]... [+FORMAT]...\ndate [OPTION]... [MMDDhhmm[[CC]YY][.ss]]", + )) + .after_help(FORMAT_HELP) + .infer_long_args(true) + .arg( + Arg::new(OPT_DATE) + .short('d') + .long(OPT_DATE) + .value_name("STRING") + .allow_hyphen_values(true) + .overrides_with(OPT_DATE) + .value_parser(clap::value_parser!(OsString)) + .help("display time described by STRING, not 'now'"), + ) + .arg( + Arg::new(OPT_FILE) + .short('f') + .long(OPT_FILE) + .value_name("DATEFILE") + .value_hint(clap::ValueHint::FilePath) + .conflicts_with(OPT_DATE) + .help("like --date; once for each line of DATEFILE"), + ) + .arg( + Arg::new(OPT_ISO_8601) + .short('I') + .long(OPT_ISO_8601) + .value_name("FMT") + .value_parser(ShortcutValueParser::new([DATE, HOURS, MINUTES, SECONDS, NS])) + .num_args(0..=1) + .default_missing_value(OPT_DATE) + .help( + "output date/time in ISO 8601 format.\nFMT='date' for date only (the \ + default),\n'hours', 'minutes', 'seconds', or 'ns'\nfor date and time to the \ + indicated precision.\nExample: 2006-08-14T02:34:56-06:00", + ), + ) + .arg( + Arg::new(OPT_RESOLUTION) + .long(OPT_RESOLUTION) + .conflicts_with_all([OPT_DATE, OPT_FILE]) + .overrides_with(OPT_RESOLUTION) + .help("output the available resolution of timestamps\nExample: 0.000000001") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_RFC_EMAIL) + .short('R') + .long(OPT_RFC_EMAIL) + .alias(OPT_RFC_2822) + .alias(OPT_RFC_822) + .overrides_with(OPT_RFC_EMAIL) + .help( + "output date and time in RFC 5322 format.\nExample: Mon, 14 Aug 2006 02:34:56 -0600", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_RFC_3339) + .long(OPT_RFC_3339) + .value_name("FMT") + .value_parser(ShortcutValueParser::new([DATE, SECONDS, NS])) + .help( + "output date/time in RFC 3339 format.\nFMT='date', 'seconds', or 'ns'\nfor date \ + and time to the indicated precision.\nExample: 2006-08-14 02:34:56-06:00", + ), + ) + .arg( + Arg::new(OPT_DEBUG) + .long(OPT_DEBUG) + .help("annotate the parsed date, and warn about questionable usage to stderr") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_REFERENCE) + .short('r') + .long(OPT_REFERENCE) + .value_name("FILE") + .value_hint(clap::ValueHint::AnyPath) + .conflicts_with_all([OPT_DATE, OPT_FILE, OPT_RESOLUTION]) + .help("display the last modification time of FILE"), + ) + .arg( + Arg::new(OPT_SET) + .short('s') + .long(OPT_SET) + .value_name("STRING") + .allow_hyphen_values(true) + // pi-uutils: the set-date capability is removed; the option is + // still parsed so it fails with a clear message instead of a + // clap "unexpected argument" error. + .help("set time described by STRING (not supported by this builtin)"), + ) + .arg( + Arg::new(OPT_UNIVERSAL) + .short('u') + .long(OPT_UNIVERSAL) + .visible_alias(OPT_UNIVERSAL_2) + .alias("uct") + .overrides_with(OPT_UNIVERSAL) + .help("print or set Coordinated Universal Time (UTC)") + .action(ArgAction::SetTrue), + ) + .arg(Arg::new(OPT_FORMAT).num_args(0..)) +} + +// pi-uutils: literalized en-US FORMAT reference from the `date-usage` locale +// blob, rendered as plain text for clap's after_help. +const FORMAT_HELP: &str = "\ +FORMAT controls the output. Interpreted sequences are: + %% a literal % + %a locale's abbreviated weekday name (e.g., Sun) + %A locale's full weekday name (e.g., Sunday) + %b locale's abbreviated month name (e.g., Jan) + %B locale's full month name (e.g., January) + %c locale's date and time (e.g., Thu Mar 3 23:05:25 2005) + %C century; like %Y, except omit last two digits (e.g., 20) + %d day of month (e.g., 01) + %D date; same as %m/%d/%y + %e day of month, space padded; same as %_d + %F full date; same as %Y-%m-%d + %g last two digits of year of ISO week number (see %G) + %G year of ISO week number (see %V); normally useful only with %V + %h same as %b + %H hour (00..23) + %I hour (01..12) + %j day of year (001..366) + %k hour, space padded ( 0..23); same as %_H + %l hour, space padded ( 1..12); same as %_I + %m month (01..12) + %M minute (00..59) + %n a newline + %N nanoseconds (000000000..999999999) + %p locale's equivalent of either AM or PM; blank if not known + %P like %p, but lower case + %q quarter of year (1..4) + %r locale's 12-hour clock time (e.g., 11:11:04 PM) + %R 24-hour hour and minute; same as %H:%M + %s seconds since 1970-01-01 00:00:00 UTC + %S second (00..60) + %t a tab + %T time; same as %H:%M:%S + %u day of week (1..7); 1 is Monday + %U week number of year, with Sunday as first day of week (00..53) + %V ISO week number, with Monday as first day of week (01..53) + %w day of week (0..6); 0 is Sunday + %W week number of year, with Monday as first day of week (00..53) + %x locale's date representation (e.g., 03/03/2005) + %X locale's time representation (e.g., 23:30:30) + %y last two digits of year (00..99) + %Y year + %z +hhmm numeric time zone (e.g., -0400) + %:z +hh:mm numeric time zone (e.g., -04:00) + %::z +hh:mm:ss numeric time zone (e.g., -04:00:00) + %:::z numeric time zone with : to necessary precision (e.g., -04, +05:30) + %Z alphabetic time zone abbreviation (e.g., EDT) + +By default, date pads numeric fields with zeroes. +The following optional flags may follow '%': + - (hyphen) do not pad the field + _ (underscore) pad with spaces + 0 (zero) pad with zeros + ^ use upper case if possible + # use opposite case if possible +After any flags comes an optional field width, as a decimal number; +then an optional modifier, which is either + E to use the locale's alternate representations if available, or + O to use the locale's alternate numeric symbols if available. + +Examples: + Convert seconds since the epoch (1970-01-01 UTC) to a date + date --date='@2147483647' + Show the time on the west coast of the US (use tzselect(1) to find TZ) + TZ='America/Los_Angeles' date"; + +/// pi-uutils: upstream's `format_date_with_locale_aware_months` minus the +/// optional icu locale-aware month/day name substitution (the i18n-datetime +/// feature is not vendored, so no localization ever applies). +fn format_date( + date: &Zoned, + format_string: &str, + config: &Config, +) -> Result { + // Check if format string has GNU modifiers (width/flags) and format if present + if let Some(result) = + format_modifiers::format_with_modifiers_if_present(date, format_string, config) + { + return result.map_err(|e| e.to_string()); + } + + let broken_down = BrokenDownTime::from(date); + broken_down + .to_string_with_config(config, format_string) + .map_err(|e| e.to_string()) +} + +/// Return the appropriate format string for the given settings. +fn make_format_string(settings: &Settings) -> &str { + match &settings.format { + Format::Iso8601(fmt) => match fmt { + Iso8601Format::Date => "%F", + Iso8601Format::Hours => "%FT%H%:z", + Iso8601Format::Minutes => "%FT%H:%M%:z", + Iso8601Format::Seconds => "%FT%T%:z", + Iso8601Format::Ns => "%FT%T,%N%:z", + }, + Format::Rfc5322 => "%a, %d %h %Y %T %z", + Format::Rfc3339(fmt) => match fmt { + Rfc3339Format::Date => "%F", + Rfc3339Format::Seconds => "%F %T%:z", + Rfc3339Format::Ns => "%F %T.%N%:z", + }, + Format::Resolution => "%s.%N", + Format::Custom(fmt) => fmt, + // pi-uutils: upstream derives the default format from the process + // locale via setlocale(3)/nl_langinfo(3) (src/uu/date/src/locale.rs). + // setlocale mutates process-global state, which a builtin must not do, + // so upstream's 24-hour fallback format is used unconditionally. + Format::Default => "%a %b %e %X %Z %Y", + } +} + +/// Timezone abbreviations with known fixed UTC offsets. +/// Checked first because the abbreviation encodes the exact offset +/// (e.g., EDT always means UTC-4, even in winter when New York observes EST). +/// Offset is in seconds to support half-hour zones like IST (UTC+5:30). +/// All other timezones (JST, CET, etc.) are dynamically resolved from IANA +/// database. +/* spell-checker: disable */ +static FIXED_OFFSET_ABBREVIATIONS: &[(&str, i32)] = &[ + ("UTC", 0), + ("GMT", 0), + ("MEST", 7200), // UTC+2 Middle European Summer Time + // US timezones (GNU compatible) + ("PST", -28800), // UTC-8 + ("PDT", -25200), // UTC-7 + ("MST", -25200), // UTC-7 + ("MDT", -21600), // UTC-6 + ("CST", -21600), // UTC-6 (Ambiguous: US Central, not China/Cuba) + ("CDT", -18000), // UTC-5 + ("EST", -18000), // UTC-5 + ("EDT", -14400), // UTC-4 + // Indian Standard Time (Ambiguous: India vs Israel vs Ireland) + ("IST", 19800), // UTC+5:30 + // Australian timezones + ("AWST", 28800), // UTC+8 + ("ACST", 34200), // UTC+9:30 + ("ACDT", 37800), // UTC+10:30 + ("AEST", 36000), // UTC+10 + ("AEDT", 39600), // UTC+11 + // German timezones + ("MEZ", 3600), // UTC+1 + ("MESZ", 7200), // UTC+2 + // Asian timezones + ("KST", 32400), // UTC+9 Korean Standard Time +]; +/* spell-checker: enable */ + +/// Lazy-loaded timezone abbreviation lookup map built from IANA database. +// pi-uutils: `LazyLock` instead of upstream's `OnceLock` + `get_or_init`. +static TZ_ABBREV_CACHE: LazyLock> = LazyLock::new(build_tz_abbrev_map); + +/// Build timezone abbreviation lookup map from IANA database. +/// This is a fallback for abbreviations not covered by +/// FIXED_OFFSET_ABBREVIATIONS. +fn build_tz_abbrev_map() -> HashMap { + let mut map = HashMap::new(); + + let tzdb = TimeZoneDatabase::from_env(); // spell-checker:disable-line + // spell-checker:disable-next-line + for tz_name in tzdb.available() { + let tz_str = tz_name.as_str(); + // Use last component as potential abbreviation + // e.g., "Pacific/Fiji" could map to "FIJI" + if let Some(last_part) = tz_str.split('/').next_back() { + let potential_abbrev = last_part.to_uppercase(); + // Only add if it looks like an abbreviation (2-5 uppercase chars) + if potential_abbrev.len() >= 2 + && potential_abbrev.len() <= 5 + && potential_abbrev.chars().all(|c| c.is_ascii_uppercase()) + { + map.entry(potential_abbrev) + .or_insert_with(|| tz_str.to_string()); + } + } + } + + map +} + +/// Get IANA timezone name for a given abbreviation. +/// Uses lazy-loaded cache with preferred mappings for disambiguation. +fn tz_abbrev_to_iana(abbrev: &str) -> Option<&str> { + TZ_ABBREV_CACHE.get(abbrev).map(String::as_str) +} + +/// Attempts to parse a date string that contains a timezone abbreviation (e.g. +/// "EST"). +/// +/// If an abbreviation is found and the date is parsable, returns `Some(Zoned)`. +/// Returns `None` if no abbreviation is detected or if parsing fails, +/// indicating that standard parsing should be attempted. +fn try_parse_with_abbreviation>(date_str: S, now: &Zoned) -> Option { + let s = date_str.as_ref(); + + // Look for timezone abbreviation at the end of the string + // Pattern: ends with uppercase letters (2-5 chars) + if let Some(last_word) = s.split_whitespace().last() { + // Check if it's a potential timezone abbreviation (all uppercase, 2-5 chars) + if last_word.len() >= 2 + && last_word.len() <= 5 + && last_word.chars().all(|c| c.is_ascii_uppercase()) + { + let tz = if let Some(&(_, offset_secs)) = FIXED_OFFSET_ABBREVIATIONS + .iter() + .find(|(abbr, _)| *abbr == last_word) + { + Offset::from_seconds(offset_secs).ok().map(TimeZone::fixed) + } else { + tz_abbrev_to_iana(last_word).and_then(|name| TimeZone::get(name).ok()) + }; + + if let Some(tz) = tz { + let date_part = s.trim_end_matches(last_word).trim(); + // Parse in the target timezone so "10:30 EDT" means 10:30 in EDT + if let Ok(parsed) = parse_datetime::parse_datetime_at_date(now.clone(), date_part) { + let dt = parsed.datetime(); + if let Ok(zoned) = dt.to_zoned(tz) { + return Some(zoned); + } + } + } + } + } + + // No abbreviation found or couldn't resolve, return original + None +} + +/// Helper function to parse dates from a line-based reader (stdin or file) +/// +/// Takes any `Read` source, reads it line by line, and parses each line as a +/// date. Returns a boxed iterator over the parse results. +fn parse_dates_from_reader( + reader: R, + now: &Zoned, + dbg_opts: DebugOptions, +) -> Box> + '_> { + let lines = BufReader::new(reader).lines(); + Box::new( + lines + .map_while(Result::ok) + .map(move |s| parse_date(s, now, dbg_opts)), + ) +} + +/// Parse a `String` into a `DateTime`. +/// If it fails, return a tuple of the `String` along with its `ParseError`. +fn parse_date + Clone>( + s: S, + now: &Zoned, + dbg_opts: DebugOptions, +) -> Result { + let input_str = s.as_ref(); + + if dbg_opts.debug { + let _ = writeln!(pi_uutils_ctx::stderr(), "date: input string: {input_str}"); + } + + // First, try to parse any timezone abbreviations + if let Some(zoned) = try_parse_with_abbreviation(input_str, now) { + if dbg_opts.debug { + // pi-uutils: context stderr instead of `stderr().lock()`. + let mut err = pi_uutils_ctx::stderr(); + let _ = writeln!( + err, + "date: parsed date part: (Y-M-D) {}", + strtime::format("%Y-%m-%d", &zoned).unwrap_or_default() + ); + let _ = writeln!( + err, + "date: parsed time part: {}", + strtime::format("%H:%M:%S", &zoned).unwrap_or_default() + ); + let tz_display = zoned.time_zone().iana_name().unwrap_or("system default"); + let _ = writeln!(err, "date: input timezone: {tz_display}"); + } + return Ok(zoned); + } + + match parse_datetime::parse_datetime_at_date(now.clone(), input_str) { + // Convert to system timezone for display + // (parse_datetime returns Zoned in the input's timezone) + Ok(date) => { + let result = date.timestamp().to_zoned(now.time_zone().clone()); + if dbg_opts.debug { + // Show final parsed date and time + // pi-uutils: context stderr instead of `stderr().lock()`. + let mut err = pi_uutils_ctx::stderr(); + let _ = writeln!( + err, + "date: parsed date part: (Y-M-D) {}", + strtime::format("%Y-%m-%d", &result).unwrap_or_default() + ); + let _ = writeln!( + err, + "date: parsed time part: {}", + strtime::format("%H:%M:%S", &result).unwrap_or_default() + ); + + // Show timezone information + let _ = writeln!(err, "date: input timezone: system default"); + + // Check if time component was specified, if not warn about midnight usage + // Only warn for date-only inputs (no time specified), but not for epoch formats + // (@N) or inputs that explicitly specify a time (containing ':') + if dbg_opts.warn_midnight && !input_str.contains(':') && !input_str.contains('@') { + // Input likely didn't specify a time, so midnight was assumed + let time_str = strtime::format("%H:%M:%S", &result).unwrap_or_default(); + if time_str == "00:00:00" { + let _ = writeln!(err, "date: warning: using midnight as starting time: 00:00:00"); + } + } + } + Ok(result) + }, + Err(e) => Err((input_str.into(), e)), + } +} + +#[cfg(not(any(unix, windows)))] +fn get_clock_resolution() -> Timestamp { + unimplemented!("getting clock resolution not implemented (unsupported target)"); +} + +#[cfg(all(unix, not(target_os = "redox")))] +/// Returns the resolution of the system’s realtime clock. +/// +/// # Panics +/// +/// Panics if `clock_getres` fails. On a POSIX-compliant system this should not +/// occur, as `CLOCK_REALTIME` is required to be supported. +/// Failure would indicate a non-conforming or otherwise broken implementation. +fn get_clock_resolution() -> Timestamp { + use rustix::time::{ClockId, clock_getres}; + + let timespec = clock_getres(ClockId::Realtime); + + #[allow(clippy::unnecessary_cast, reason = "needed for 32 bit target")] + Timestamp::constant(timespec.tv_sec as _, timespec.tv_nsec as _) +} + +#[cfg(all(unix, target_os = "redox"))] +fn get_clock_resolution() -> Timestamp { + // Redox OS does not support the posix clock_getres function, however + // internally it uses a resolution of 1ns to represent timestamps. + // https://gitlab.redox-os.org/redox-os/kernel/-/blob/master/src/time.rs + Timestamp::constant(0, 1) +} + +#[cfg(windows)] +fn get_clock_resolution() -> Timestamp { + // Windows does not expose a system call for getting the resolution of the + // clock, however the FILETIME struct returned by GetSystemTimeAsFileTime, + // and GetSystemTimePreciseAsFileTime has a resolution of 100ns. + // https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-filetime + Timestamp::constant(0, 100) +} + +// pi-uutils: upstream's `convert_for_set` and the `set_system_datetime` +// variants (clock_settime / SetSystemTime) are removed with the set-date +// capability. + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("date") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = std::fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + // --- upstream unit tests (0.8.0) --- + + #[test] + fn test_parse_military_timezone_with_offset() { + // Valid cases: letter only, letter + digit, uppercase + assert_eq!(parse_military_timezone_with_offset("m"), Some((12, DayDelta::Previous))); // UTC+12 -> 12:00 UTC + assert_eq!(parse_military_timezone_with_offset("m9"), Some((21, DayDelta::Previous))); // 12 + 9 = 21 + assert_eq!(parse_military_timezone_with_offset("a5"), Some((4, DayDelta::Same))); // 23 + 5 = 28 % 24 = 4 + assert_eq!(parse_military_timezone_with_offset("z"), Some((0, DayDelta::Same))); // UTC+0 -> 00:00 UTC + assert_eq!(parse_military_timezone_with_offset("M9"), Some((21, DayDelta::Previous))); // Uppercase works + + // Invalid cases: 'j' reserved, empty, too long, starts with digit + assert_eq!(parse_military_timezone_with_offset("j"), None); // Reserved for local time + assert_eq!(parse_military_timezone_with_offset(""), None); // Empty + assert_eq!(parse_military_timezone_with_offset("m999"), None); // Too long + assert_eq!(parse_military_timezone_with_offset("9m"), None); // Starts with digit + } + + #[test] + fn test_abbreviation_resolves_relative_date_against_now() { + let now = "2025-03-15T20:00:00+00:00[UTC]".parse::().unwrap(); + let result = + parse_date("yesterday 10:00 GMT", &now, DebugOptions::new(false, false)).unwrap(); + assert_eq!(result.date(), jiff::civil::date(2025, 3, 14)); + } + + #[test] + fn test_strip_parenthesized_comments() { + assert_eq!(strip_parenthesized_comments("hello"), "hello"); + assert_eq!(strip_parenthesized_comments("2026-01-05"), "2026-01-05"); + assert_eq!(strip_parenthesized_comments("("), ""); + assert_eq!(strip_parenthesized_comments("1(comment"), "1"); + assert_eq!(strip_parenthesized_comments("2026-01-05(this is a comment"), "2026-01-05"); + assert_eq!(strip_parenthesized_comments("2026(comment)-01-05"), "2026-01-05"); + assert_eq!(strip_parenthesized_comments("()"), ""); + assert_eq!(strip_parenthesized_comments("((foo)2026-01-05)"), ""); + + // These cases test the balanced parentheses removal feature + // which extends beyond what GNU date strictly supports + assert_eq!(strip_parenthesized_comments("a(b)c"), "ac"); + assert_eq!(strip_parenthesized_comments("a(b)c(d)e"), "ace"); + assert_eq!(strip_parenthesized_comments("(a)(b)"), ""); + + // When parentheses are unmatched, processing stops at the unmatched opening + // paren + assert_eq!(strip_parenthesized_comments("a(b)c(d"), "ac"); + + // Additional edge cases for nested and complex parentheses + assert_eq!(strip_parenthesized_comments("a(b(c)d)e"), "ae"); // Nested balanced + assert_eq!(strip_parenthesized_comments("a(b(c)d"), "a"); // Nested unbalanced + assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched + } + + // --- pi-uutils behavior contracts --- + + #[test] + fn utc_date_string_formats_exactly() { + let (code, stdout, stderr) = + run_in(PathBuf::from("."), vec!["-u", "-d", "2026-01-02 03:04:05", "+%Y-%m-%dT%H:%M:%S"]); + assert_eq!(code, 0); + assert_eq!(stdout, "2026-01-02T03:04:05\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn epoch_input_round_trips_through_seconds_format() { + let (code, stdout, stderr) = + run_in(PathBuf::from("."), vec!["-u", "-d", "@1767323045", "+%s"]); + assert_eq!(code, 0); + assert_eq!(stdout, "1767323045\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn set_is_unsupported() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--set", "2026-01-02 03:04:05"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "date: setting the date is not supported by this builtin\n"); + + let (code, _, stderr) = run_in(PathBuf::from("."), vec!["-s", "now"]); + assert_eq!(code, 1); + assert!(stderr.contains("not supported by this builtin")); + } + + #[test] + fn datefile_relative_path_resolves_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + std::fs::write(root.join("dates.txt"), "2026-01-02 03:04:05\n@0\n").unwrap(); + + // Relative operand + scope cwd differing from the process cwd: only the + // call-site `pi_uutils_ctx::resolve` patch makes this find the file. + let (code, stdout, stderr) = run_in(root, vec!["-u", "-f", "dates.txt", "+%F"]); + assert_eq!(code, 0); + assert_eq!(stdout, "2026-01-02\n1970-01-01\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn datefile_bad_line_reports_but_keeps_processing() { + let (_dir, root) = canonical_tempdir(); + std::fs::write(root.join("dates.txt"), "foobarbaz\n@86400\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-u", "-f", "dates.txt", "+%F"]); + assert_eq!(code, 1, "a bad line must fail the invocation"); + assert_eq!(stdout, "1970-01-02\n", "good lines after a bad one still print"); + assert!(stderr.contains("invalid date 'foobarbaz'"), "stderr: {stderr}"); + } + + #[test] + fn invalid_date_string_reports_and_fails() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["-d", "foobarbaz"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "date: invalid date 'foobarbaz'\n"); + } + + #[test] + fn rfc_email_iso_and_rfc3339_formats() { + let fixed = "2026-01-02 03:04:05"; + + let (code, stdout, _) = run_in(PathBuf::from("."), vec!["-u", "-d", fixed, "-R"]); + assert_eq!((code, stdout.as_str()), (0, "Fri, 02 Jan 2026 03:04:05 +0000\n")); + + let (code, stdout, _) = run_in(PathBuf::from("."), vec!["-u", "-d", fixed, "-Iseconds"]); + assert_eq!((code, stdout.as_str()), (0, "2026-01-02T03:04:05+00:00\n")); + + let (code, stdout, _) = + run_in(PathBuf::from("."), vec!["-u", "-d", fixed, "--rfc-3339=seconds"]); + assert_eq!((code, stdout.as_str()), (0, "2026-01-02 03:04:05+00:00\n")); + } + + #[test] + fn reference_relative_path_resolves_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + std::fs::write(root.join("ref-file"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-u", "-r", "ref-file", "+%s"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let secs: i64 = stdout.trim().parse().unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + assert!((now - secs).abs() < 60, "mtime epoch {secs} should be close to now {now}"); + + // Missing file fails, naming the operand as typed. + let (code, _, stderr) = run_in(root, vec!["-r", "missing-file"]); + assert_eq!(code, 1); + assert!(stderr.contains("missing-file"), "stderr: {stderr}"); + } + + #[test] + fn bsd_epoch_reference_formats_seconds() { + let (_dir, root) = canonical_tempdir(); + + // BSD `-r` names an epoch; a nonexistent all-digit path is unambiguous. + let (code, stdout, stderr) = run_in(root, vec!["-r", "1736344012", "+%s"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "1736344012\n", "")); + } + + #[test] + fn numeric_existing_reference_keeps_gnu_file_semantics() { + let (_dir, root) = canonical_tempdir(); + std::fs::write(root.join("1736344012"), b"x").unwrap(); + + // An existing all-digit filename remains GNU `-r FILE`, not BSD epoch + // syntax. Its mtime is necessarily close to the current clock. + let (code, stdout, stderr) = run_in(root, vec!["-u", "-r", "1736344012", "+%s"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let mtime: i64 = stdout.trim().parse().unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + assert!((now - mtime).abs() < 60, "mtime epoch {mtime} should be close to now {now}"); + } + + #[test] + fn bsd_signed_adjustment_formats_relative_date() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["-u", "-v-1d", "+%F"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let expected = strtime::format( + "%F", + &Timestamp::now() + .to_zoned(TimeZone::UTC) + .yesterday() + .unwrap(), + ) + .unwrap(); + assert_eq!(stdout, format!("{expected}\n")); + + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["-u", "-v+1d", "+%F"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let expected = + strtime::format("%F", &Timestamp::now().to_zoned(TimeZone::UTC).tomorrow().unwrap()) + .unwrap(); + assert_eq!(stdout, format!("{expected}\n")); + } + + #[test] + fn bsd_no_set_flag_is_a_no_op() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["-j", "+%s"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + assert!(stdout.trim().parse::().is_ok(), "epoch output expected: {stdout:?}"); + } + + #[test] + fn unsupported_bsd_forms_fail_loudly() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["-v1d", "+%F"]); + assert_eq!((code, stdout.as_str()), (1, "")); + assert!( + stderr.contains("BSD -v field-set adjustments are not supported"), + "unexpected stderr: {stderr:?}" + ); + + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["-j", "-f", "%F"]); + assert_eq!((code, stdout.as_str()), (1, "")); + assert!( + stderr.contains("BSD 'date -j -f' parse mode is not supported"), + "unexpected stderr: {stderr:?}" + ); + + let (_dir, root) = canonical_tempdir(); + let (code, stdout, stderr) = run_in(root, vec!["-v-1d", "-r", "1736344012", "+%F"]); + assert_eq!((code, stdout.as_str()), (1, "")); + assert!( + stderr.contains("BSD -v with -r EPOCH is not supported"), + "unexpected stderr: {stderr:?}" + ); + } + + #[test] + fn format_operand_without_plus_is_rejected() { + // With -d: GNU's "lacks a leading '+'" message. + let (code, _, stderr) = run_in(PathBuf::from("."), vec!["-d", "2026-01-02", "%F"]); + assert_eq!(code, 1); + assert!(stderr.contains("lacks a leading '+'"), "stderr: {stderr}"); + + // Without a date source: GNU treats the operand as an invalid date. + let (code, _, stderr) = run_in(PathBuf::from("."), vec!["%F"]); + assert_eq!(code, 1); + assert!(stderr.contains("invalid date '%F'"), "stderr: {stderr}"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("FORMAT controls the output")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-date/src/format_modifiers.rs b/crates/vendor/uu-date/src/format_modifiers.rs new file mode 100644 index 000000000..20f3f987c --- /dev/null +++ b/crates/vendor/uu-date/src/format_modifiers.rs @@ -0,0 +1,760 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore strtime + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin: the `translate!` error string is literalized with the +// en-US locale text and the regex cache uses `LazyLock` instead of `OnceLock`. +// No behavior changes. + +//! GNU date format modifier support +//! +//! This module implements GNU-compatible format modifiers for date formatting. +//! These modifiers extend standard strftime format specifiers with optional +//! width and flag modifiers. +//! +//! ## Syntax +//! +//! Format: `%[flags][width]specifier` +//! +//! ### Flags +//! - `-`: Do not pad the field +//! - `_`: Pad with spaces instead of zeros +//! - `0`: Pad with zeros (default for numeric fields) +//! - `^`: Convert to uppercase +//! - `#`: Use opposite case (uppercase becomes lowercase and vice versa) +//! - `+`: Force display of sign (+ for positive, - for negative) +//! +//! ### Width +//! - One or more digits specifying minimum field width +//! - Field will be padded to this width using the padding character +//! +//! ### Examples +//! - `%10Y`: Year padded to 10 digits with zeros (0000001999) +//! - `%_10m`: Month padded to 10 digits with spaces ( 06) +//! - `%-d`: Day without padding (1 instead of 01) +//! - `%^B`: Month name in uppercase (JUNE) +//! - `%+4C`: Century with sign, padded to 4 characters (+019) + +use std::{fmt, sync::LazyLock}; + +use jiff::{ + Zoned, + fmt::strtime::{BrokenDownTime, Config, PosixCustom}, +}; +use regex::Regex; + +/// Error type for format modifier operations +#[derive(Debug)] +pub enum FormatError { + /// Error from the underlying jiff library + JiffError(jiff::Error), + /// Field width calculation overflowed or required allocation failed + FieldWidthTooLarge { width: usize, specifier: String }, +} + +impl fmt::Display for FormatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::JiffError(e) => write!(f, "{e}"), + // pi-uutils: literalized en-US translation of + // `date-error-format-modifier-width-too-large`. + Self::FieldWidthTooLarge { width, specifier } => { + write!(f, "format modifier width '{width}' is too large for specifier '%{specifier}'") + }, + } + } +} + +impl From for FormatError { + fn from(e: jiff::Error) -> Self { + Self::JiffError(e) + } +} + +/// Regex to match format specifiers with optional modifiers +/// Pattern: % \[flags\] \[width\] specifier +/// Flags: -, _, 0, ^, #, + +/// Width: one or more digits +/// Specifier: any letter or special sequence like :z, ::z, :::z +// pi-uutils: `LazyLock` instead of upstream's function-local `OnceLock`. +static FORMAT_SPEC_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"%([_0^#+-]*)(\d*)(:*[a-zA-Z])").unwrap()); + +/// Check if format string contains any GNU modifiers and format if present. +/// +/// This function combines modifier detection and formatting in a single pass +/// for better performance. If no modifiers are found, returns None and the +/// caller should use standard formatting. If modifiers are found, returns +/// the formatted string. +pub fn format_with_modifiers_if_present( + date: &Zoned, + format_string: &str, + config: &Config, +) -> Option> { + let re = &*FORMAT_SPEC_REGEX; + + // Quick check: does the string contain any modifiers? + let has_modifiers = re.captures_iter(format_string).any(|cap| { + let flags = cap.get(1).map_or("", |m| m.as_str()); + let width_str = cap.get(2).map_or("", |m| m.as_str()); + !flags.is_empty() || !width_str.is_empty() + }); + + if !has_modifiers { + return None; + } + + // If we have modifiers, format the string + Some(format_with_modifiers(date, format_string, config)) +} + +/// Process a format string with GNU modifiers. +/// +/// # Arguments +/// * `date` - The date to format +/// * `format_string` - Format string with GNU modifiers +/// * `config` - Strftime configuration +/// +/// # Returns +/// Formatted string with modifiers applied +/// +/// # Errors +/// Returns `FormatError` if formatting fails +fn format_with_modifiers( + date: &Zoned, + format_string: &str, + config: &Config, +) -> Result { + // First, replace %% with a placeholder to avoid matching it + let placeholder = "\x00PERCENT\x00"; + let temp_format = format_string.replace("%%", placeholder); + + let re = &*FORMAT_SPEC_REGEX; + let mut result = String::new(); + let mut last_end = 0; + + let broken_down = BrokenDownTime::from(date); + + for cap in re.captures_iter(&temp_format) { + let whole_match = cap.get(0).unwrap(); + let flags = cap.get(1).map_or("", |m| m.as_str()); + let width_str = cap.get(2).map_or("", |m| m.as_str()); + let spec = cap.get(3).unwrap().as_str(); + + // Add text before this match + result.push_str(&temp_format[last_end..whole_match.start()]); + + // Format the base specifier first + let base_format = format!("%{spec}"); + let formatted = broken_down.to_string_with_config(config, &base_format)?; + + // Check if this specifier has modifiers + if !flags.is_empty() || !width_str.is_empty() { + // Apply modifiers to the formatted value + let width: usize = width_str.parse().unwrap_or(0); + let explicit_width = !width_str.is_empty(); + let modified = apply_modifiers(&formatted, flags, width, spec, explicit_width)?; + result.push_str(&modified); + } else { + // No modifiers, use formatted value as-is + result.push_str(&formatted); + } + + last_end = whole_match.end(); + } + + // Add remaining text + result.push_str(&temp_format[last_end..]); + + // Restore %% by converting placeholder to % + let result = result.replace(placeholder, "%"); + + Ok(result) +} + +/// Returns true if the specifier produces text output (default pad is space) +/// rather than numeric output (default pad is zero). +fn is_text_specifier(specifier: &str) -> bool { + matches!(specifier.chars().last(), Some('A' | 'a' | 'B' | 'b' | 'h' | 'Z' | 'p' | 'P')) +} + +/// Returns true if the specifier defaults to space padding. +/// This includes text specifiers and numeric specifiers like %e and %k +/// that use blank-padding by default in GNU date. +fn is_space_padded_specifier(specifier: &str) -> bool { + matches!( + specifier.chars().last(), + Some('A' | 'a' | 'B' | 'b' | 'h' | 'Z' | 'p' | 'P' | 'e' | 'k' | 'l') + ) +} + +/// Returns the default width for a specifier. +/// This is used when a flag like `_` is used without an explicit width. +fn get_default_width(specifier: &str) -> usize { + match specifier.chars().last() { + // Day of month: 2 digits (01-31) + Some('d') | Some('e') => 2, + // Month: 2 digits (01-12) + Some('m') => 2, + // Hour: 2 digits (00-23) + Some('H') | Some('k') => 2, + // Hour (12-hour): 2 digits (01-12) + Some('I') | Some('l') => 2, + // Minute: 2 digits (00-59) + Some('M') => 2, + // Second: 2 digits (00-60) + Some('S') => 2, + // Year (2-digit): 2 digits + Some('y') => 2, + // Day of year: 3 digits (001-366) + Some('j') => 3, + // Week number: 2 digits (00-53) + Some('U') | Some('W') | Some('V') => 2, + // Day of week: 1 digit (0-6 or 1-7) + Some('w') | Some('u') => 1, + // Century: 2 digits (00-99) + Some('C') => 2, + // Full year: 4 digits + Some('Y') | Some('G') => 4, + // ISO week year (2-digit): 2 digits + Some('g') => 2, + // Epoch seconds: typically 10 digits (but variable) + Some('s') => 0, + // Nanoseconds: 9 digits + Some('N') => 9, + // Quarter: 1 digit + Some('q') => 1, + // Timezone offset: varies + Some('z') => 0, + // Text specifiers have no default width + _ => 0, + } +} + +/// Strip default padding (leading zeros or leading spaces) from a value, +/// preserving at least one character. +fn strip_default_padding(value: &str) -> String { + if value.starts_with('0') && value.len() >= 2 { + let stripped = value.trim_start_matches('0'); + if stripped.is_empty() { + return "0".to_string(); + } + if let Some(first_char) = stripped.chars().next() + && first_char.is_ascii_digit() + { + return stripped.to_string(); + } + } + if value.starts_with(' ') { + let stripped = value.trim_start(); + if !stripped.is_empty() { + return stripped.to_string(); + } + } + value.to_string() +} + +/// Apply width and flag modifiers to a formatted value. +/// +/// The `specifier` parameter is the format specifier (e.g., "d", "B", "Y") +/// which determines the default padding character (space for text, zero for +/// numeric). Flags are processed in order so that when conflicting flags +/// appear, the last one takes precedence (e.g., `_+` means `+` wins for +/// padding). +/// +/// The `explicit_width` parameter indicates whether a width was explicitly +/// specified in the format string (true) or if width is 0 (false). +fn apply_modifiers( + value: &str, + flags: &str, + width: usize, + specifier: &str, + explicit_width: bool, +) -> Result { + let mut result = value.to_string(); + + // Determine default pad character based on specifier type + // Determine default pad character based on specifier type. + // Text specifiers (month names, etc.) and numeric specifiers like %e, %k, %l + // default to space padding; other numeric specifiers default to zero padding. + let default_pad = if is_space_padded_specifier(specifier) { + ' ' + } else { + '0' + }; + + // Process flags in order - last conflicting flag wins + let mut pad_char = default_pad; + let mut no_pad = false; + let mut uppercase = false; + let mut swap_case = false; + let mut force_sign = false; + let mut underscore_flag = false; + + for flag in flags.chars() { + match flag { + '-' => { + no_pad = true; + }, + '_' => { + no_pad = false; + pad_char = ' '; + underscore_flag = true; + }, + '0' => { + no_pad = false; + pad_char = '0'; + }, + '^' => { + uppercase = true; + swap_case = false; // ^ overrides # + }, + '#' if !uppercase => { + // Only apply # if ^ hasn't been set + swap_case = true; + }, + '+' => { + force_sign = true; + no_pad = false; + pad_char = '0'; + }, + _ => {}, + } + } + + // Apply case modifications (uppercase takes precedence over swap_case) + if uppercase { + result = result.to_uppercase(); + } else if swap_case { + if result + .chars() + .all(|c| !c.is_alphabetic() || c.is_uppercase()) + { + result = result.to_lowercase(); + } else if !result + .chars() + .all(|c| !c.is_alphabetic() || c.is_lowercase()) + { + result = result.to_uppercase(); + } + } + + // If no_pad flag is active, suppress all padding and return + if no_pad { + return Ok(strip_default_padding(&result)); + } + + // Handle padding flag without explicit width: use default width + // This applies when _ or 0 flag overrides the default padding character + // and no explicit width is specified (e.g., %_m, %0e) + let effective_width = if !explicit_width && (underscore_flag || pad_char != default_pad) { + get_default_width(specifier) + } else { + width + }; + + // When the requested width is narrower than the default formatted width, GNU + // first removes default padding and then reapplies the requested width. + if effective_width > 0 && effective_width < result.len() { + result = strip_default_padding(&result); + } + + // Strip default padding when switching pad characters on numeric fields + if !is_text_specifier(specifier) && result.len() >= 2 { + if pad_char == ' ' && result.starts_with('0') { + // Switching to space padding: strip leading zeros + result = strip_default_padding(&result); + } else if pad_char == '0' && result.starts_with(' ') { + // Switching to zero padding: strip leading spaces + result = strip_default_padding(&result); + } + } + + // Apply force sign for numeric values + // GNU behavior: + only adds sign if: + // 1. An explicit width is provided, OR + // 2. The value exceeds the default width for that specifier (e.g., year > 4 + // digits) + if force_sign + && !result.starts_with('+') + && !result.starts_with('-') + && result.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + let default_w = get_default_width(specifier); + // Add sign only if explicit width provided OR result exceeds default width + if explicit_width || (default_w > 0 && result.len() > default_w) { + result.insert(0, '+'); + } + } + + // Apply width padding + if effective_width > result.len() { + let padding = effective_width - result.len(); + let has_sign = result.starts_with('+') || result.starts_with('-'); + + if pad_char == '0' && has_sign { + // Zero padding: sign first, then zeros (e.g., "-0022") + let sign = result.chars().next().unwrap(); + let rest = &result[1..]; + let mut padded = try_alloc_padded(result.len(), padding, effective_width, specifier)?; + padded.push(sign); + padded.extend(std::iter::repeat_n('0', padding)); + padded.push_str(rest); + result = padded; + } else { + // Default: pad on the left (e.g., " -22" or " 1999") + let mut padded = try_alloc_padded(result.len(), padding, effective_width, specifier)?; + padded.extend(std::iter::repeat_n(pad_char, padding)); + padded.push_str(&result); + result = padded; + } + } + + Ok(result) +} + +/// Allocate a `String` with enough capacity for `current_len + padding`, +/// returning `FieldWidthTooLarge` on arithmetic overflow or allocation failure. +fn try_alloc_padded( + current_len: usize, + padding: usize, + width: usize, + specifier: &str, +) -> Result { + let target_len = current_len + .checked_add(padding) + .ok_or_else(|| FormatError::FieldWidthTooLarge { width, specifier: specifier.to_string() })?; + let mut s = String::new(); + s.try_reserve(target_len) + .map_err(|_| FormatError::FieldWidthTooLarge { width, specifier: specifier.to_string() })?; + Ok(s) +} + +#[cfg(test)] +mod tests { + use jiff::{civil, tz::TimeZone}; + + use super::*; + + fn make_test_date(year: i16, month: i8, day: i8, hour: i8) -> Zoned { + civil::date(year, month, day) + .at(hour, 0, 0, 0) + .to_zoned(TimeZone::UTC) + .unwrap() + } + + fn get_config() -> Config { + Config::new().custom(PosixCustom::new()).lenient(true) + } + + #[test] + fn test_width_and_padding_modifiers() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + + // Test basic width with zero padding + let result = format_with_modifiers(&date, "%10Y", &config).unwrap(); + assert_eq!(result, "0000001999"); + + // Test large width + let result = format_with_modifiers(&date, "%20Y", &config).unwrap(); + assert_eq!(result, "00000000000000001999"); + assert_eq!(result.len(), 20); + + // Test underscore (space) padding with month + let result = format_with_modifiers(&date, "%_10m", &config).unwrap(); + assert_eq!(result, " 6"); + assert_eq!(result.len(), 10); + + // Test underscore padding with day + let date_day5 = make_test_date(1999, 6, 5, 0); + let result = format_with_modifiers(&date_day5, "%_10d", &config).unwrap(); + assert_eq!(result, " 5"); + } + + #[test] + fn test_no_pad_and_case_flags() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + + // Test no-pad: %-10Y suppresses all padding (width ignored) + let result = format_with_modifiers(&date, "%-10Y", &config).unwrap(); + assert_eq!(result, "1999"); + + // Test no-pad: %-d strips default zero padding + let result = format_with_modifiers(&date, "%-d", &config).unwrap(); + assert_eq!(result, "1"); + + // Test uppercase: %^B should uppercase month name + let result = format_with_modifiers(&date, "%^B", &config).unwrap(); + assert_eq!(result, "JUNE"); + + // Test uppercase with width: %^10B should uppercase and space-pad (text + // specifier) + let result = format_with_modifiers(&date, "%^10B", &config).unwrap(); + assert_eq!(result, " JUNE"); + assert_eq!(result.len(), 10); + } + + #[test] + fn test_sign_flags() { + let date = make_test_date(1970, 1, 1, 0); + let config = get_config(); + + // Test force sign with century: %+4C + let result = format_with_modifiers(&date, "%+4C", &config).unwrap(); + assert!(result.starts_with('+')); + assert_eq!(result.len(), 4); + + // Test force sign with zero padding: %+6Y + let result = format_with_modifiers(&date, "%+6Y", &config).unwrap(); + assert_eq!(result, "+01970"); + } + + #[test] + fn test_combined_flags_underscore_and_sign() { + let date = make_test_date(1970, 1, 1, 0); + let config = get_config(); + // %_+6Y: _ sets space pad, then + overrides to zero pad with sign (last wins) + let result = format_with_modifiers(&date, "%_+6Y", &config).unwrap(); + assert_eq!(result, "+01970"); + } + + #[test] + fn test_combined_flags_no_pad_and_uppercase() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + // %-^10B: uppercase + no-pad (- suppresses all padding, width ignored) + let result = format_with_modifiers(&date, "%-^10B", &config).unwrap(); + assert_eq!(result, "JUNE"); + } + + #[test] + fn test_swap_case_flag() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + // %#B: swap case on "June" (mixed case) → uppercase + let result = format_with_modifiers(&date, "%#B", &config).unwrap(); + assert_eq!(result, "JUNE"); + } + + #[test] + fn test_width_smaller_than_result() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + // %1d: width 1 < "01".len() → strip zero padding → "1" + let result = format_with_modifiers(&date, "%1d", &config).unwrap(); + assert_eq!(result, "1"); + } + + #[test] + fn test_edge_cases_and_special_formats() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + + // Test width zero (no effect) + let result = format_with_modifiers(&date, "%Y", &config).unwrap(); + assert_eq!(result, "1999"); + + // Test no modifiers (standard format) + let result = format_with_modifiers(&date, "%Y-%m-%d", &config).unwrap(); + assert_eq!(result, "1999-06-01"); + + // Test %% escape sequence + let result = format_with_modifiers(&date, "%%Y=%Y", &config).unwrap(); + assert_eq!(result, "%Y=1999"); + + // Test multiple modifiers in one format string + // %-5d: no-pad suppresses all padding → "1" (width ignored) + let result = format_with_modifiers(&date, "%10Y-%_5m-%-5d", &config).unwrap(); + assert_eq!(result, "0000001999- 6-1"); + } + + #[test] + fn test_modifier_detection() { + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + + // Should detect modifiers + let result = format_with_modifiers_if_present(&date, "%10Y", &config); + assert!(result.is_some()); + + // Should not detect modifiers + let result = format_with_modifiers_if_present(&date, "%Y-%m-%d", &config); + assert!(result.is_none()); + + // Should detect flag without width + let result = format_with_modifiers_if_present(&date, "%^B", &config); + assert!(result.is_some()); + } + + #[test] + fn test_negative_values_with_space_padding() { + // Test case from GNU test: neg-secs2 + // Format: %_5s with value -22 should produce " -22" (space-padded) + use jiff::Timestamp; + + let ts = Timestamp::from_second(-22).unwrap(); + let date = ts.to_zoned(TimeZone::UTC); + let config = get_config(); + + let result = format_with_modifiers(&date, "%_5s", &config).unwrap(); + assert_eq!(result, " -22", "Space padding should pad before the sign for negative numbers"); + } + + // Unit tests for apply_modifiers function + #[test] + fn test_apply_modifiers_basic() { + // No modifiers (numeric specifier) + assert_eq!(apply_modifiers("1999", "", 0, "Y", false).unwrap(), "1999"); + // Zero padding + assert_eq!(apply_modifiers("1999", "0", 10, "Y", true).unwrap(), "0000001999"); + // Space padding (strips leading zeros) + assert_eq!(apply_modifiers("06", "_", 5, "m", true).unwrap(), " 6"); + // No-pad (strips leading zeros, width ignored) + assert_eq!(apply_modifiers("01", "-", 5, "d", true).unwrap(), "1"); + // Uppercase + assert_eq!(apply_modifiers("june", "^", 0, "B", false).unwrap(), "JUNE"); + // Swap case: all uppercase → lowercase + assert_eq!(apply_modifiers("UTC", "#", 0, "Z", false).unwrap(), "utc"); + // Swap case: mixed case → uppercase + assert_eq!(apply_modifiers("June", "#", 0, "B", false).unwrap(), "JUNE"); + } + + #[test] + fn test_apply_modifiers_signs() { + // Force sign with explicit width + assert_eq!(apply_modifiers("1970", "+", 6, "Y", true).unwrap(), "+01970"); + // Force sign without explicit width: should NOT add sign for 4-digit year + assert_eq!(apply_modifiers("1999", "+", 0, "Y", false).unwrap(), "1999"); + // Force sign without explicit width: SHOULD add sign for year > 4 digits + assert_eq!(apply_modifiers("12345", "+", 0, "Y", false).unwrap(), "+12345"); + // Negative with zero padding: sign first, then zeros + assert_eq!(apply_modifiers("-22", "0", 5, "s", true).unwrap(), "-0022"); + // Negative with space padding: spaces first, then sign + assert_eq!(apply_modifiers("-22", "_", 5, "s", true).unwrap(), " -22"); + // Force sign (_+): + is last, overrides _ → zero pad with sign + assert_eq!(apply_modifiers("5", "_+", 5, "s", true).unwrap(), "+0005"); + // No-pad + uppercase: no padding applied + assert_eq!(apply_modifiers("june", "-^", 10, "B", true).unwrap(), "JUNE"); + } + + #[test] + fn test_case_flag_precedence() { + // Test that ^ (uppercase) overrides # (swap case) + assert_eq!(apply_modifiers("June", "^#", 0, "B", false).unwrap(), "JUNE"); + assert_eq!(apply_modifiers("June", "#^", 0, "B", false).unwrap(), "JUNE"); + // Test # alone (swap case) + assert_eq!(apply_modifiers("June", "#", 0, "B", false).unwrap(), "JUNE"); + assert_eq!(apply_modifiers("JUNE", "#", 0, "B", false).unwrap(), "june"); + } + + #[test] + fn test_apply_modifiers_text_specifiers() { + // Text specifiers default to space padding + assert_eq!(apply_modifiers("June", "", 10, "B", true).unwrap(), " June"); + assert_eq!(apply_modifiers("Mon", "", 10, "a", true).unwrap(), " Mon"); + // Numeric specifiers default to zero padding + assert_eq!(apply_modifiers("6", "", 10, "m", true).unwrap(), "0000000006"); + } + + #[test] + fn test_apply_modifiers_width_smaller_than_result() { + // Width smaller than result strips default padding + assert_eq!(apply_modifiers("01", "", 1, "d", true).unwrap(), "1"); + assert_eq!(apply_modifiers("06", "", 1, "m", true).unwrap(), "6"); + } + + #[test] + fn test_apply_modifiers_parametrized() { + let test_cases = vec![ + ("1", "0", 3, "Y", true, "001"), + ("1", "_", 3, "d", true, " 1"), + ("1", "-", 3, "d", true, "1"), // no-pad: width ignored + ("abc", "^", 5, "B", true, " ABC"), // text specifier: space pad + ("5", "+", 4, "s", true, "+005"), + ("5", "_+", 4, "s", true, "+005"), // + is last: zero pad with sign + ("-3", "0", 5, "s", true, "-0003"), + ("05", "_", 3, "d", true, " 5"), + ("09", "-", 4, "d", true, "9"), // no-pad: width ignored + ("1970", "_+", 6, "Y", true, "+01970"), // + is last: zero pad with sign + ]; + + for (value, flags, width, spec, explicit_width, expected) in test_cases { + assert_eq!( + apply_modifiers(value, flags, width, spec, explicit_width).unwrap(), + expected, + "value='{value}', flags='{flags}', width={width}, spec='{spec}', \ + explicit_width={explicit_width}", + ); + } + } + + #[test] + fn test_apply_modifiers_width_too_large() { + let err = apply_modifiers("x", "", usize::MAX, "c", true).unwrap_err(); + assert!(matches!( + err, + FormatError::FieldWidthTooLarge { width, specifier } + if width == usize::MAX && specifier == "c" + )); + } + + #[test] + fn test_underscore_flag_without_width() { + // %_m should pad month to default width 2 with spaces + assert_eq!(apply_modifiers("6", "_", 0, "m", false).unwrap(), " 6"); + // %_d should pad day to default width 2 with spaces + assert_eq!(apply_modifiers("1", "_", 0, "d", false).unwrap(), " 1"); + // %_H should pad hour to default width 2 with spaces + assert_eq!(apply_modifiers("5", "_", 0, "H", false).unwrap(), " 5"); + // %_Y should pad year to default width 4 with spaces + assert_eq!(apply_modifiers("1999", "_", 0, "Y", false).unwrap(), "1999"); + // already at default width + } + + #[test] + fn test_plus_flag_without_width() { + // %+Y without width should NOT add sign for 4-digit year + assert_eq!(apply_modifiers("1999", "+", 0, "Y", false).unwrap(), "1999"); + // %+Y without width SHOULD add sign for year > 4 digits + assert_eq!(apply_modifiers("12345", "+", 0, "Y", false).unwrap(), "+12345"); + // %+Y with explicit width should add sign + assert_eq!(apply_modifiers("1999", "+", 6, "Y", true).unwrap(), "+01999"); + } + + #[test] + fn test_zero_flag_on_space_padded_specifiers() { + // GNU date: %0e should override space-padding with zero-padding + // Verified: `date -d "2024-06-05" "+%0e"` → "05" + let date = make_test_date(1999, 6, 5, 5); + let config = get_config(); + + // %0e: day-of-month (normally space-padded) with 0 flag → zero-padded + let result = format_with_modifiers(&date, "%0e", &config).unwrap(); + assert_eq!(result, "05", "GNU: %0e should produce '05', not ' 5'"); + + // %0k: hour (normally space-padded) with 0 flag → zero-padded + let result = format_with_modifiers(&date, "%0k", &config).unwrap(); + assert_eq!(result, "05", "GNU: %0k should produce '05', not ' 5'"); + } + + #[test] + fn test_underscore_century_default_width() { + // GNU date: %C default width is 2, not 4 + // Verified: `date -d "2024-06-15" "+%_C"` → "20" (no extra padding) + let date = make_test_date(1999, 6, 1, 0); + let config = get_config(); + + // %_C: century with underscore flag, no explicit width + // Default width for %C should be 2 (century is 00-99) + let result = format_with_modifiers(&date, "%_C", &config).unwrap(); + assert_eq!( + result, "19", + "GNU: %_C should produce '19', not ' 19' (default width is 2, not 4)" + ); + } +} diff --git a/crates/vendor/uu-find/Cargo.toml b/crates/vendor/uu-find/Cargo.toml index fe00f82be..c81a70a80 100644 --- a/crates/vendor/uu-find/Cargo.toml +++ b/crates/vendor/uu-find/Cargo.toml @@ -25,5 +25,6 @@ pi-walker = { path = "../../pi-walker" } [dev-dependencies] tempfile = "3" +parking_lot = "0.12" filetime = "0.2" diff --git a/crates/vendor/uu-find/src/find/mod.rs b/crates/vendor/uu-find/src/find/mod.rs index e979f0d69..ff3a14308 100644 --- a/crates/vendor/uu-find/src/find/mod.rs +++ b/crates/vendor/uu-find/src/find/mod.rs @@ -399,13 +399,66 @@ fn print_version() { let _ = writeln!(stdout(), "find (Rust) {}", env!("CARGO_PKG_VERSION")); } +/// pi-uutils: BSD `find -E` compatibility (macOS muscle memory). +/// +/// BSD `-E` selects POSIX extended regular expressions before the path list; +/// GNU find rejects it, but supports the equivalent `-regextype +/// posix-extended`. Rewriting is limited to otherwise-unparseable invocations: +/// a valid GNU expression may use `-E` as an operand, so it must retain its +/// existing meaning. The inserted setting starts the expression, before every +/// `-regex`/`-iregex`, because matcher construction applies the current regex +/// type as it encounters those predicates. +fn rewrite_bsd_invocation(args: &[&str]) -> Option> { + if !args.contains(&"-E") { + return None; + } + let Err(error) = parse_args(args) else { + return None; + }; + if !error.to_string().contains("Unrecognized flag: '-E'") { + return None; + } + + let mut rewritten: Vec = args + .iter() + .filter(|arg| **arg != "-E") + .map(|arg| (*arg).to_string()) + .collect(); + + let mut i = 0; + while i < rewritten.len() { + match rewritten[i].as_str() { + "-O0" | "-O1" | "-O2" | "-O3" | "-H" | "-L" | "-P" => i += 1, + "--" => { + i += 1; + break; + }, + _ => break, + } + } + while i < rewritten.len() + && (rewritten[i] == "-" || !rewritten[i].starts_with('-')) + && rewritten[i] != "!" + && rewritten[i] != "(" + { + i += 1; + } + rewritten.splice(i..i, ["-regextype".to_string(), "posix-extended".to_string()]); + Some(rewritten) +} + /// Does all the work for find. /// /// All main has to do is pass in the command-line args and exit the process /// with the exit code. Note that the first string in args is expected to be /// the name of the executable. pub fn find_main(args: &[&str], deps: &dyn Dependencies) -> i32 { - match do_find(&args[1..], deps) { + let rewritten = rewrite_bsd_invocation(&args[1..]); + let args: Vec<&str> = match &rewritten { + Some(args) => args.iter().map(String::as_str).collect(), + None => args[1..].to_vec(), + }; + match do_find(&args, deps) { Ok(ret) => ret, Err(e) => { writeln!(&mut stderr(), "Error: {e}").unwrap(); diff --git a/crates/vendor/uu-find/tests/bsd_compat.rs b/crates/vendor/uu-find/tests/bsd_compat.rs new file mode 100644 index 000000000..25cfa1a59 --- /dev/null +++ b/crates/vendor/uu-find/tests/bsd_compat.rs @@ -0,0 +1,107 @@ +use std::{ + collections::HashMap, + ffi::OsString, + fs, + io::Write, + path::PathBuf, + sync::{Arc, atomic::AtomicBool}, +}; + +use parking_lot::Mutex; +use pi_uutils_ctx::ScopeIo; + +fn run_in(cwd: PathBuf, args: Vec) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(AtomicBool::new(false)), + }; + let argv = std::iter::once(OsString::from("find")) + .chain(args.into_iter().map(OsString::from)) + .collect(); + let code = pi_uutils_ctx::scope(io, || uu_find::run(argv)); + + ( + code, + String::from_utf8(stdout_buf.lock().clone()).unwrap(), + String::from_utf8(stderr_buf.lock().clone()).unwrap(), + ) +} + +fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canonical = fs::canonicalize(dir.path()).unwrap(); + (dir, canonical) +} + +#[test] +fn bsd_dash_e_selects_posix_extended_regexes() { + let (_dir, root) = canonical_tempdir(); + for file in ["a.txt", "b.md", "c.rs"] { + fs::write(root.join(file), b"x").unwrap(); + } + + let (code, stdout, stderr) = run_in(root.clone(), vec![ + "-E".to_string(), + root.display().to_string(), + "-regex".to_string(), + r".*\.(txt|md)".to_string(), + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + assert_eq!(stderr, ""); + let mut matches: Vec = stdout.lines().map(PathBuf::from).collect(); + matches.sort(); + assert_eq!(matches, vec![root.join("a.txt"), root.join("b.md")]); +} + +#[test] +fn regex_without_bsd_dash_e_retains_default_syntax() { + let (_dir, root) = canonical_tempdir(); + for file in ["a.txt", "b.md", "c.rs"] { + fs::write(root.join(file), b"x").unwrap(); + } + + let (code, stdout, stderr) = run_in(root.clone(), vec![ + root.display().to_string(), + "-regex".to_string(), + r".*\.(txt|md)".to_string(), + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + assert_eq!(stderr, ""); + assert_eq!(stdout, ""); +} + +#[test] +fn valid_gnu_expression_can_use_dash_e_as_an_operand() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("-E"), b"x").unwrap(); + + let (code, stdout, stderr) = + run_in(root.clone(), vec![root.display().to_string(), "-name".to_string(), "-E".to_string()]); + assert_eq!(code, 0, "stderr: {stderr}"); + assert_eq!(stderr, ""); + assert_eq!(stdout, format!("{}\n", root.join("-E").display())); +} diff --git a/crates/vendor/uu-hostname/Cargo.toml b/crates/vendor/uu-hostname/Cargo.toml new file mode 100644 index 000000000..94660b7d7 --- /dev/null +++ b/crates/vendor/uu-hostname/Cargo.toml @@ -0,0 +1,31 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/hostname), patched to route +# output through pi-uutils-ctx and to reject the set-hostname path so it can run +# in-process as a shell builtin. See src/hostname.rs for the patch markers +# (`pi-uutils:` comments). +[package] +name = "uu_hostname" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "hostname ~ (uutils) display the host name of the current host (vendored + patched for in-process embedding)" + +[lib] +path = "src/hostname.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +hostname = "0.4" +uucore = { version = "0.8.0", features = ["wide"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] +dns-lookup = "3.0.0" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.0", features = [ + "Win32_Networking_WinSock", + "Win32_Foundation", +] } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-hostname/LICENSE b/crates/vendor/uu-hostname/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-hostname/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-hostname/src/hostname.rs b/crates/vendor/uu-hostname/src/hostname.rs new file mode 100644 index 000000000..24e79b3e9 --- /dev/null +++ b/crates/vendor/uu-hostname/src/hostname.rs @@ -0,0 +1,308 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore hashset Addrs addrs + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. The set-hostname path is removed entirely (a NAME operand +// is rejected with an "unsupported" error instead of calling `hostname::set`, +// so the `hostname` crate's "set" feature is dropped), all output is routed +// through the context streams, `translate!` strings are literalized, and the +// entry point no longer calls `std::process::exit`. + +#[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] +use std::net::ToSocketAddrs; +use std::{collections::hash_set::HashSet, ffi::OsString, io::Write, str}; + +use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; +#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] +use dns_lookup::lookup_host; +use pi_uutils_ctx::format_usage; +use uucore::error::{FromIo, UResult, USimpleError}; + +static OPT_DOMAIN: &str = "domain"; +static OPT_IP_ADDRESS: &str = "ip-address"; +static OPT_FQDN: &str = "fqdn"; +static OPT_SHORT: &str = "short"; +static OPT_HOST: &str = "host"; + +#[cfg(windows)] +mod wsa { + use std::io; + + use windows_sys::Win32::Networking::WinSock::{WSACleanup, WSADATA, WSAStartup}; + + pub(super) struct WsaHandle(()); + + pub(super) fn start() -> io::Result { + let mut data = std::mem::MaybeUninit::::uninit(); + let err = unsafe { WSAStartup(0x0202, data.as_mut_ptr()) }; + if err == 0 { + Ok(WsaHandle(())) + } else { + Err(io::Error::from_raw_os_error(err)) + } + } + + impl Drop for WsaHandle { + fn drop(&mut self) { + // This possibly returns an error but we can't handle it + let _ = unsafe { WSACleanup() }; + } + } +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match hostname_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "hostname: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn hostname_main(matches: &ArgMatches) -> UResult<()> { + #[cfg(windows)] + let _handle = wsa::start().map_err_context(|| "failed to start Winsock".to_string())?; + + match matches.get_one::(OPT_HOST) { + None => display_hostname(matches), + // pi-uutils: setting the process-global hostname from inside a shell + // builtin is refused (upstream calls `hostname::set` here). + Some(_host) => Err(USimpleError::new( + 1, + "setting the hostname is not supported by this builtin".to_string(), + )), + } +} + +pub fn uu_app() -> Command { + Command::new("hostname") + .version(uucore::crate_version!()) + .about("Display or set the system's host name.") + .override_usage(format_usage("hostname [OPTION]... [HOSTNAME]")) + .infer_long_args(true) + .arg( + Arg::new(OPT_DOMAIN) + .short('d') + .long("domain") + .overrides_with_all([OPT_DOMAIN, OPT_IP_ADDRESS, OPT_FQDN, OPT_SHORT]) + .help("Display the name of the DNS domain if possible") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_IP_ADDRESS) + .short('i') + .long("ip-address") + .overrides_with_all([OPT_DOMAIN, OPT_IP_ADDRESS, OPT_FQDN, OPT_SHORT]) + .help("Display the network address(es) of the host") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_FQDN) + .short('f') + .long("fqdn") + .overrides_with_all([OPT_DOMAIN, OPT_IP_ADDRESS, OPT_FQDN, OPT_SHORT]) + .help("Display the FQDN (Fully Qualified Domain Name) (default)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_SHORT) + .short('s') + .long("short") + .overrides_with_all([OPT_DOMAIN, OPT_IP_ADDRESS, OPT_FQDN, OPT_SHORT]) + .help("Display the short hostname (the portion before the first dot) if possible") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_HOST) + .value_parser(ValueParser::os_string()) + .value_hint(clap::ValueHint::Hostname), + ) +} + +fn display_hostname(matches: &ArgMatches) -> UResult<()> { + let hostname = hostname::get() + .map_err_context(|| "failed to get hostname".to_owned())? + .to_string_lossy() + .into_owned(); + + // pi-uutils: all output below goes to the context stdout instead of the + // process stdout. + let mut out = pi_uutils_ctx::stdout(); + + if matches.get_flag(OPT_IP_ADDRESS) { + let addresses; + + #[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] + { + let hostname = hostname + ":1"; + let addrs = hostname + .to_socket_addrs() + .map_err_context(|| "failed to resolve socket addresses".to_owned())?; + addresses = addrs; + } + + // DNS reverse lookup via "hostname:1" does not work on FreeBSD and OpenBSD + // use dns-lookup crate instead + #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] + { + let addrs: Vec = lookup_host(hostname.as_str()) + .map_err_context(|| "failed to lookup hostname".to_owned())? + .collect(); + addresses = addrs; + } + + let mut hashset = HashSet::new(); + let mut output = String::new(); + for addr in addresses { + // XXX: not sure why this is necessary... + if !hashset.contains(&addr) { + let mut ip = addr.to_string(); + if ip.ends_with(":1") { + let len = ip.len(); + ip.truncate(len - 2); + } + output.push_str(&ip); + output.push(' '); + hashset.insert(addr); + } + } + let len = output.len(); + if len > 0 { + writeln!(out, "{}", &output[0..len - 1])?; + } + + Ok(()) + } else { + if matches.get_flag(OPT_SHORT) || matches.get_flag(OPT_DOMAIN) { + let mut it = hostname.char_indices().filter(|&ci| ci.1 == '.'); + if let Some(ci) = it.next() { + if matches.get_flag(OPT_SHORT) { + writeln!(out, "{}", &hostname[0..ci.0])?; + } else { + writeln!(out, "{}", &hostname[ci.0 + 1..])?; + } + } else if matches.get_flag(OPT_SHORT) { + writeln!(out, "{hostname}")?; + } + return Ok(()); + } + + writeln!(out, "{hostname}")?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: PathBuf::from("."), + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("hostname") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + #[test] + fn bare_invocation_prints_hostname() { + let (code, stdout, stderr) = run_in(vec![]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert!(stdout.ends_with('\n')); + assert!(!stdout.trim_end().is_empty()); + } + + #[test] + fn set_attempt_is_rejected() { + let (code, stdout, stderr) = run_in(vec!["new-name.example.com"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "hostname: setting the hostname is not supported by this builtin\n"); + } + + #[test] + fn short_is_dotless_prefix_of_full_hostname() { + let (code, short, stderr) = run_in(vec!["-s"]); + let (_, full, _) = run_in(vec![]); + assert_eq!((code, stderr.as_str()), (0, "")); + let short = short.trim_end(); + assert!(!short.contains('.'), "-s must strip everything after the first dot"); + assert!(full.trim_end().starts_with(short)); + } + + #[test] + fn fqdn_flag_matches_default_display() { + // -f is the default display mode; it must print the same name as the + // bare invocation, not attempt any set path. + let (code, fqdn, stderr) = run_in(vec!["-f"]); + let (_, bare, _) = run_in(vec![]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert_eq!(fqdn, bare); + } +} diff --git a/crates/vendor/uu-ln/Cargo.toml b/crates/vendor/uu-ln/Cargo.toml new file mode 100644 index 000000000..2cc4020a4 --- /dev/null +++ b/crates/vendor/uu-ln/Cargo.toml @@ -0,0 +1,23 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/ln), patched to resolve path +# arguments against the shell working directory and route I/O + prompts through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/ln.rs for +# the patch markers (`pi-uutils:` comments). +[package] +name = "uu_ln" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "ln ~ (uutils) create a (file system) link to TARGET (vendored + patched for in-process embedding)" + +[lib] +path = "src/ln.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +thiserror = "2.0.3" +uucore = { version = "0.8.0", features = ["backup-control", "fs"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-ln/LICENSE b/crates/vendor/uu-ln/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-ln/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-ln/src/ln.rs b/crates/vendor/uu-ln/src/ln.rs new file mode 100644 index 000000000..7ee3d34e2 --- /dev/null +++ b/crates/vendor/uu-ln/src/ln.rs @@ -0,0 +1,860 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) srcpath targetpath EEXIST + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Every filesystem syscall resolves its path operand +// against the shell working directory via `pi_uutils_ctx::resolve` AT THE CALL +// SITE, while the original operands are kept for display/error messages (GNU +// prints operands as typed) — and, crucially, for the CONTENT of symbolic +// links, which stays exactly as typed like GNU ln (only the location where the +// link is created gets resolved). All process-global stdio and the `-i` prompt +// are routed through `pi_uutils_ctx`, `translate!` strings are literalized, and +// the entry point no longer calls `std::process::exit`. + +#[cfg(any(unix, target_os = "redox"))] +use std::os::unix::fs::symlink; +#[cfg(windows)] +use std::os::windows::fs::{symlink_dir, symlink_file}; +use std::{ + borrow::Cow, + collections::HashSet, + ffi::OsString, + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use thiserror::Error; +use uucore::{ + backup_control::{self, BackupMode}, + display::Quotable, + error::{FromIo, UError, UResult, USimpleError, strip_errno}, + fs::{ + MissingHandling, ResolveMode, canonicalize, make_path_relative_to, paths_refer_to_same_file, + }, +}; + +pub struct Settings { + overwrite: OverwriteMode, + backup: BackupMode, + suffix: OsString, + symbolic: bool, + relative: bool, + logical: bool, + target_dir: Option, + no_target_dir: bool, + no_dereference: bool, + verbose: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OverwriteMode { + NoClobber, + Interactive, + Force, +} + +// pi-uutils: the `translate!` message templates are literalized with the +// en-US strings from upstream's locales/en-US.ftl. +#[derive(Error, Debug)] +enum LnError { + #[error("target {} is not a directory", _0.quote())] + TargetIsNotADirectory(PathBuf), + + #[error("")] + SomeLinksFailed, + + #[error("{} and {} are the same file", _0.quote(), _1.quote())] + SameFile(PathBuf, PathBuf), + + #[error("missing destination file operand after {}", _0.quote())] + MissingDestination(PathBuf), + + #[error("extra operand {}\nTry '{} --help' for more information.", _0.quote(), _1)] + ExtraOperand(OsString, String), + + #[error("{}: hard link not allowed for directory", _0.to_string_lossy())] + FailedToCreateHardLinkDir(PathBuf), +} + +impl UError for LnError { + fn code(&self) -> i32 { + 1 + } +} + +mod options { + pub const FORCE: &str = "force"; + //pub const DIRECTORY: &str = "directory"; + pub const INTERACTIVE: &str = "interactive"; + pub const NO_DEREFERENCE: &str = "no-dereference"; + pub const SYMBOLIC: &str = "symbolic"; + pub const LOGICAL: &str = "logical"; + pub const PHYSICAL: &str = "physical"; + pub const TARGET_DIRECTORY: &str = "target-directory"; + pub const NO_TARGET_DIRECTORY: &str = "no-target-directory"; + pub const RELATIVE: &str = "relative"; + pub const VERBOSE: &str = "verbose"; +} + +static ARG_FILES: &str = "files"; + +/// pi-uutils: replacement for uucore's `show_error!` — writes the diagnostic +/// to the context stderr instead of the process-global one. Errors that render +/// to an empty message (e.g. [`LnError::SomeLinksFailed`]) print nothing +/// rather than a dangling "ln: " prefix. +fn show_error(msg: impl std::fmt::Display) { + let rendered = msg.to_string(); + if !rendered.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "ln: {rendered}"); + } +} + +/// pi-uutils: replacement for uucore's `read_yes`, reading from the context +/// stdin one byte at a time (no buffering) so consecutive prompts don't +/// over-read into a later prompt's input. Returns true when the first character +/// of the line is `y`/`Y`. +fn read_yes() -> bool { + use std::io::Read as _; + let mut stdin = pi_uutils_ctx::stdin(); + let mut buf = [0u8; 1]; + let mut first = None; + loop { + match stdin.read(&mut buf) { + Ok(0) => break, // EOF + Ok(_) => { + if buf[0] == b'\n' { + break; + } + if first.is_none() { + first = Some(buf[0]); + } + }, + Err(_) => return false, + } + } + matches!(first, Some(b'y' | b'Y')) +} + +/// pi-uutils: replacement for uucore's `prompt_yes!` — writes +/// "ln: \ " to the context stderr, then reads the answer from the +/// context stdin. +fn prompt_yes(prompt: impl std::fmt::Display) -> bool { + let mut err = pi_uutils_ctx::stderr(); + let _ = write!(err, "ln: {prompt} "); + let _ = err.flush(); + read_yes() +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match ln_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + // pi-uutils: `SomeLinksFailed` renders to an empty message + // (upstream prints the per-file diagnostics as it goes); don't + // emit a dangling "ln: " prefix for it. + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "ln: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn ln_main(matches: &ArgMatches) -> UResult<()> { + /* the list of files */ + + let paths: Vec = matches + .get_many::(ARG_FILES) + .unwrap() + .map(PathBuf::from) + .collect(); + + let symbolic = matches.get_flag(options::SYMBOLIC); + + let overwrite_mode = if matches.get_flag(options::FORCE) { + OverwriteMode::Force + } else if matches.get_flag(options::INTERACTIVE) { + OverwriteMode::Interactive + } else { + OverwriteMode::NoClobber + }; + + let backup_mode = backup_control::determine_backup_mode(matches)?; + let backup_suffix = backup_control::determine_backup_suffix(matches); + + // When we have "-L" or "-L -P", false otherwise + let logical = matches.get_flag(options::LOGICAL); + + let settings = Settings { + overwrite: overwrite_mode, + backup: backup_mode, + suffix: OsString::from(backup_suffix), + symbolic, + logical, + relative: matches.get_flag(options::RELATIVE), + target_dir: matches + .get_one::(options::TARGET_DIRECTORY) + .map(PathBuf::from), + no_target_dir: matches.get_flag(options::NO_TARGET_DIRECTORY), + no_dereference: matches.get_flag(options::NO_DEREFERENCE), + verbose: matches.get_flag(options::VERBOSE), + }; + + exec(&paths[..], &settings) +} + +pub fn uu_app() -> Command { + let after_help = format!( + "In the 1st form, create a link to TARGET with the name LINK_NAME.\nIn the 2nd form, create \ + a link to TARGET in the current directory.\nIn the 3rd and 4th forms, create links to each \ + TARGET in DIRECTORY.\nCreate hard links by default, symbolic links with --symbolic.\nBy \ + default, each destination (name of new link) should not already exist.\nWhen creating hard \ + links, each TARGET must exist. Symbolic links\ncan hold arbitrary text; if later resolved, \ + a relative link is\ninterpreted in relation to its parent directory.\n\n{}", + backup_control::BACKUP_CONTROL_LONG_HELP + ); + + Command::new("ln") + .version(uucore::crate_version!()) + .about("Make links between files.") + .override_usage(format_usage( + "ln [OPTION]... [-T] TARGET LINK_NAME\nln [OPTION]... TARGET\nln [OPTION]... TARGET... \ + DIRECTORY\nln [OPTION]... -t DIRECTORY TARGET...", + )) + .infer_long_args(true) + // pi-uutils: free the `-h` short for the BSD `--no-dereference` + // alias below; neither GNU nor BSD ln has `-h` help, and `--help` + // keeps working via the explicit long-only arg. + .disable_help_flag(true) + .arg( + Arg::new("help") + .long("help") + .help("Print help information") + .action(ArgAction::Help), + ) + .after_help(after_help) + .arg(backup_control::arguments::backup()) + .arg(backup_control::arguments::backup_no_args()) + /*.arg( + Arg::new(options::DIRECTORY) + .short('d') + .long(options::DIRECTORY) + .help("allow users with appropriate privileges to attempt to make hard links to directories") + )*/ + .arg( + Arg::new(options::FORCE) + .short('f') + .long(options::FORCE) + .help("remove existing destination files") + .overrides_with(options::INTERACTIVE) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::INTERACTIVE) + .short('i') + .long(options::INTERACTIVE) + .help("prompt whether to remove existing destination files") + .overrides_with(options::FORCE) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::NO_DEREFERENCE) + .short('n') + // pi-uutils: BSD/macOS ln spells this flag `-h` (`ln -sfh` is + // common macOS muscle memory); hidden alias, GNU help shape. + .short_alias('h') + .long(options::NO_DEREFERENCE) + .help("treat LINK_NAME as a normal file if it is a\nsymbolic link to a directory") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::LOGICAL) + .short('L') + .long(options::LOGICAL) + .help("follow TARGETs that are symbolic links") + .overrides_with(options::PHYSICAL) + .action(ArgAction::SetTrue), + ) + .arg( + // Not implemented yet + Arg::new(options::PHYSICAL) + .short('P') + .long(options::PHYSICAL) + .help("make hard links directly to symbolic links") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::SYMBOLIC) + .short('s') + .long(options::SYMBOLIC) + .help("make symbolic links instead of hard links") + // override added for https://github.com/uutils/coreutils/issues/2359 + .overrides_with(options::SYMBOLIC) + .action(ArgAction::SetTrue), + ) + .arg(backup_control::arguments::suffix()) + .arg( + Arg::new(options::TARGET_DIRECTORY) + .short('t') + .long(options::TARGET_DIRECTORY) + .help("specify the DIRECTORY in which to create the links") + .value_name("DIRECTORY") + .value_hint(clap::ValueHint::DirPath) + .value_parser(clap::value_parser!(OsString)) + .conflicts_with(options::NO_TARGET_DIRECTORY), + ) + .arg( + Arg::new(options::NO_TARGET_DIRECTORY) + .short('T') + .long(options::NO_TARGET_DIRECTORY) + .help("treat LINK_NAME as a normal file always") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::RELATIVE) + .short('r') + .long(options::RELATIVE) + .help("create symbolic links relative to link location") + .requires(options::SYMBOLIC) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::VERBOSE) + .short('v') + .long(options::VERBOSE) + .help("print name of each linked file") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(ARG_FILES) + .action(ArgAction::Append) + .value_hint(clap::ValueHint::AnyPath) + .value_parser(clap::value_parser!(OsString)) + .required(true) + .num_args(1..), + ) +} + +fn exec(files: &[PathBuf], settings: &Settings) -> UResult<()> { + // Handle cases where we create links in a directory first. + if let Some(target_path) = &settings.target_dir { + // 4th form: a directory is specified by -t. + return link_files_in_dir(files, target_path, settings); + } + if !settings.no_target_dir { + if files.len() == 1 { + // 2nd form: the target directory is the current directory. + return link_files_in_dir(files, &PathBuf::from("."), settings); + } + let last_file = &PathBuf::from(files.last().unwrap()); + // pi-uutils: probe the destination via the resolved path. + if files.len() > 2 || pi_uutils_ctx::resolve(last_file).is_dir() { + // 3rd form: create links in the last argument. + return link_files_in_dir(&files[0..files.len() - 1], last_file, settings); + } + } + + // 1st form. Now there should be only two operands, but if -T is + // specified we may have a wrong number of operands. + if files.len() == 1 { + return Err(LnError::MissingDestination(files[0].clone()).into()); + } + if files.len() > 2 { + // pi-uutils: `uucore::execution_phrase()` reads the process argv, + // which is the host shell's; the builtin is always invoked as "ln". + return Err(LnError::ExtraOperand(files[2].clone().into(), "ln".to_string()).into()); + } + assert!(!files.is_empty()); + + link(&files[0], &files[1], settings) +} + +#[allow(clippy::cognitive_complexity)] +fn link_files_in_dir(files: &[PathBuf], target_dir: &Path, settings: &Settings) -> UResult<()> { + // pi-uutils: resolved target directory for every syscall below; the + // operand keeps its as-typed spelling for display and link-name building. + let target_dir_fs = pi_uutils_ctx::resolve(target_dir); + if !target_dir_fs.is_dir() { + return Err(LnError::TargetIsNotADirectory(target_dir.to_owned()).into()); + } + // remember the linked destinations for further usage + let mut linked_destinations: HashSet = HashSet::with_capacity(files.len()); + + let mut all_successful = true; + for srcpath in files { + let targetpath = if settings.no_dereference && target_dir_fs.is_symlink() { + let remove_target = || { + // In that case, we don't want to do link resolution + // We need to clean the target + if target_dir_fs.is_file() + && let Err(e) = fs::remove_file(&target_dir_fs) + { + show_error(format_args!("Could not update {}: {e}", target_dir.quote())); + } + #[cfg(windows)] + if target_dir_fs.is_dir() { + // Not sure why but on Windows, the symlink can be + // considered as a dir + // See test_ln::test_symlink_no_deref_dir + if let Err(e) = fs::remove_dir(&target_dir_fs) { + show_error(format_args!("Could not update {}: {e}", target_dir.quote())); + } + } + }; + match settings.overwrite { + OverwriteMode::NoClobber => {}, + OverwriteMode::Interactive => { + if prompt_yes(format_args!("replace {}?", target_dir.quote())) { + remove_target(); + } + }, + OverwriteMode::Force => { + remove_target(); + }, + } + target_dir.to_path_buf() + } else if let Some(name) = srcpath.as_os_str().to_str() { + match Path::new(name).file_name() { + Some(basename) => target_dir.join(basename), + // This can be None only for "." or "..". Trying + // to create a link with such name will fail with + // EEXIST, which agrees with the behavior of GNU + // coreutils. + None => target_dir.join(name), + } + } else { + show_error(format_args!("cannot stat {}: No such file or directory", srcpath.quote())); + all_successful = false; + continue; + }; + + if linked_destinations.contains(&targetpath) { + // If the target file was already created in this ln call, do not overwrite + show_error(format_args!( + "will not overwrite just-created {} with {}", + targetpath.quote(), + srcpath.quote() + )); + all_successful = false; + } else if let Err(e) = link(srcpath, &targetpath, settings) { + show_error(format_args!("{e}")); + all_successful = false; + } + + linked_destinations.insert(targetpath.clone()); + } + if all_successful { + Ok(()) + } else { + Err(LnError::SomeLinksFailed.into()) + } +} + +fn relative_path<'a>(src: &'a Path, dst: &Path) -> Cow<'a, Path> { + // pi-uutils: canonicalize from the resolved operands so `-r` computes the + // link text against the shell working directory (uucore's canonicalize + // would otherwise fall back to the process cwd for relative paths). + if let Ok(src_abs) = + canonicalize(pi_uutils_ctx::resolve(src), MissingHandling::Missing, ResolveMode::Physical) + && let Ok(dst_abs) = canonicalize( + pi_uutils_ctx::resolve(dst.parent().unwrap()), + MissingHandling::Missing, + ResolveMode::Physical, + ) { + return make_path_relative_to(src_abs, dst_abs).into(); + } + src.into() +} + +#[allow(clippy::cognitive_complexity)] +fn link(src: &Path, dst: &Path, settings: &Settings) -> UResult<()> { + let mut backup_path = None; + let source: Cow<'_, Path> = if settings.relative { + relative_path(src, dst) + } else { + src.into() + }; + + // pi-uutils: resolved counterparts of both operands for every filesystem + // syscall below. `src`/`dst`/`source` keep the as-typed spelling for + // display — and `source` is what gets stored as the symlink CONTENT, so it + // must never be resolved. + let src_fs = pi_uutils_ctx::resolve(src); + let dst_fs = pi_uutils_ctx::resolve(dst); + + if dst_fs.is_symlink() || dst_fs.exists() { + // pi-uutils: probe numbered backups from the resolved destination so + // the directory scan hits the shell's working directory. + backup_path = backup_control::get_backup_path(settings.backup, &dst_fs, &settings.suffix); + if settings.backup == BackupMode::Existing && !settings.symbolic { + // when ln --backup f f, it should detect that it is the same file + if paths_refer_to_same_file(&src_fs, &dst_fs, true) { + return Err(LnError::SameFile(src.to_owned(), dst.to_owned()).into()); + } + } + if let Some(p) = &backup_path { + fs::rename(&dst_fs, p).map_err_context(|| format!("cannot backup {}", dst.quote()))?; + } + match settings.overwrite { + OverwriteMode::NoClobber => {}, + OverwriteMode::Interactive => { + if !prompt_yes(format_args!("replace {}?", dst.quote())) { + return Err(LnError::SomeLinksFailed.into()); + } + + let _ = fs::remove_file(&dst_fs); + // In case of error, don't do anything + }, + OverwriteMode::Force => { + if !dst_fs.is_symlink() && paths_refer_to_same_file(&src_fs, &dst_fs, true) { + // Even in force overwrite mode, verify we are not targeting the same entry and + // return a SameFile error if so + let same_entry = match ( + canonicalize(&src_fs, MissingHandling::Missing, ResolveMode::Physical), + canonicalize(&dst_fs, MissingHandling::Missing, ResolveMode::Physical), + ) { + (Ok(src), Ok(dst)) => src == dst, + _ => true, + }; + if same_entry { + return Err(LnError::SameFile(src.to_owned(), dst.to_owned()).into()); + } + } + let _ = fs::remove_file(&dst_fs); + // In case of error, don't do anything + }, + } + } + + let res: UResult<()> = if settings.symbolic { + // pi-uutils: the link is created at the resolved location, but its + // content (`source`) stays exactly as typed, like GNU ln. uucore's + // io-error conversion renders EEXIST as "Already exists"; format the + // GNU-style diagnostic ("failed to create symbolic link 'x': File + // exists") from the raw OS error instead. + symlink(&source, &dst_fs).map_err(|e| { + USimpleError::new( + 1, + format!("failed to create symbolic link {}: {}", dst.quote(), strip_errno(&e)), + ) + }) + } else { + // pi-uutils: hard links dereference their target, so the resolved + // source is what the syscalls get. + let source_fs = pi_uutils_ctx::resolve(&source); + let p = if settings.logical && source_fs.is_symlink() { + fs::canonicalize(&source_fs) + .map_err_context(|| format!("failed to access {}", source.quote()))? + } else { + source_fs + }; + match fs::hard_link(&p, &dst_fs) { + Ok(()) => Ok(()), + Err(_) if p.is_dir() => { + Err(LnError::FailedToCreateHardLinkDir(source.to_path_buf()).into()) + }, + // pi-uutils: same GNU-style rendering as the symlink arm (uucore + // would print "Already exists" for EEXIST). + Err(e) => Err(USimpleError::new( + 1, + format!( + "failed to create hard link {} => {}: {}", + source.quote(), + dst.quote(), + strip_errno(&e) + ), + )), + } + }; + + if let Err(e) = res { + if let Some(p) = &backup_path { + fs::rename(p, &dst_fs).map_err_context(|| format!("cannot backup {}", dst.quote()))?; + } + return Err(e); + } + + if settings.verbose { + // pi-uutils: verbose output goes to the context stdout. + let mut out = pi_uutils_ctx::stdout(); + write!(out, "{} -> {}", dst.quote(), source.quote())?; + match backup_path { + Some(path) => { + // pi-uutils: `path` derives from the resolved (absolute) + // destination; rebuild a display path from the operand for + // the verbose message. + let backup_display = match (dst.parent(), path.file_name()) { + (Some(parent), Some(name)) if !parent.as_os_str().is_empty() => parent.join(name), + (_, Some(name)) => PathBuf::from(name), + _ => path.clone(), + }; + writeln!(out, " (backup: {})", backup_display.quote())?; + }, + None => writeln!(out)?, + } + } + Ok(()) +} + +#[cfg(windows)] +pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> std::io::Result<()> { + // pi-uutils: the dir/file probe resolves the target against the shell + // working directory (upstream consults the process cwd); the stored link + // content is still the caller's as-typed `src`. + if pi_uutils_ctx::resolve(src.as_ref()).is_dir() { + symlink_dir(src, dst) + } else { + symlink_file(src, dst) + } +} + +#[cfg(target_os = "wasi")] +fn symlink, P2: AsRef>(_src: P1, _dst: P2) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "symlinks not supported on this platform", + )) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_with_stdin(cwd: PathBuf, args: Vec<&str>, stdin: &[u8]) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::Cursor::new(stdin.to_vec())), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("ln") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + run_with_stdin(cwd, args, b"") + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var, + /// which canonicalizing code paths would otherwise expand mid-assertion). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[cfg(unix)] + #[test] + fn symlink_relative_operands_create_in_scope_cwd_with_literal_content() { + let (_dir, root) = canonical_tempdir(); + + // Relative operands + scope cwd differing from the process cwd: only + // the call-site `pi_uutils_ctx::resolve` patch places the link in the + // tempdir — while the CONTENT must stay exactly as typed. + let (code, stdout, stderr) = run_in(root.clone(), vec!["-s", "target", "link"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + + let link = root.join("link"); + assert!(link.is_symlink(), "link must be created inside the scope cwd"); + assert_eq!(fs::read_link(&link).unwrap(), PathBuf::from("target")); + } + + #[cfg(unix)] + #[test] + fn bsd_dash_h_replaces_symlink_to_directory() { + let (_dir, root) = canonical_tempdir(); + fs::create_dir(root.join("dir_a")).unwrap(); + fs::create_dir(root.join("dir_b")).unwrap(); + std::os::unix::fs::symlink("dir_a", root.join("cur")).unwrap(); + + // macOS `ln -sfh`: BSD spells `--no-dereference` as `-h`. Without it, + // `cur` dereferences to `dir_a` and the link lands *inside* it. + let (code, stdout, stderr) = run_in(root.clone(), vec!["-sfh", "dir_b", "cur"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + assert_eq!(fs::read_link(root.join("cur")).unwrap(), PathBuf::from("dir_b")); + assert!(!root.join("dir_a").join("dir_b").exists(), "must not link inside the target dir"); + } + + #[cfg(unix)] + #[test] + fn hard_link_shares_inode() { + use std::os::unix::fs::MetadataExt; + + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("a"), b"payload").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["a", "b"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + + assert_eq!(fs::read(root.join("b")).unwrap(), b"payload"); + assert_eq!(fs::metadata(root.join("a")).unwrap().nlink(), 2); + assert_eq!( + fs::metadata(root.join("a")).unwrap().ino(), + fs::metadata(root.join("b")).unwrap().ino() + ); + } + + #[cfg(unix)] + #[test] + fn existing_destination_without_force_fails_with_file_exists() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("link"), b"old").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-s", "target", "link"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "ln: failed to create symbolic link 'link': File exists\n"); + assert_eq!(fs::read(root.join("link")).unwrap(), b"old", "destination must be untouched"); + } + + #[cfg(unix)] + #[test] + fn force_overwrites_existing_destination() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("link"), b"old").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-sf", "target", "link"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + assert_eq!(fs::read_link(root.join("link")).unwrap(), PathBuf::from("target")); + } + + #[cfg(unix)] + #[test] + fn verbose_symlink_prints_mapping_to_stdout() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, vec!["-sv", "target", "link"]); + assert_eq!(code, 0); + assert_eq!(stdout, "'link' -> 'target'\n"); + assert_eq!(stderr, ""); + } + + #[cfg(unix)] + #[test] + fn interactive_prompt_reads_ctx_stdin() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("link"), b"old").unwrap(); + + // Decline: destination untouched, some-links-failed exit code, no + // dangling "ln: " diagnostic beyond the prompt itself. + let (code, stdout, stderr) = + run_with_stdin(root.clone(), vec!["-si", "target", "link"], b"n\n"); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "ln: replace 'link'? "); + assert!(!root.join("link").is_symlink()); + + // Accept: existing file is replaced by the symlink. + let (code, _, stderr) = run_with_stdin(root.clone(), vec!["-si", "target", "link"], b"y\n"); + assert_eq!(code, 0); + assert_eq!(stderr, "ln: replace 'link'? "); + assert_eq!(fs::read_link(root.join("link")).unwrap(), PathBuf::from("target")); + } + + #[cfg(unix)] + #[test] + fn relative_flag_computes_link_text_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("target"), b"x").unwrap(); + fs::create_dir(root.join("sub")).unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-sr", "target", "sub/link"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + assert_eq!(fs::read_link(root.join("sub").join("link")).unwrap(), PathBuf::from("../target")); + } + + #[cfg(unix)] + #[test] + fn target_directory_flag_places_links_in_directory() { + let (_dir, root) = canonical_tempdir(); + fs::create_dir(root.join("d")).unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-s", "-t", "d", "x"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + assert_eq!(fs::read_link(root.join("d").join("x")).unwrap(), PathBuf::from("x")); + } + + #[test] + fn missing_destination_is_an_error() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, vec!["-T", "only"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!( + stderr.contains("missing destination file operand after 'only'"), + "stderr was: {stderr:?}" + ); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("Make links between files.")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-mktemp/Cargo.toml b/crates/vendor/uu-mktemp/Cargo.toml new file mode 100644 index 000000000..dae4ba329 --- /dev/null +++ b/crates/vendor/uu-mktemp/Cargo.toml @@ -0,0 +1,24 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/mktemp), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/mktemp.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_mktemp" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "mktemp ~ (uutils) create and display a temporary file or directory from TEMPLATE (vendored + patched for in-process embedding)" + +[lib] +path = "src/mktemp.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +rand = { version = "0.10.0", features = ["std_rng"] } +tempfile = "3.15.0" +thiserror = "2.0.3" +uucore = "0.8.0" +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-mktemp/LICENSE b/crates/vendor/uu-mktemp/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-mktemp/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-mktemp/src/mktemp.rs b/crates/vendor/uu-mktemp/src/mktemp.rs new file mode 100644 index 000000000..53c0670b3 --- /dev/null +++ b/crates/vendor/uu-mktemp/src/mktemp.rs @@ -0,0 +1,941 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (paths) GPGHome findxs + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. The temp-file parent directory (from `-p`/`--tmpdir` or a +// relative TEMPLATE prefix) is resolved against the shell working directory via +// `pi_uutils_ctx::resolve` at the creation sites, so the printed path is the +// path actually created. TMPDIR/POSIXLY_CORRECT are read from the scope +// environment, all stdio goes through `pi_uutils_ctx`, `translate!` strings are +// literalized, and the entry point no longer calls `std::process::exit`. + +#[cfg(unix)] +use std::fs; +#[cfg(unix)] +use std::os::unix::prelude::PermissionsExt; +use std::{ + env, + ffi::{OsStr, OsString}, + io::{ErrorKind, Write}, + iter, + path::{MAIN_SEPARATOR, Path, PathBuf}, +}; + +use clap::{ + Arg, ArgAction, ArgMatches, Command, + builder::{TypedValueParser, ValueParserFactory}, +}; +use pi_uutils_ctx::format_usage; +use rand::{ + RngExt as _, SeedableRng as _, + rngs::{self, SmallRng}, +}; +use tempfile::Builder; +use thiserror::Error; +use uucore::{ + display::Quotable, + error::{FromIo, UError, UResult}, +}; + +static DEFAULT_TEMPLATE: &str = "tmp.XXXXXXXXXX"; + +static OPT_DIRECTORY: &str = "directory"; +static OPT_DRY_RUN: &str = "dry-run"; +static OPT_QUIET: &str = "quiet"; +static OPT_SUFFIX: &str = "suffix"; +static OPT_TMPDIR: &str = "tmpdir"; +static OPT_P: &str = "p"; +static OPT_T: &str = "t"; + +static ARG_TEMPLATE: &str = "template"; + +#[cfg(not(windows))] +const TMPDIR_ENV_VAR: &str = "TMPDIR"; +#[cfg(windows)] +const TMPDIR_ENV_VAR: &str = "TMP"; + +const FALLBACK_TMPDIR: &str = "/tmp"; + +// pi-uutils: `translate!` error strings literalized from locales/en-US.ftl. +#[derive(Error, Debug)] +enum MkTempError { + #[error("could not persist file {}", .0.quote())] + PersistError(PathBuf), + + #[error("with --suffix, template {} must end in X", .0.quote())] + MustEndInX(String), + + #[error("too few X's in template {}", .0.quote())] + TooFewXs(String), + + #[error("invalid template, {}, contains directory separator", .0.quote())] + PrefixContainsDirSeparator(String), + + #[error("invalid suffix {}, contains directory separator", .0.quote())] + SuffixContainsDirSeparator(String), + + #[error("invalid template, {}; with --tmpdir, it may not be absolute", .0.quote())] + InvalidTemplate(OsString), + + #[error("too many templates")] + TooManyTemplates, + + #[error("failed to create {} via template {}: No such file or directory", .0, .1.quote())] + NotFound(String, PathBuf), +} + +impl UError for MkTempError { + fn usage(&self) -> bool { + matches!(self, Self::TooManyTemplates) + } +} + +/// Options parsed from the command-line. +/// +/// This provides a layer of indirection between the application logic +/// and the argument parsing library `clap`, allowing each to vary +/// independently. +#[derive(Clone)] +pub struct Options { + /// Whether to create a temporary directory instead of a file. + pub directory: bool, + + /// Whether to just print the name of a file that would have been created. + pub dry_run: bool, + + /// Whether to suppress file creation error messages. + pub quiet: bool, + + /// The directory in which to create the temporary file. + /// + /// If `None`, the file will be created in the current directory. + pub tmpdir: Option, + + /// The suffix to append to the temporary file, if any. + pub suffix: Option, + + /// Whether to treat the template argument as a single file path component. + pub treat_as_template: bool, + + /// The template to use for the name of the temporary file. + pub template: OsString, +} + +impl Options { + fn from(matches: &ArgMatches) -> Self { + let tmpdir = matches + .get_one::>(OPT_TMPDIR) + .or_else(|| matches.get_one::>(OPT_P)) + .map(|dir| match dir { + // If the argument of -p/--tmpdir is non-empty, use it as the + // tmpdir. + Some(d) => d.clone(), + // Otherwise use $TMPDIR if set, else use the system's default + // temporary directory. + None => get_tmpdir_env_or_default(), + }); + let (tmpdir, template) = match matches.get_one::(ARG_TEMPLATE) { + // If no template argument is given, `--tmpdir` is implied. + None => { + let tmpdir = Some(tmpdir.unwrap_or_else(get_tmpdir_env_or_default)); + let template = DEFAULT_TEMPLATE; + (tmpdir, OsString::from(template)) + }, + Some(template) => { + // pi-uutils: TMPDIR comes from the scope environment, not the + // host process environment. + let tmpdir = if let Some(tmpdir) = pi_uutils_ctx::var(TMPDIR_ENV_VAR) + && matches.get_flag(OPT_T) + { + Some(PathBuf::from(tmpdir)) + } else if tmpdir.is_some() { + tmpdir + } else if matches.get_flag(OPT_T) || matches.contains_id(OPT_TMPDIR) { + // If --tmpdir is given without an argument, or -t is given + // export in TMPDIR + Some(env::temp_dir()) + } else { + None + }; + (tmpdir, template.clone()) + }, + }; + Self { + directory: matches.get_flag(OPT_DIRECTORY), + dry_run: matches.get_flag(OPT_DRY_RUN), + quiet: matches.get_flag(OPT_QUIET), + tmpdir, + suffix: matches.get_one::(OPT_SUFFIX).cloned(), + treat_as_template: matches.get_flag(OPT_T), + template, + } + } +} + +/// Parameters that control the path to and name of the temporary file. +/// +/// The temporary file will be created at +/// +/// ```text +/// {directory}/{prefix}{XXX}{suffix} +/// ``` +/// +/// where `{XXX}` is a sequence of random characters whose length is +/// `num_rand_chars`. +struct Params { + /// The directory that will contain the temporary file. + directory: PathBuf, + + /// The (non-random) prefix of the temporary file. + prefix: String, + + /// The number of random characters in the name of the temporary file. + num_rand_chars: usize, + + /// The (non-random) suffix of the temporary file. + suffix: String, +} + +/// Find the start and end indices of the last contiguous block of Xs. +/// +/// If no contiguous block of at least three Xs could be found, this +/// function returns `None`. +/// +/// # Examples +/// +/// ```rust,ignore +/// assert_eq!(find_last_contiguous_block_of_xs("XXX_XXX"), Some((4, 7))); +/// assert_eq!(find_last_contiguous_block_of_xs("aXbXcX"), None); +/// ``` +fn find_last_contiguous_block_of_xs(s: &str) -> Option<(usize, usize)> { + let bytes = s.as_bytes(); + + // Find the index of the last 'X'. + let end = bytes.iter().rposition(|&b| b == b'X')?; + + // Walk left to find the start of the run of Xs that ends at `end`. + let mut start = end; + while start > 0 && bytes[start - 1] == b'X' { + start -= 1; + } + + if end + 1 - start >= 3 { + Some((start, end + 1)) + } else { + None + } +} + +impl Params { + fn from(options: Options) -> Result { + // Convert OsString template to string for processing + // When using -t flag, be permissive with invalid UTF-8 like GNU mktemp + // Otherwise, maintain strict UTF-8 validation (existing behavior) + let mut template_str = if options.treat_as_template { + // For -t templates, use lossy conversion for GNU compatibility + options.template.to_string_lossy().into_owned() + } else { + // For regular templates, maintain strict validation + match options.template.to_str() { + Some(s) => s.to_string(), + None => { + return Err(MkTempError::InvalidTemplate("template contains invalid UTF-8".into())); + }, + } + }; + + // The template argument must end in 'X' if a suffix option is given. + if options.suffix.is_some() && !template_str.ends_with('X') { + return Err(MkTempError::MustEndInX(template_str.clone())); + } + + // Get the start and end indices of the randomized part of the template. + // + // For example, if the template is "abcXXXXyz", then `i` is 3 and `j` is 7. + let (i, j) = match find_last_contiguous_block_of_xs(&template_str) { + Some(indices) => indices, + // pi-uutils: BSD `mktemp -t PREFIX` treats PREFIX as a name prefix, + // unlike GNU `-t`, which requires Xs and would otherwise fail here. + None if options.treat_as_template => { + template_str.push('.'); + template_str.push_str("XXXXXXXXXX"); + let j = template_str.len(); + (j - 10, j) + }, + None => { + let s = match options.suffix { + // If a suffix is specified, the error message includes the template without the + // suffix. + Some(_) => template_str + .chars() + .take(template_str.len()) + .collect::(), + None => template_str.clone(), + }; + return Err(MkTempError::TooFewXs(s)); + }, + }; + + // Combine the directory given as an option and the prefix of the template. + // + // For example, if `tmpdir` is "a/b" and the template is "c/dXXX", + // then `prefix` is "a/b/c/d". + let tmpdir = options.tmpdir; + let prefix_from_option = tmpdir.clone().unwrap_or_default(); + let prefix_from_template = &template_str[..i]; + let prefix_path = Path::new(&prefix_from_option).join(prefix_from_template); + if options.treat_as_template && prefix_from_template.contains(MAIN_SEPARATOR) { + return Err(MkTempError::PrefixContainsDirSeparator(template_str.clone())); + } + if tmpdir.is_some() && Path::new(prefix_from_template).is_absolute() { + return Err(MkTempError::InvalidTemplate(template_str.clone().into())); + } + + // Split the parent directory from the file part of the prefix. + // + // For example, if `prefix_path` is "a/b/c/d", then `directory` is + // "a/b/c" and `prefix` gets reassigned to "d". + let (directory, prefix) = { + let prefix_str = prefix_path.to_string_lossy(); + if prefix_str.ends_with(MAIN_SEPARATOR) { + (prefix_path, String::new()) + } else { + let directory = match prefix_path.parent() { + None => PathBuf::new(), + Some(d) => d.to_path_buf(), + }; + let prefix = match prefix_path.file_name() { + None => String::new(), + Some(f) => f.to_string_lossy().to_string(), + }; + (directory, prefix) + } + }; + + // Combine the suffix from the template with the suffix given as an option. + // + // For example, if the suffix command-line argument is ".txt" and + // the template is "XXXabc", then `suffix` is "abc.txt". + let suffix_from_option = options + .suffix + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let suffix_from_template = &template_str[j..]; + let suffix = format!("{suffix_from_template}{suffix_from_option}"); + if suffix.contains(MAIN_SEPARATOR) { + return Err(MkTempError::SuffixContainsDirSeparator(suffix)); + } + + // The number of random characters in the template. + // + // For example, if the template is "abcXXXXyz", then the number of + // random characters is four. + let num_rand_chars = j - i; + + Ok(Self { directory, prefix, num_rand_chars, suffix }) + } +} + +/// Custom parser that converts empty string to `None`, and non-empty string to +/// `Some(PathBuf)`. +/// +/// This parser is used for the `-p` and `--tmpdir` options where an empty +/// string argument should be treated as "not provided", causing mktemp to fall +/// back to using the `$TMPDIR` environment variable or the system's default +/// temporary directory. +/// +/// # Examples +/// +/// - Empty string `""` -> `None` +/// - Non-empty string `"/tmp"` -> `Some(PathBuf::from("/tmp"))` +/// +/// This handles the special case where users can pass an empty directory name +/// to explicitly request fallback behavior. +#[derive(Clone, Debug)] +struct OptionalPathBufParser; + +impl TypedValueParser for OptionalPathBufParser { + type Value = Option; + + fn parse_ref( + &self, + _cmd: &Command, + _arg: Option<&Arg>, + value: &OsStr, + ) -> Result { + if value.is_empty() { + Ok(None) + } else { + Ok(Some(PathBuf::from(value))) + } + } +} + +impl ValueParserFactory for OptionalPathBufParser { + type Parser = Self; + + fn value_parser() -> Self::Parser { + Self + } +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(&argv) { + Ok(matches) => matches, + Err(err) => { + // pi-uutils: upstream maps a too-many-values clap error on the + // TEMPLATE argument to the GNU "too many templates" usage error. + if err.kind() == clap::error::ErrorKind::TooManyValues + && err.context().any(|(kind, val)| { + kind == clap::error::ContextKind::InvalidArg + && val == &clap::error::ContextValue::String("[template]".into()) + }) { + let _ = writeln!(pi_uutils_ctx::stderr(), "mktemp: too many templates"); + return 1; + } + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match mktemp_main(&argv, &matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + // pi-uutils: --quiet failures surface as bare exit-code errors + // that render to an empty message; don't emit a dangling + // "mktemp: " prefix. + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "mktemp: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn mktemp_main(args: &[OsString], matches: &ArgMatches) -> UResult<()> { + // Parse command-line options into a format suitable for the + // application logic. + let options = Options::from(matches); + + // pi-uutils: POSIXLY_CORRECT comes from the scope environment. + if pi_uutils_ctx::var("POSIXLY_CORRECT").is_some() { + // If POSIXLY_CORRECT was set, template MUST be the last argument. + if matches.contains_id(ARG_TEMPLATE) { + // Template argument was provided, check if was the last one. + if args.last().unwrap() != &options.template { + return Err(Box::new(MkTempError::TooManyTemplates)); + } + } + } + + let dry_run = options.dry_run; + let suppress_file_err = options.quiet; + let make_dir = options.directory; + + // Parse file path parameters from the command-line options. + let Params { directory: tmpdir, prefix, num_rand_chars: rand, suffix } = Params::from(options)?; + + // Create the temporary file or directory, or simulate creating it. + let res = if dry_run { + Ok(dry_exec(&tmpdir, &prefix, rand, &suffix)) + } else { + exec(&tmpdir, &prefix, rand, &suffix, make_dir) + }; + + let res = if suppress_file_err { + // Mapping all UErrors to ExitCodes prevents the errors from being printed + res.map_err(|e| e.code().into()) + } else { + res + }; + + // pi-uutils: replacement for upstream's `println_verbatim` — writes the + // created path's bytes verbatim to the context stdout instead of the + // process stdout. + let path = res?; + let print = || -> std::io::Result<()> { + let mut out = pi_uutils_ctx::stdout(); + out.write_all(uucore::os_str_as_bytes(path.as_os_str()).map_err(std::io::Error::other)?)?; + out.write_all(b"\n")?; + out.flush() + }; + print().map_err_context(|| "failed to print directory name".to_string())?; + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("mktemp") + .version(uucore::crate_version!()) + .about("Create a temporary file or directory.") + .override_usage(format_usage("mktemp [OPTION]... [TEMPLATE]")) + .infer_long_args(true) + .arg( + Arg::new(OPT_DIRECTORY) + .short('d') + .long(OPT_DIRECTORY) + .help("Make a directory instead of a file") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_DRY_RUN) + .short('u') + .long(OPT_DRY_RUN) + .help("do not create anything; merely print a name (unsafe)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_QUIET) + .short('q') + .long("quiet") + .help("Fail silently if an error occurs.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_SUFFIX) + .long(OPT_SUFFIX) + .help( + "append SUFFIX to TEMPLATE; SUFFIX must not contain a path separator. This option \ + is implied if TEMPLATE does not end with X.", + ) + .value_name("SUFFIX") + .value_parser(clap::value_parser!(OsString)), + ) + .arg( + Arg::new(OPT_P) + .short('p') + .help("short form of --tmpdir") + .value_name("DIR") + .num_args(1) + .value_parser(OptionalPathBufParser) + .value_hint(clap::ValueHint::DirPath), + ) + .arg( + Arg::new(OPT_TMPDIR) + .long(OPT_TMPDIR) + .help( + "interpret TEMPLATE relative to DIR; if DIR is not specified, use $TMPDIR ($TMP on \ + windows) if set, else /tmp. With this option, TEMPLATE must not be an absolute \ + name; unlike with -t, TEMPLATE may contain slashes, but mktemp creates only the \ + final component", + ) + .value_name("DIR") + // Allows use of default argument just by setting --tmpdir. Else, + // use provided input to generate tmpdir + .num_args(0..=1) + // Require an equals to avoid ambiguity if no tmpdir is supplied + .require_equals(true) + .overrides_with(OPT_P) + .value_parser(OptionalPathBufParser) + .value_hint(clap::ValueHint::DirPath), + ) + .arg( + Arg::new(OPT_T) + .short('t') + .help( + "Generate a template (using the supplied prefix and TMPDIR (TMP on windows) if \ + set) to create a filename template [deprecated]", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(ARG_TEMPLATE) + .num_args(..=1) + .value_parser(clap::value_parser!(OsString)), + ) +} + +fn dry_exec(tmpdir: &Path, prefix: &str, rand: usize, suffix: &str) -> PathBuf { + // pi-uutils: resolve the parent directory against the shell working + // directory so the printed candidate matches where creation would occur. + let tmpdir = pi_uutils_ctx::resolve(tmpdir); + let len = prefix.len() + suffix.len() + rand; + let mut buf = Vec::with_capacity(len); + buf.extend(prefix.as_bytes()); + buf.extend(iter::repeat_n(b'X', rand)); + buf.extend(suffix.as_bytes()); + + // Randomize. + let bytes = &mut buf[prefix.len()..prefix.len() + rand]; + SmallRng::try_from_rng(&mut rngs::SysRng) + .unwrap_or_else(|_| { + //rand::rng panics if getrandom failed + SmallRng::seed_from_u64(bytes.as_ptr() as usize as u64) + }) + .fill(bytes); + for byte in bytes { + *byte = match *byte % 62 { + v @ 0..=9 => v + b'0', + v @ 10..=35 => v - 10 + b'a', + v @ 36..=61 => v - 36 + b'A', + _ => unreachable!(), + } + } + // We guarantee utf8. + let buf = String::from_utf8(buf).unwrap(); + tmpdir.join(buf) +} + +/// Create a temporary directory with the given parameters. +/// +/// This function creates a temporary directory as a subdirectory of +/// `dir`. The name of the directory is the concatenation of `prefix`, +/// a string of `rand` random characters, and `suffix`. The +/// permissions of the directory are set to `u+rwx` +/// +/// # Errors +/// +/// If the temporary directory could not be written to disk or if the +/// given directory `dir` does not exist. +fn make_temp_dir(dir: &Path, prefix: &str, rand: usize, suffix: &str) -> UResult { + let mut builder = Builder::new(); + builder.prefix(prefix).rand_bytes(rand).suffix(suffix); + + // On *nix platforms grant read-write-execute for owner only. + // The directory is created with these permission at creation time, using + // mkdir(3) syscall. This is not relevant on Windows systems. See: https://docs.rs/tempfile/latest/tempfile/#security + // `fs` is not imported on Windows anyways. + #[cfg(not(windows))] + builder.permissions(fs::Permissions::from_mode(0o700)); + + match builder.tempdir_in(dir) { + Ok(d) => { + // `keep` consumes the TempDir without removing it + let path = d.keep(); + Ok(path) + }, + Err(e) if e.kind() == ErrorKind::NotFound => { + let filename = format!("{prefix}{}{suffix}", "X".repeat(rand)); + let path = Path::new(dir).join(filename); + Err(MkTempError::NotFound("directory".to_string(), path).into()) + }, + Err(e) => Err(e.into()), + } +} + +/// Create a temporary file with the given parameters. +/// +/// This function creates a temporary file in the directory `dir`. The +/// name of the file is the concatenation of `prefix`, a string of +/// `rand` random characters, and `suffix`. The permissions of the +/// file are set to `u+rw`. +/// +/// # Errors +/// +/// If the file could not be written to disk or if the directory does +/// not exist. +fn make_temp_file(dir: &Path, prefix: &str, rand: usize, suffix: &str) -> UResult { + let mut builder = Builder::new(); + builder.prefix(prefix).rand_bytes(rand).suffix(suffix); + match builder.tempfile_in(dir) { + // `keep` ensures that the file is not deleted + Ok(named_tempfile) => match named_tempfile.keep() { + Ok((_, pathbuf)) => Ok(pathbuf), + Err(e) => Err(MkTempError::PersistError(e.file.path().to_path_buf()).into()), + }, + Err(e) if e.kind() == ErrorKind::NotFound => { + let filename = format!("{prefix}{}{suffix}", "X".repeat(rand)); + let path = Path::new(dir).join(filename); + Err(MkTempError::NotFound("file".to_string(), path).into()) + }, + Err(e) => Err(e.into()), + } +} + +fn exec(dir: &Path, prefix: &str, rand: usize, suffix: &str, make_dir: bool) -> UResult { + // pi-uutils: resolve the parent directory against the shell working + // directory at the creation site; the resolved form is also what gets + // printed, so the printed path is the path actually created. + let dir = pi_uutils_ctx::resolve(dir); + let path = if make_dir { + make_temp_dir(&dir, prefix, rand, suffix)? + } else { + make_temp_file(&dir, prefix, rand, suffix)? + }; + + // Get just the last component of the path to the created + // temporary file or directory. + let filename = path.file_name(); + let filename = filename.unwrap().to_str().unwrap(); + + // Join the directory to the path to get the path to print. + // pi-uutils: unlike upstream (which re-joins the operand as typed), join + // the resolved directory so the printed path names the created entry even + // when the shell cwd differs from the process cwd. + let path = dir.join(filename); + + Ok(path) +} + +/// Reads from `TMPDIR_ENV_VAR` but defaults to /tmp if value is set to empty +/// string. +fn get_tmpdir_env_or_default() -> PathBuf { + // pi-uutils: read TMPDIR from the scope environment; when it is unset + // there, fall back to the host default temp dir as upstream does. + match pi_uutils_ctx::var(TMPDIR_ENV_VAR) { + Some(val) if val.is_empty() => PathBuf::from(FALLBACK_TMPDIR), + Some(val) => PathBuf::from(val), + None => env::temp_dir(), + } +} + +/// Create a temporary file or directory +/// +/// Behavior is determined by the `options` parameter, see [`Options`] for +/// details. +pub fn mktemp(options: &Options) -> UResult { + // Parse file path parameters from the command-line options. + let Params { directory: tmpdir, prefix, num_rand_chars: rand, suffix } = + Params::from(options.clone())?; + + // Create the temporary file or directory, or simulate creating it. + if options.dry_run { + Ok(dry_exec(&tmpdir, &prefix, rand, &suffix)) + } else { + exec(&tmpdir, &prefix, rand, &suffix, options.directory) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(cwd: PathBuf, env: HashMap, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env, + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("mktemp") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var, + /// which would otherwise break printed-path assertions). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = std::fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + fn tmpdir_env(dir: &Path) -> HashMap { + HashMap::from([("TMPDIR".to_string(), dir.display().to_string())]) + } + + #[test] + fn default_invocation_creates_file_at_printed_path() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), tmpdir_env(&root), vec![]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_file(), "printed path {printed:?} must be a regular file"); + // Scope TMPDIR is honored for the default template. + assert_eq!(printed.parent(), Some(root.as_path())); + assert!( + printed + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("tmp.") + ); + } + + #[test] + fn directory_flag_creates_directory() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), tmpdir_env(&root), vec!["-d"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_dir(), "printed path {printed:?} must be a directory"); + assert_eq!(printed.parent(), Some(root.as_path())); + } + + #[test] + fn relative_tmpdir_resolves_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + std::fs::create_dir(root.join("sub")).unwrap(); + + // Relative -p operand + scope cwd differing from the process cwd: only + // the creation-site `pi_uutils_ctx::resolve` patch makes this land in + // the scope cwd's subdir. + let (code, stdout, stderr) = + run_in(root.clone(), HashMap::new(), vec!["-p", "sub", "foo.XXXX"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_file(), "printed path {printed:?} must exist"); + assert_eq!(printed.parent(), Some(root.join("sub").as_path())); + assert!( + printed + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("foo.") + ); + } + + #[test] + fn too_few_xs_is_an_error() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, HashMap::new(), vec!["foo.XX"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "mktemp: too few X's in template 'foo.XX'\n"); + } + + #[test] + fn bsd_t_prefix_creates_file_in_tmpdir() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), tmpdir_env(&root), vec!["-t", "omp"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_file(), "printed path {printed:?} must be a regular file"); + assert_eq!(printed.parent(), Some(root.as_path())); + let name = printed.file_name().unwrap().to_str().unwrap(); + assert!(name.starts_with("omp."), "unexpected name {name}"); + assert_eq!(name.len(), "omp.".len() + 10); + } + + #[test] + fn bsd_t_prefix_creates_directory_with_d_flag() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), tmpdir_env(&root), vec!["-d", "-t", "pfx"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_dir(), "printed path {printed:?} must be a directory"); + assert_eq!(printed.parent(), Some(root.as_path())); + } + + #[test] + fn gnu_t_template_keeps_template_behavior() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), tmpdir_env(&root), vec!["-t", "fooXXXX"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_file(), "printed path {printed:?} must be a regular file"); + assert_eq!(printed.parent(), Some(root.as_path())); + let name = printed.file_name().unwrap().to_str().unwrap(); + assert!(name.starts_with("foo"), "unexpected name {name}"); + assert_eq!(name.len(), "foo".len() + 4); + } + + #[test] + fn template_without_xs_without_t_remains_an_error() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, HashMap::new(), vec!["prefix"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "mktemp: too few X's in template 'prefix'\n"); + } + + #[test] + fn dry_run_prints_nonexistent_path() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), tmpdir_env(&root), vec!["-u"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert_eq!(printed.parent(), Some(root.as_path())); + assert!(!printed.exists(), "dry-run path {printed:?} must not be created"); + } + + #[test] + fn suffix_is_appended_after_random_block() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = + run_in(root.clone(), HashMap::new(), vec!["--suffix=.txt", "-p", ".", "fooXXXX"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let printed = PathBuf::from(stdout.trim_end_matches('\n')); + assert!(printed.is_file()); + let name = printed.file_name().unwrap().to_str().unwrap().to_string(); + assert!(name.starts_with("foo") && name.ends_with(".txt"), "unexpected name {name}"); + } + + #[test] + fn quiet_suppresses_creation_error_message_but_not_exit_code() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = + run_in(root, HashMap::new(), vec!["-q", "-p", "missing-dir", "foo.XXXX"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "", "--quiet must suppress the creation error message"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), HashMap::new(), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("temporary file or directory")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-nproc/Cargo.toml b/crates/vendor/uu-nproc/Cargo.toml new file mode 100644 index 000000000..a075046ad --- /dev/null +++ b/crates/vendor/uu-nproc/Cargo.toml @@ -0,0 +1,22 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/nproc), patched to read the +# OMP_NUM_THREADS/OMP_THREAD_LIMIT environment variables from the shell scope +# environment and route I/O through pi-uutils-ctx so it can run in-process as a +# shell builtin. See src/nproc.rs for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_nproc" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "nproc ~ (uutils) display the number of processing units available (vendored + patched for in-process embedding)" + +[lib] +path = "src/nproc.rs" + +[dependencies] +libc = "0.2.172" +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { version = "0.8.0", features = ["fs"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-nproc/LICENSE b/crates/vendor/uu-nproc/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-nproc/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-nproc/src/nproc.rs b/crates/vendor/uu-nproc/src/nproc.rs new file mode 100644 index 000000000..5b6cfed2b --- /dev/null +++ b/crates/vendor/uu-nproc/src/nproc.rs @@ -0,0 +1,287 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) NPROCESSORS nprocs numstr sysconf + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. The OMP_NUM_THREADS and OMP_THREAD_LIMIT environment +// variables are read from the scope environment via `pi_uutils_ctx::var` (the +// shell's exported variables), not the host process environment. All output is +// routed through the context stdout, `translate!` strings are literalized, and +// the entry point no longer calls `std::process::exit`. + +use std::{ffi::OsString, io::Write, thread}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use uucore::{ + display::Quotable, + error::{UResult, USimpleError}, +}; + +static OPT_ALL: &str = "all"; +static OPT_IGNORE: &str = "ignore"; + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match nproc_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "nproc: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn nproc_main(matches: &ArgMatches) -> UResult<()> { + let ignore = match matches.get_one::(OPT_IGNORE) { + Some(numstr) => match numstr.trim().parse::() { + Ok(num) => num, + Err(e) => { + return Err(USimpleError::new( + 1, + // pi-uutils: literalized translate!("nproc-error-invalid-number") + format!("{} is not a valid number: {e}", numstr.quote()), + )); + }, + }, + None => 0, + }; + + // pi-uutils: OMP_THREAD_LIMIT comes from the scope environment (the + // shell's exported variables), not the host process environment. + let limit = match pi_uutils_ctx::var("OMP_THREAD_LIMIT") { + // Uses the OpenMP variable to limit the number of threads + // If the parsing fails, returns the max size (so, no impact) + // If OMP_THREAD_LIMIT=0, rejects the value + Some(threads) => match threads.parse() { + Ok(0) | Err(_) => usize::MAX, + Ok(n) => n, + }, + // the variable 'OMP_THREAD_LIMIT' doesn't exist + // fallback to the max + None => usize::MAX, + }; + + let mut cores = if matches.get_flag(OPT_ALL) { + num_cpus_all() + } else { + // OMP_NUM_THREADS doesn't have an impact on --all + // pi-uutils: OMP_NUM_THREADS comes from the scope environment. + match pi_uutils_ctx::var("OMP_NUM_THREADS") { + // Uses the OpenMP variable to force the number of threads + // If the parsing fails, returns the number of CPU + Some(threads) => { + // In some cases, OMP_NUM_THREADS can be "x,y,z" + // In this case, only take the first one (like GNU) + // If OMP_NUM_THREADS=0, rejects the value + match threads.split_terminator(',').next() { + None => available_parallelism(), + Some(s) => match s.trim().parse() { + Ok(0) | Err(_) => available_parallelism(), + Ok(n) => n, + }, + } + }, + // the variable 'OMP_NUM_THREADS' doesn't exist + // fallback to the regular CPU detection + None => available_parallelism(), + } + }; + + cores = std::cmp::min(limit, cores); + if cores <= ignore { + cores = 1; + } else { + cores -= ignore; + } + // pi-uutils: write to the context stdout instead of the process stdout. + pi_uutils_ctx::stdout() + .write_all(format!("{cores}\n").as_bytes()) + .map_err(|e| USimpleError::new(1, e.to_string()))?; + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("nproc") + .version(uucore::crate_version!()) + .about( + "Print the number of cores available to the current process.\nIf the OMP_NUM_THREADS or \ + OMP_THREAD_LIMIT environment variables are set, then\nthey will determine the minimum \ + and maximum returned value respectively.", + ) + .override_usage(format_usage("nproc [OPTIONS]...")) + .infer_long_args(true) + .arg( + Arg::new(OPT_ALL) + .long(OPT_ALL) + .help("print the number of cores available to the system") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_IGNORE) + .long(OPT_IGNORE) + .value_name("N") + .help("ignore up to N cores"), + ) +} + +#[cfg(unix)] +fn num_cpus_all() -> usize { + // In some situation, /proc and /sys are not mounted, and sysconf returns 1. + // However, we want to guarantee that `nproc --all` >= `nproc`. + unsafe { libc::sysconf(libc::_SC_NPROCESSORS_CONF) } + .try_into() + .ok() + .filter(|&n: &isize| n > 1) + .map_or_else(available_parallelism, |n| n as usize) +} + +// Other platforms (e.g., windows), available_parallelism() directly. +#[cfg(not(unix))] +fn num_cpus_all() -> usize { + available_parallelism() +} + +/// In some cases, [`thread::available_parallelism`]() may return an Err +/// In this case, we will return 1 (like GNU) +fn available_parallelism() -> usize { + thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(env: HashMap, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: PathBuf::from("."), + env, + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("nproc") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + #[test] + fn scope_env_omp_num_threads_forces_count() { + let env = HashMap::from([("OMP_NUM_THREADS".to_string(), "3".to_string())]); + let (code, stdout, stderr) = run_in(env, vec![]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "3\n", "")); + } + + #[test] + fn omp_thread_limit_caps_omp_num_threads() { + let env = HashMap::from([ + ("OMP_NUM_THREADS".to_string(), "64".to_string()), + ("OMP_THREAD_LIMIT".to_string(), "2".to_string()), + ]); + let (code, stdout, stderr) = run_in(env, vec![]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "2\n", "")); + } + + #[test] + fn all_prints_positive_integer_and_ignores_omp_num_threads() { + // --all reports hardware CPUs; OMP_NUM_THREADS must not force it. + let env = HashMap::from([("OMP_NUM_THREADS".to_string(), "0".to_string())]); + let (code, stdout, stderr) = run_in(env, vec!["--all"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let n: usize = stdout + .trim_end() + .parse() + .expect("--all output is an integer"); + assert!(n >= 1); + } + + #[test] + fn process_environment_is_not_consulted() { + // The variable exists only in the host process environment, not the + // scope map: only the un-patched `std::env::var` path would see it. + unsafe { std::env::set_var("OMP_NUM_THREADS", "1234") }; + let (code, stdout, stderr) = run_in(HashMap::new(), vec![]); + unsafe { std::env::remove_var("OMP_NUM_THREADS") }; + assert_eq!((code, stderr.as_str()), (0, "")); + assert_ne!(stdout, "1234\n"); + let n: usize = stdout.trim_end().parse().expect("output is an integer"); + assert!(n >= 1); + } + + #[test] + fn ignore_subtracts_and_floors_at_one() { + let env = HashMap::from([("OMP_NUM_THREADS".to_string(), "8".to_string())]); + let (code, stdout, _) = run_in(env, vec!["--ignore=3"]); + assert_eq!((code, stdout.as_str()), (0, "5\n")); + + let env = HashMap::from([("OMP_NUM_THREADS".to_string(), "2".to_string())]); + let (code, stdout, _) = run_in(env, vec!["--ignore=5"]); + assert_eq!((code, stdout.as_str()), (0, "1\n")); + } + + #[test] + fn invalid_ignore_value_is_an_error() { + let (code, stdout, stderr) = run_in(HashMap::new(), vec!["--ignore=bogus"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.contains("is not a valid number"), "stderr: {stderr}"); + } +} diff --git a/crates/vendor/uu-printenv/Cargo.toml b/crates/vendor/uu-printenv/Cargo.toml new file mode 100644 index 000000000..db7e8c291 --- /dev/null +++ b/crates/vendor/uu-printenv/Cargo.toml @@ -0,0 +1,21 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/printenv), patched to read +# the environment from the shell scope and route I/O through pi-uutils-ctx so +# it can run in-process as a shell builtin. See src/printenv.rs for the patch +# markers (`pi-uutils:` comments). +[package] +name = "uu_printenv" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "printenv ~ (uutils) display value of environment VAR (vendored + patched for in-process embedding)" + +[lib] +path = "src/printenv.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { version = "0.8.0" } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-printenv/LICENSE b/crates/vendor/uu-printenv/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-printenv/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-printenv/src/printenv.rs b/crates/vendor/uu-printenv/src/printenv.rs new file mode 100644 index 000000000..5e0eac263 --- /dev/null +++ b/crates/vendor/uu-printenv/src/printenv.rs @@ -0,0 +1,243 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. The environment comes from the SCOPE, not the process: +// the no-argument dump iterates `pi_uutils_ctx::env_snapshot()` and named +// lookups go through `pi_uutils_ctx::var`, because the embedding shell's +// exported variables are not present in the host process environment. All +// output is routed through the context stdout, `translate!` strings are +// literalized, and the entry point no longer calls `std::process::exit`. + +use std::{ffi::OsString, io::Write}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use uucore::{error::UResult, line_ending::LineEnding}; + +static OPT_NULL: &str = "null"; + +static ARG_VARIABLES: &str = "variables"; + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match printenv_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + // pi-uutils: unset-variable failures surface as bare exit-code + // errors that render to an empty message (upstream prints nothing + // for them); don't emit a dangling "printenv: " prefix. + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "printenv: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn printenv_main(matches: &ArgMatches) -> UResult<()> { + let variables: Vec = matches + .get_many::(ARG_VARIABLES) + .map(|v| v.map(ToString::to_string).collect()) + .unwrap_or_default(); + + let separator = LineEnding::from_zero_flag(matches.get_flag(OPT_NULL)); + + if variables.is_empty() { + // pi-uutils: replacement for `uucore::display::print_all_env_vars` — + // dumps the scope environment map to the context stdout. + let mut stdout = pi_uutils_ctx::stdout(); + for (key, value) in pi_uutils_ctx::env_snapshot() { + write!(stdout, "{key}={value}{separator}")?; + } + stdout.flush()?; + return Ok(()); + } + + let mut error_found = false; + for env_var in variables { + // we silently ignore a=b as variable but we trigger an error + if env_var.contains('=') { + error_found = true; + continue; + } + // pi-uutils: look the variable up in the scope environment (upstream + // uses `std::env::var_os`) and write it to the context stdout. + if let Some(var) = pi_uutils_ctx::var(&env_var) { + let mut stdout = pi_uutils_ctx::stdout(); + write!(stdout, "{var}{separator}")?; + stdout.flush()?; + } else { + error_found = true; + } + } + + if error_found { Err(1.into()) } else { Ok(()) } +} + +pub fn uu_app() -> Command { + Command::new("printenv") + .version(uucore::crate_version!()) + .about( + "Display the values of the specified environment VARIABLE(s), or (with no VARIABLE) \ + display name and value pairs for them all.", + ) + .override_usage(format_usage("printenv [OPTION]... [VARIABLE]...")) + .infer_long_args(true) + .arg( + Arg::new(OPT_NULL) + .short('0') + .long(OPT_NULL) + .help("end each output line with 0 byte rather than newline") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(ARG_VARIABLES) + .action(ArgAction::Append) + .num_args(1..), + ) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_with_env(env: HashMap, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: std::path::PathBuf::from("."), + env, + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("printenv") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + fn scope_env() -> HashMap { + HashMap::from([ + ("FOO".to_string(), "bar".to_string()), + ("BAZ".to_string(), "qux".to_string()), + ]) + } + + #[test] + fn named_variable_prints_scope_value() { + let (code, stdout, stderr) = run_with_env(scope_env(), vec!["FOO"]); + assert_eq!(code, 0); + assert_eq!(stdout, "bar\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn unset_variable_is_silent_failure() { + let (code, stdout, stderr) = run_with_env(scope_env(), vec!["NOPE"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "", "unset variables fail without a message"); + } + + #[test] + fn mixed_set_and_unset_prints_set_ones_and_fails() { + let (code, stdout, stderr) = run_with_env(scope_env(), vec!["FOO", "NOPE", "BAZ"]); + assert_eq!(code, 1); + assert_eq!(stdout, "bar\nqux\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn no_args_dumps_scope_env_not_process_env() { + // The host process certainly has PATH set; the scope env deliberately + // does not, so its absence proves the dump reads the scope map. + assert!(std::env::var_os("PATH").is_some()); + let (code, stdout, stderr) = run_with_env(scope_env(), vec![]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines.contains(&"FOO=bar")); + assert!(lines.contains(&"BAZ=qux")); + assert!(!lines.iter().any(|l| l.starts_with("PATH="))); + } + + #[test] + fn null_flag_terminates_with_nul() { + let (code, stdout, _) = run_with_env(scope_env(), vec!["-0", "FOO"]); + assert_eq!((code, stdout.as_str()), (0, "bar\0")); + + let (code, stdout, _) = run_with_env(scope_env(), vec!["--null", "FOO", "BAZ"]); + assert_eq!((code, stdout.as_str()), (0, "bar\0qux\0")); + } + + #[test] + fn name_containing_equals_is_ignored_but_fails() { + let (code, stdout, stderr) = run_with_env(scope_env(), vec!["FOO=bar", "BAZ"]); + assert_eq!(code, 1); + assert_eq!(stdout, "qux\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_with_env(HashMap::new(), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("environment VARIABLE")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-readlink/Cargo.toml b/crates/vendor/uu-readlink/Cargo.toml new file mode 100644 index 000000000..e54321c8b --- /dev/null +++ b/crates/vendor/uu-readlink/Cargo.toml @@ -0,0 +1,22 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/readlink), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/readlink.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_readlink" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "readlink ~ (uutils) display resolved path of PATHNAME (vendored + patched for in-process embedding)" + +[lib] +path = "src/readlink.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { version = "0.8.0", features = ["fs"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-readlink/LICENSE b/crates/vendor/uu-readlink/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-readlink/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-readlink/src/readlink.rs b/crates/vendor/uu-readlink/src/readlink.rs new file mode 100644 index 000000000..813d4a458 --- /dev/null +++ b/crates/vendor/uu-readlink/src/readlink.rs @@ -0,0 +1,422 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) errno + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Every filesystem syscall resolves its path operand +// against the shell working directory via `pi_uutils_ctx::resolve` AT THE CALL +// SITE, while the original operands are kept for display/error messages (GNU +// prints operands as typed). All process-global stdio is routed through +// `pi_uutils_ctx`, `translate!` strings are literalized, POSIXLY_CORRECT is +// read from the scope environment, and the entry point no longer calls +// `std::process::exit`. + +use std::{ + ffi::OsString, + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use uucore::{ + display::Quotable, + error::{FromIo, UResult, UUsageError}, + fs::{MissingHandling, ResolveMode, canonicalize}, + libc::EINVAL, + line_ending::LineEnding, +}; + +const OPT_CANONICALIZE: &str = "canonicalize"; +const OPT_CANONICALIZE_MISSING: &str = "canonicalize-missing"; +const OPT_CANONICALIZE_EXISTING: &str = "canonicalize-existing"; +const OPT_NO_NEWLINE: &str = "no-newline"; +const OPT_QUIET: &str = "quiet"; +const OPT_SILENT: &str = "silent"; +const OPT_VERBOSE: &str = "verbose"; +const OPT_ZERO: &str = "zero"; + +const ARG_FILES: &str = "files"; + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match readlink_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + // pi-uutils: silent failures surface as bare exit-code errors that + // render to an empty message (upstream prints nothing for them); + // don't emit a dangling "readlink: " prefix. + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "readlink: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn readlink_main(matches: &ArgMatches) -> UResult<()> { + let mut no_trailing_delimiter = matches.get_flag(OPT_NO_NEWLINE); + let use_zero = matches.get_flag(OPT_ZERO); + // pi-uutils: POSIXLY_CORRECT comes from the scope environment (the shell's + // exported variables), not the host process environment. + let verbose = matches.get_flag(OPT_VERBOSE) || pi_uutils_ctx::var("POSIXLY_CORRECT").is_some(); + + // GNU readlink -f/-e/-m follows symlinks first and then applies `..` (physical + // resolution). ResolveMode::Logical collapses `..` before following links, + // which yields the opposite order, so we choose Physical here for GNU + // compatibility. + let res_mode = if matches.get_flag(OPT_CANONICALIZE) + || matches.get_flag(OPT_CANONICALIZE_EXISTING) + || matches.get_flag(OPT_CANONICALIZE_MISSING) + { + ResolveMode::Physical + } else { + ResolveMode::None + }; + + let can_mode = if matches.get_flag(OPT_CANONICALIZE_EXISTING) { + MissingHandling::Existing + } else if matches.get_flag(OPT_CANONICALIZE_MISSING) { + MissingHandling::Missing + } else { + MissingHandling::Normal + }; + + let files: Vec = matches + .get_many::(ARG_FILES) + .map(|v| v.map(PathBuf::from).collect()) + .unwrap_or_default(); + + if files.is_empty() { + return Err(UUsageError::new(1, "missing operand".to_string())); + } + + if no_trailing_delimiter && files.len() > 1 { + let _ = writeln!( + pi_uutils_ctx::stderr(), + "readlink: ignoring --no-newline with multiple arguments" + ); + no_trailing_delimiter = false; + } + + let line_ending = if no_trailing_delimiter { + None + } else { + Some(LineEnding::from_zero_flag(use_zero)) + }; + + for p in &files { + // pi-uutils: resolve the operand against the shell working directory; + // `p` is kept for display. Resolving before `canonicalize` also keeps + // uucore's internal `env::current_dir()` fallback from being consulted. + let resolved = pi_uutils_ctx::resolve(p); + let path_result = if res_mode == ResolveMode::None { + fs::read_link(&resolved) + } else { + canonicalize(&resolved, can_mode, res_mode) + }; + + match path_result { + Ok(path) => { + show(&path, line_ending)?; + }, + Err(err) => { + if !verbose { + return Err(1.into()); + } + + let message = if err.raw_os_error() == Some(EINVAL) { + format!("{}: Invalid argument", p.maybe_quote()) + } else { + err.map_err_context(|| p.maybe_quote().to_string()) + .to_string() + }; + let _ = writeln!(pi_uutils_ctx::stderr(), "readlink: {message}"); + return Err(1.into()); + }, + } + } + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("readlink") + .version(uucore::crate_version!()) + .about("Print value of a symbolic link or canonical file name.") + .override_usage(format_usage("readlink [OPTION]... [FILE]...")) + .infer_long_args(true) + .arg( + Arg::new(OPT_CANONICALIZE) + .short('f') + .long(OPT_CANONICALIZE) + .help( + "canonicalize by following every symlink in every component of the given name \ + recursively; all but the last component must exist", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_CANONICALIZE_EXISTING) + .short('e') + .long("canonicalize-existing") + .help( + "canonicalize by following every symlink in every component of the given name \ + recursively, all components must exist", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_CANONICALIZE_MISSING) + .short('m') + .long(OPT_CANONICALIZE_MISSING) + .help( + "canonicalize by following every symlink in every component of the given name \ + recursively, without requirements on components existence", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_NO_NEWLINE) + .short('n') + .long(OPT_NO_NEWLINE) + .help("do not output the trailing delimiter") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_QUIET) + .short('q') + .long(OPT_QUIET) + .help("suppress most error messages") + .overrides_with_all([OPT_QUIET, OPT_SILENT, OPT_VERBOSE]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_SILENT) + .short('s') + .long(OPT_SILENT) + .help("suppress most error messages") + .overrides_with_all([OPT_QUIET, OPT_SILENT, OPT_VERBOSE]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_VERBOSE) + .short('v') + .long(OPT_VERBOSE) + .help("report error message") + .overrides_with_all([OPT_QUIET, OPT_SILENT, OPT_VERBOSE]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_ZERO) + .short('z') + .long(OPT_ZERO) + .help("separate output with NUL rather than newline") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(ARG_FILES) + .action(ArgAction::Append) + .value_parser(clap::value_parser!(OsString)) + .value_hint(clap::ValueHint::AnyPath), + ) +} + +/// pi-uutils: replacement for upstream's `show` — writes the resolved path +/// bytes verbatim to the context stdout instead of the process stdout. +fn show(path: &Path, line_ending: Option) -> UResult<()> { + let mut out = pi_uutils_ctx::stdout(); + out.write_all(uucore::os_str_as_bytes(path.as_os_str())?)?; + if let Some(line_ending) = line_ending { + write!(out, "{line_ending}")?; + } + out.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("readlink") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var, + /// which -f/-e/-m resolution would otherwise expand mid-assertion). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[cfg(unix)] + #[test] + fn resolves_relative_operand_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + std::os::unix::fs::symlink("target-file", root.join("link")).unwrap(); + + // Relative operand + scope cwd differing from the process cwd: only the + // call-site `pi_uutils_ctx::resolve` patch makes this find the link. + let (code, stdout, stderr) = run_in(root, vec!["link"]); + assert_eq!(code, 0); + assert_eq!(stdout, "target-file\n"); + assert_eq!(stderr, ""); + } + + #[cfg(unix)] + #[test] + fn canonicalize_follows_symlink_to_absolute_path() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("target"), b"x").unwrap(); + std::os::unix::fs::symlink("target", root.join("link")).unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-f", "link"]); + assert_eq!(code, 0); + assert_eq!(stdout, format!("{}\n", root.join("target").display())); + assert_eq!(stderr, ""); + } + + #[test] + fn canonicalize_missing_builds_path_from_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-m", "missing/sub"]); + assert_eq!(code, 0); + assert_eq!(stdout, format!("{}\n", root.join("missing").join("sub").display())); + assert_eq!(stderr, ""); + } + + #[cfg(unix)] + #[test] + fn canonicalize_existing_fails_silently_on_missing_final_component() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, vec!["-e", "missing"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "", "non-verbose failures print nothing"); + } + + #[cfg(unix)] + #[test] + fn non_symlink_is_silent_failure_by_default_and_einval_with_verbose() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("plain"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["plain"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (1, "", "")); + + let (code, stdout, stderr) = run_in(root, vec!["-v", "plain"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "readlink: plain: Invalid argument\n"); + } + + #[cfg(unix)] + #[test] + fn no_newline_with_multiple_args_warns_and_keeps_delimiter() { + let (_dir, root) = canonical_tempdir(); + std::os::unix::fs::symlink("a", root.join("l1")).unwrap(); + std::os::unix::fs::symlink("b", root.join("l2")).unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-n", "l1", "l2"]); + assert_eq!(code, 0); + assert_eq!(stdout, "a\nb\n"); + assert_eq!(stderr, "readlink: ignoring --no-newline with multiple arguments\n"); + } + + #[cfg(unix)] + #[test] + fn zero_terminates_with_nul_and_no_newline_drops_delimiter() { + let (_dir, root) = canonical_tempdir(); + std::os::unix::fs::symlink("a", root.join("l1")).unwrap(); + + let (code, stdout, _) = run_in(root.clone(), vec!["-z", "l1"]); + assert_eq!((code, stdout.as_str()), (0, "a\0")); + + let (code, stdout, _) = run_in(root, vec!["-n", "l1"]); + assert_eq!((code, stdout.as_str()), (0, "a")); + } + + #[test] + fn missing_operand_is_usage_error() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec![]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.contains("missing operand")); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("canonical file name")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-realpath/Cargo.toml b/crates/vendor/uu-realpath/Cargo.toml new file mode 100644 index 000000000..0a3954649 --- /dev/null +++ b/crates/vendor/uu-realpath/Cargo.toml @@ -0,0 +1,22 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/realpath), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/realpath.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_realpath" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "realpath ~ (uutils) display resolved absolute path of PATHNAME (vendored + patched for in-process embedding)" + +[lib] +path = "src/realpath.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { version = "0.8.0", features = ["fs"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-realpath/LICENSE b/crates/vendor/uu-realpath/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-realpath/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-realpath/src/realpath.rs b/crates/vendor/uu-realpath/src/realpath.rs new file mode 100644 index 000000000..061f67dfe --- /dev/null +++ b/crates/vendor/uu-realpath/src/realpath.rs @@ -0,0 +1,568 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) retcode + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Every filesystem syscall resolves its path operand +// against the shell working directory via `pi_uutils_ctx::resolve` AT THE CALL +// SITE (FILE operands and the --relative-to/--relative-base option paths), +// while the original operands are kept for display/error messages (GNU prints +// operands as typed). All process-global stdio is routed through +// `pi_uutils_ctx`, `translate!` strings are literalized, `show_if_err!` is +// replaced by a context-stderr write plus `pi_uutils_ctx::set_exit_code`, and +// the entry point no longer calls `std::process::exit`. + +use std::{ + ffi::{OsStr, OsString}, + io::Write, + path::{Path, PathBuf}, +}; + +use clap::{ + Arg, ArgAction, ArgMatches, Command, + builder::{TypedValueParser, ValueParserFactory}, +}; +use pi_uutils_ctx::format_usage; +use uucore::{ + display::Quotable, + error::{FromIo, UResult}, + fs::{MissingHandling, ResolveMode, canonicalize, make_path_relative_to}, + line_ending::LineEnding, +}; + +const OPT_QUIET: &str = "quiet"; +const OPT_STRIP: &str = "strip"; +const OPT_ZERO: &str = "zero"; +const OPT_PHYSICAL: &str = "physical"; +const OPT_LOGICAL: &str = "logical"; +const OPT_CANONICALIZE_MISSING: &str = "canonicalize-missing"; +const OPT_CANONICALIZE: &str = "canonicalize"; +const OPT_CANONICALIZE_EXISTING: &str = "canonicalize-existing"; +const OPT_RELATIVE_TO: &str = "relative-to"; +const OPT_RELATIVE_BASE: &str = "relative-base"; + +const ARG_FILES: &str = "files"; + +/// Custom parser that validates `OsString` is not empty +#[derive(Clone, Debug)] +struct NonEmptyOsStringParser; + +impl TypedValueParser for NonEmptyOsStringParser { + type Value = OsString; + + fn parse_ref( + &self, + _cmd: &Command, + _arg: Option<&Arg>, + value: &OsStr, + ) -> Result { + if value.is_empty() { + let mut err = clap::Error::new(clap::error::ErrorKind::ValueValidation); + err.insert( + clap::error::ContextKind::Custom, + // pi-uutils: literalized `translate!("realpath-invalid-empty-operand")` + clap::error::ContextValue::String("invalid operand: empty string".to_string()), + ); + return Err(err); + } + Ok(value.to_os_string()) + } +} + +impl ValueParserFactory for NonEmptyOsStringParser { + type Parser = Self; + + fn value_parser() -> Self::Parser { + Self + } +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match realpath_main(&matches) { + // pi-uutils: per-file failures accumulate their exit code via + // `pi_uutils_ctx::set_exit_code` (upstream's `show!` machinery). + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "realpath: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn realpath_main(matches: &ArgMatches) -> UResult<()> { + /* the list of files */ + + let paths: Vec = matches + .get_many::(ARG_FILES) + .unwrap() + .map(PathBuf::from) + .collect(); + + let strip = matches.get_flag(OPT_STRIP); + let line_ending = LineEnding::from_zero_flag(matches.get_flag(OPT_ZERO)); + let quiet = matches.get_flag(OPT_QUIET); + let logical = matches.get_flag(OPT_LOGICAL); + let can_mode = if matches.get_flag(OPT_CANONICALIZE_MISSING) { + MissingHandling::Missing + } else if matches.get_flag(OPT_CANONICALIZE_EXISTING) { + // -e: all components must exist + // Despite the name, MissingHandling::Existing requires all components to exist + MissingHandling::Existing + } else { + // Default behavior (same as -E): all but last component must exist + // MissingHandling::Normal allows the final component to not exist + MissingHandling::Normal + }; + let resolve_mode = if strip { + ResolveMode::None + } else if logical { + ResolveMode::Logical + } else { + ResolveMode::Physical + }; + let (relative_to, relative_base) = prepare_relative_options(matches, can_mode, resolve_mode)?; + for path in &paths { + let result = resolve_path( + path, + line_ending, + resolve_mode, + can_mode, + relative_to.as_deref(), + relative_base.as_deref(), + ); + if !quiet { + // pi-uutils: replacement for `show_if_err!` — report the error on + // the context stderr and record the exit code, then keep + // processing the remaining operands (upstream continue semantics). + if let Err(err) = result.map_err_context(|| path.maybe_quote().to_string()) { + let _ = writeln!(pi_uutils_ctx::stderr(), "realpath: {err}"); + pi_uutils_ctx::set_exit_code(err.code()); + } + } + } + // Although we return `Ok`, it is possible that a call to + // `show!()` above has set the exit code for the program to a + // non-zero integer. + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("realpath") + .version(uucore::crate_version!()) + .about("Print the resolved path") + .override_usage(format_usage("realpath [OPTION]... FILE...")) + .infer_long_args(true) + .arg( + Arg::new(OPT_QUIET) + .short('q') + .long(OPT_QUIET) + .help("Do not print warnings for invalid paths") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_STRIP) + .short('s') + .long(OPT_STRIP) + .visible_alias("no-symlinks") + .help("Only strip '.' and '..' components, but don't resolve symbolic links") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_ZERO) + .short('z') + .long(OPT_ZERO) + .help("Separate output filenames with \\0 rather than newline") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_LOGICAL) + .short('L') + .long(OPT_LOGICAL) + .help("resolve '..' components before symlinks") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_PHYSICAL) + .short('P') + .long(OPT_PHYSICAL) + .overrides_with_all([OPT_STRIP, OPT_LOGICAL]) + .help("resolve symlinks as encountered (default)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_CANONICALIZE) + .short('E') + .long(OPT_CANONICALIZE) + .overrides_with_all([OPT_CANONICALIZE_EXISTING, OPT_CANONICALIZE_MISSING]) + .help("all but the last component must exist (default)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_CANONICALIZE_EXISTING) + .short('e') + .long(OPT_CANONICALIZE_EXISTING) + .overrides_with_all([OPT_CANONICALIZE, OPT_CANONICALIZE_MISSING]) + .help( + "canonicalize by following every symlink in every component of the given name \ + recursively, all components must exist", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_CANONICALIZE_MISSING) + .short('m') + .long(OPT_CANONICALIZE_MISSING) + .overrides_with_all([OPT_CANONICALIZE, OPT_CANONICALIZE_EXISTING]) + .help( + "canonicalize by following every symlink in every component of the given name \ + recursively, without requirements on components existence", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_RELATIVE_TO) + .long(OPT_RELATIVE_TO) + .value_name("DIR") + .value_parser(NonEmptyOsStringParser) + .help("print the resolved path relative to DIR"), + ) + .arg( + Arg::new(OPT_RELATIVE_BASE) + .long(OPT_RELATIVE_BASE) + .value_name("DIR") + .value_parser(NonEmptyOsStringParser) + .help("print absolute paths unless paths below DIR"), + ) + .arg( + Arg::new(ARG_FILES) + .action(ArgAction::Append) + .required(true) + .value_parser(NonEmptyOsStringParser) + .value_hint(clap::ValueHint::AnyPath), + ) +} + +/// Prepare `--relative-to` and `--relative-base` options. +/// Convert them to their absolute values. +/// Check if `--relative-to` is a descendant of `--relative-base`, +/// otherwise nullify their value. +fn prepare_relative_options( + matches: &ArgMatches, + can_mode: MissingHandling, + resolve_mode: ResolveMode, +) -> UResult<(Option, Option)> { + let relative_to = matches + .get_one::(OPT_RELATIVE_TO) + .map(PathBuf::from); + let relative_base = matches + .get_one::(OPT_RELATIVE_BASE) + .map(PathBuf::from); + let relative_to = canonicalize_relative_option(relative_to, can_mode, resolve_mode)?; + let relative_base = canonicalize_relative_option(relative_base, can_mode, resolve_mode)?; + if let (Some(base), Some(to)) = (relative_base.as_deref(), relative_to.as_deref()) + && !to.starts_with(base) + { + return Ok((None, None)); + } + Ok((relative_to, relative_base)) +} + +/// Prepare single `relative-*` option. +fn canonicalize_relative_option( + relative: Option, + can_mode: MissingHandling, + resolve_mode: ResolveMode, +) -> UResult> { + Ok(match relative { + None => None, + Some(p) => Some( + canonicalize_relative(&p, can_mode, resolve_mode) + .map_err_context(|| p.maybe_quote().to_string())?, + ), + }) +} + +/// Make `relative-to` or `relative-base` path values absolute. +/// +/// # Errors +/// +/// If the given path is not a directory the function returns an error. +/// If some parts of the file don't exist, or symlinks make loops, or +/// some other IO error happens, the function returns error, too. +fn canonicalize_relative( + r: &Path, + can_mode: MissingHandling, + resolve: ResolveMode, +) -> std::io::Result { + // pi-uutils: resolve the option path against the shell working directory; + // `r` is kept by the caller for display. Resolving before `canonicalize` + // also keeps uucore's internal `env::current_dir()` fallback from being + // consulted. + let abs = canonicalize(pi_uutils_ctx::resolve(r), can_mode, resolve)?; + if can_mode == MissingHandling::Existing && !abs.is_dir() { + abs.read_dir()?; // raise not a directory error + } + Ok(abs) +} + +/// Resolve a path to an absolute form and print it. +/// +/// If `relative_to` and/or `relative_base` is given +/// the path is printed in a relative form to one of this options. +/// See the details in `process_relative` function. +/// If `zero` is `true`, then this function +/// prints the path followed by the null byte (`'\0'`) instead of a +/// newline character (`'\n'`). +/// +/// # Errors +/// +/// This function returns an error if there is a problem resolving +/// symbolic links. +fn resolve_path( + p: &Path, + line_ending: LineEnding, + resolve: ResolveMode, + can_mode: MissingHandling, + relative_to: Option<&Path>, + relative_base: Option<&Path>, +) -> std::io::Result<()> { + // pi-uutils: resolve the operand against the shell working directory; `p` + // is kept by the caller for display. Resolving before `canonicalize` also + // keeps uucore's internal `env::current_dir()` fallback from being + // consulted. + let abs = canonicalize(pi_uutils_ctx::resolve(p), can_mode, resolve)?; + + let abs = process_relative(abs, relative_base, relative_to); + + // pi-uutils: replacement for `print_verbatim` + process stdout — writes + // the resolved path bytes verbatim to the context stdout. + let mut out = pi_uutils_ctx::stdout(); + out.write_all( + uucore::os_str_as_bytes(abs.as_os_str()).map_err(|e| std::io::Error::other(e.to_string()))?, + )?; + out.write_all(&[line_ending.into()])?; + out.flush()?; + Ok(()) +} + +/// Conditionally converts an absolute path to a relative form, +/// according to the rules: +/// 1. if only `relative_to` is given, the result is relative to `relative_to` +/// 2. if only `relative_base` is given, it checks whether given `path` is a +/// descendant of `relative_base`, on success the result is relative to +/// `relative_base`, otherwise the result is the given `path` +/// 3. if both `relative_to` and `relative_base` are given, the result is +/// relative to `relative_to` if `path` is a descendant of `relative_base`, +/// otherwise the result is `path` +/// +/// For more information see +/// +fn process_relative( + path: PathBuf, + relative_base: Option<&Path>, + relative_to: Option<&Path>, +) -> PathBuf { + if let Some(base) = relative_base { + if path.starts_with(base) { + make_path_relative_to(path, relative_to.unwrap_or(base)) + } else { + path + } + } else if let Some(to) = relative_to { + make_path_relative_to(path, to) + } else { + path + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, fs, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("realpath") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var, + /// which canonicalization would otherwise expand mid-assertion). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[cfg(unix)] + #[test] + fn resolves_relative_operand_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("target"), b"x").unwrap(); + std::os::unix::fs::symlink("target", root.join("link")).unwrap(); + + // Relative operand + scope cwd differing from the process cwd: only + // the call-site `pi_uutils_ctx::resolve` patch makes this find the + // symlink and print its canonical target. + let (code, stdout, stderr) = run_in(root.clone(), vec!["link"]); + assert_eq!(code, 0); + assert_eq!(stdout, format!("{}\n", root.join("target").display())); + assert_eq!(stderr, ""); + } + + #[test] + fn canonicalize_missing_builds_path_from_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-m", "missing/sub"]); + assert_eq!(code, 0); + assert_eq!(stdout, format!("{}\n", root.join("missing").join("sub").display())); + assert_eq!(stderr, ""); + } + + #[test] + fn relative_to_option_resolves_against_scope_cwd_and_relativizes_output() { + let (_dir, root) = canonical_tempdir(); + fs::create_dir(root.join("sub")).unwrap(); + fs::write(root.join("sub").join("file"), b"x").unwrap(); + + // Both the operand and the (relative) --relative-to directory resolve + // against the scope cwd. + let (code, stdout, stderr) = run_in(root, vec!["--relative-to", "sub", "sub/file"]); + assert_eq!(code, 0); + assert_eq!(stdout, "file\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn zero_flag_terminates_with_nul() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-z", "f"]); + assert_eq!(code, 0); + assert_eq!(stdout, format!("{}\0", root.join("f").display())); + assert_eq!(stderr, ""); + } + + #[test] + fn nonexistent_operand_errors_but_later_operands_still_process() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["missing/x", "f"]); + assert_eq!(code, 1); + assert_eq!(stdout, format!("{}\n", root.join("f").display())); + assert!(stderr.contains("realpath: missing/x"), "stderr: {stderr}"); + assert!(stderr.contains("No such file"), "stderr: {stderr}"); + } + + #[test] + fn quiet_suppresses_error_messages() { + let (_dir, root) = canonical_tempdir(); + + // Upstream drops the per-file result entirely under -q (the error is + // neither printed nor accumulated into the exit code). + let (code, stdout, stderr) = run_in(root, vec!["-q", "missing/x"]); + assert_eq!(code, 0); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + } + + #[cfg(unix)] + #[test] + fn strip_keeps_symlinks_unresolved() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("target"), b"x").unwrap(); + std::os::unix::fs::symlink("target", root.join("link")).unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-s", "link"]); + assert_eq!(code, 0); + assert_eq!(stdout, format!("{}\n", root.join("link").display())); + assert_eq!(stderr, ""); + } + + #[test] + fn empty_operand_is_rejected() { + // The NonEmptyOsStringParser turns "" into a clap parse error (rendered + // by clap's default renderer since the uucore localization layer is + // patched out) instead of a filesystem lookup. + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec![""]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.contains("invalid value"), "stderr: {stderr}"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("Print the resolved path")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-sed/src/lib.rs b/crates/vendor/uu-sed/src/lib.rs index f26a81ae9..4235c3554 100644 --- a/crates/vendor/uu-sed/src/lib.rs +++ b/crates/vendor/uu-sed/src/lib.rs @@ -212,19 +212,32 @@ mod tests { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("file.txt"), "x marks\n").unwrap(); let (code, out, err) = - run_sed_in(dir.path().to_path_buf(), b"", &["-i", "s/x/y/", "file.txt"]); + run_sed_in(dir.path().to_path_buf(), b"", &["-i", "s/x marks/y marks/", "file.txt"]); assert_eq!(code, 0, "stderr: {err}"); assert!(out.is_empty(), "in-place edit must not print: {out}"); assert_eq!(std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "y marks\n"); } + #[test] + fn bsd_empty_in_place_suffix_edits_without_backup() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("file.txt"), "x marks\n").unwrap(); + let (code, out, err) = + run_sed_in(dir.path().to_path_buf(), b"", &["-i", "", "s/x marks/y marks/", "file.txt"]); + assert_eq!(code, 0, "stderr: {err}"); + assert!(out.is_empty(), "in-place edit must not print: {out}"); + assert_eq!(std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "y marks\n"); + assert!(!dir.path().join("file.txt.bak").exists()); + } + #[test] fn in_place_backup_suffix_keeps_original() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("file.txt"), "x marks\n").unwrap(); - let (code, _, err) = + let (code, out, err) = run_sed_in(dir.path().to_path_buf(), b"", &["-i.bak", "s/x/y/", "file.txt"]); assert_eq!(code, 0, "stderr: {err}"); + assert!(out.is_empty(), "in-place edit must not print: {out}"); assert_eq!(std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "y marks\n"); assert_eq!(std::fs::read_to_string(dir.path().join("file.txt.bak")).unwrap(), "x marks\n"); } diff --git a/crates/vendor/uu-sed/src/sed/mod.rs b/crates/vendor/uu-sed/src/sed/mod.rs index b7126346c..d2b7ddfbf 100644 --- a/crates/vendor/uu-sed/src/sed/mod.rs +++ b/crates/vendor/uu-sed/src/sed/mod.rs @@ -20,7 +20,7 @@ pub mod processor; pub mod script_char_provider; pub mod script_line_provider; -use std::{collections::HashMap, path::PathBuf}; +use std::{collections::HashMap, ffi::OsString, path::PathBuf}; use clap::{Arg, ArgMatches, Command, arg, crate_version}; use pi_uutils_ctx::format_usage; @@ -49,22 +49,30 @@ pub fn sed_main(matches: &ArgMatches) -> UResult<()> { Ok(()) } -/// Rewrite GNU-style attached `-i` backup suffixes (`-i.bak`, `-ibak`) into -/// the `-i=.bak` form clap needs with `require_equals`. GNU sed's `-i` -/// takes its optional suffix only when directly attached, so a separate -/// following token must stay a script/file operand; scanning stops at `--`. -pub fn normalize_args(argv: Vec) -> Vec { +// pi-uutils: normalize the BSD/macOS `sed -i ''` idiom before clap parsing, +// following uu-stat's `rewrite_bsd_invocation` precedent. +/// Normalize GNU's attached `-i` backup suffixes and BSD's empty backup +/// suffix. GNU sed's `-i` takes its optional suffix only when directly +/// attached, so a separate non-empty token must stay a script/file operand; +/// scanning stops at `--`. +pub fn normalize_args(argv: Vec) -> Vec { let mut out = Vec::with_capacity(argv.len()); - let mut iter = argv.into_iter(); + let mut iter = argv.into_iter().peekable(); // argv[0] is the command name; never rewritten. if let Some(first) = iter.next() { out.push(first); } let mut past_separator = false; - for arg in iter { + while let Some(arg) = iter.next() { if !past_separator { if arg == "--" { past_separator = true; + } else if is_in_place_flag(&arg) && iter.peek().is_some_and(|next| next.is_empty()) { + // BSD `-i ''` means in-place without a backup, matching GNU + // bare `-i`; the empty token would otherwise become SCRIPT. + out.push(arg); + iter.next(); + continue; } else if let Some(s) = arg.to_str() && let Some(suffix) = s.strip_prefix("-i") && !suffix.is_empty() @@ -79,6 +87,22 @@ pub fn normalize_args(argv: Vec) -> Vec out } +/// Whether `arg` is a clap-supported short-flag cluster ending in `-i`. +fn is_in_place_flag(arg: &OsString) -> bool { + let Some(cluster) = arg.to_str().and_then(|arg| arg.strip_prefix('-')) else { + return false; + }; + let Some(prefix) = cluster.strip_suffix('i') else { + return false; + }; + + !cluster.is_empty() + && !cluster.starts_with('-') + && prefix + .chars() + .all(|flag| matches!(flag, 'a' | 'E' | 'r' | 'n' | 's' | 'u' | 'z')) +} + #[allow(clippy::cognitive_complexity)] pub fn uu_app() -> Command { let util_name = "sed"; @@ -417,6 +441,36 @@ mod tests { assert_eq!(ctx.in_place_suffix, Some(".bak".to_string())); } + #[test] + fn test_bsd_empty_in_place_suffix_with_short_flag_cluster() { + // clap accepts `-Ei` as `-E -i`, so the BSD empty suffix must be + // removed from this valid GNU flag cluster as well. + let matches = test_matches(&["-Ei", "", "s/x/y/", "file.txt"]); + let ctx = build_context(&matches); + + assert!(ctx.regex_extended); + assert!(ctx.in_place); + assert_eq!(ctx.in_place_suffix, None); + let (scripts, files) = get_scripts_files(&matches).expect("BSD invocation parses"); + assert_eq!(scripts, vec![ScriptValue::StringVal("s/x/y/".to_string())]); + assert_eq!(files, vec![PathBuf::from("file.txt")]); + } + + #[test] + fn test_nonempty_token_after_in_place_is_not_consumed() { + let argv = ["sed", "-i", ".bak", "s/x/y/", "file.txt"] + .into_iter() + .map(std::ffi::OsString::from) + .collect(); + let actual = normalize_args(argv); + let expected = ["sed", "-i", ".bak", "s/x/y/", "file.txt"] + .into_iter() + .map(std::ffi::OsString::from) + .collect::>(); + + assert_eq!(actual, expected); + } + #[test] fn test_length_default_and_custom() { let matches_default = test_matches(&[]); diff --git a/crates/vendor/uu-seq/Cargo.toml b/crates/vendor/uu-seq/Cargo.toml new file mode 100644 index 000000000..f301f621d --- /dev/null +++ b/crates/vendor/uu-seq/Cargo.toml @@ -0,0 +1,32 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/seq), patched to route I/O +# through pi-uutils-ctx so it can run in-process as a shell builtin. See +# src/seq.rs for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_seq" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "seq ~ (uutils) display a sequence of numbers (vendored + patched for in-process embedding)" + +[lib] +path = "src/seq.rs" + +[dependencies] +bigdecimal = "0.4" +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +num-bigint = "0.4" +num-traits = "0.2" +thiserror = "2.0.3" +# pi-uutils: upstream also enables "signals" (SIGPIPE probing) — dropped, the +# in-process builtin has no process-global signal handling. +uucore = { version = "0.8.0", features = [ + "extendedbigdecimal", + "fast-inc", + "format", + "parser", + "quoting-style", +] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-seq/LICENSE b/crates/vendor/uu-seq/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-seq/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-seq/src/error.rs b/crates/vendor/uu-seq/src/error.rs new file mode 100644 index 000000000..417dc0960 --- /dev/null +++ b/crates/vendor/uu-seq/src/error.rs @@ -0,0 +1,57 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore numberparse +//! Errors returned by seq. + +// pi-uutils: `translate!` message lookups are literalized with the en-US +// strings from upstream's locales/en-US.ftl. + +use thiserror::Error; +use uucore::{display::Quotable, error::UError}; + +use crate::numberparse::ParseNumberError; + +#[derive(Debug, Error)] +pub enum SeqError { + /// An error parsing the input arguments. + /// + /// The parameters are the [`String`] argument as read from the + /// command line and the underlying parsing error itself. + #[error("invalid {} argument: {}", parse_error_type(.1), .0.quote())] + ParseError(String, ParseNumberError), + + /// The increment argument was zero, which is not allowed. + /// + /// The parameter is the increment argument as a [`String`] as read + /// from the command line. + #[error("invalid Zero increment value: {}", .0.quote())] + ZeroIncrement(String), + + /// No arguments were passed to this function, 1 or more is required + #[error("missing operand")] + NoArguments, + + /// Both a format and equal width where passed to seq + #[error("format string may not be specified when printing equal width strings")] + FormatAndEqualWidth, +} + +fn parse_error_type(e: &ParseNumberError) -> &'static str { + match e { + ParseNumberError::Float => "floating point", + ParseNumberError::Nan => "'not-a-number'", + } +} + +impl UError for SeqError { + /// Always return 1. + fn code(&self) -> i32 { + 1 + } + + fn usage(&self) -> bool { + true + } +} diff --git a/crates/vendor/uu-seq/src/number.rs b/crates/vendor/uu-seq/src/number.rs new file mode 100644 index 000000000..caa530b55 --- /dev/null +++ b/crates/vendor/uu-seq/src/number.rs @@ -0,0 +1,52 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore extendedbigdecimal +use num_traits::Zero; +use uucore::extendedbigdecimal::ExtendedBigDecimal; + +/// A number with a specified number of integer and fractional digits. +/// +/// This struct can be used to represent a number along with information +/// on how many significant digits to use when displaying the number. +/// The [`PreciseNumber::num_integral_digits`] field also includes the width +/// needed to display the "-" character for a negative number. +/// [`PreciseNumber::num_fractional_digits`] provides the number of decimal +/// digits after the decimal point (a.k.a. precision), or None if that number +/// cannot intuitively be obtained (i.e. hexadecimal floats). +/// Note: Those 2 fields should not necessarily be interpreted literally, but as +/// matching GNU `seq` behavior: the exact way of guessing desired precision +/// from user input is a matter of interpretation. +/// +/// You can get an instance of this struct by calling [`str::parse`]. +#[derive(Debug)] +pub struct PreciseNumber { + pub number: ExtendedBigDecimal, + pub num_integral_digits: usize, + pub num_fractional_digits: Option, +} + +impl PreciseNumber { + // pi-uutils: upstream's unused `new` constructor (only reachable from the + // fuzzing harness) is dropped to keep the vendored crate warning-free. + + pub fn one() -> Self { + // We would like to implement `num_traits::One`, but it requires + // a multiplication implementation, and we don't want to + // implement that here. + Self { + number: ExtendedBigDecimal::one(), + num_integral_digits: 1, + num_fractional_digits: Some(0), + } + } + + /// Decide whether this number is zero (either positive or negative). + pub fn is_zero(&self) -> bool { + // We would like to implement `num_traits::Zero`, but it + // requires an addition implementation, and we don't want to + // implement that here. + self.number.is_zero() + } +} diff --git a/crates/vendor/uu-seq/src/numberparse.rs b/crates/vendor/uu-seq/src/numberparse.rs new file mode 100644 index 000000000..74b832d4d --- /dev/null +++ b/crates/vendor/uu-seq/src/numberparse.rs @@ -0,0 +1,351 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore extendedbigdecimal bigdecimal numberparse +// hexadecimalfloat +//! Parsing numbers for use in `seq`. +//! +//! This module provides an implementation of [`FromStr`] for the +//! [`PreciseNumber`] struct. +use std::str::FromStr; + +use uucore::{ + extendedbigdecimal::ExtendedBigDecimal, + parser::num_parser::{ExtendedParser, ExtendedParserError}, +}; + +use crate::number::PreciseNumber; + +/// An error returned when parsing a number fails. +#[derive(Debug, PartialEq, Eq)] +pub enum ParseNumberError { + Float, + Nan, +} + +/// Compute the number of integral and fractional digits in input string, +/// and wrap the result in a PreciseNumber. +/// We know that the string has already been parsed correctly, so we don't +/// need to be too careful. +fn compute_num_digits(input: &str, ebd: ExtendedBigDecimal) -> PreciseNumber { + let input = input.to_lowercase(); + let input = input.trim_start(); + + // Leading + is ignored for this. + let input = input.strip_prefix('+').unwrap_or(input); + + // Integral digits for any hex number is ill-defined (0 is fine as an output) + // Fractional digits for an floating hex number is ill-defined, return None + // as we'll totally ignore that number for precision computations. + // Still return 0 for hex integers though. + if input.starts_with("0x") || input.starts_with("-0x") { + return PreciseNumber { + number: ebd, + num_integral_digits: 0, + num_fractional_digits: if input.contains('.') || input.contains('p') { + None + } else { + Some(0) + }, + }; + } + + // Split the exponent part, if any + let parts: Vec<&str> = input.split('e').collect(); + debug_assert!(parts.len() <= 2); + + // Count all the digits up to `.`, `-` sign is included. + let (mut int_digits, mut frac_digits) = match parts[0].find('.') { + Some(i) => { + // Cover special case .X and -.X where we behave as if there was a leading 0: + // 0.X, -0.X. + let int_digits = match i { + 0 => 1, + 1 if parts[0].starts_with('-') => 2, + _ => i, + }; + + (int_digits, parts[0].len() - i - 1) + }, + None => (parts[0].len(), 0), + }; + + // If there is an exponent, reparse that (yes this is not optimal, + // but we can't necessarily exactly recover that from the parsed number). + if parts.len() == 2 { + let exp = parts[1].parse::().unwrap_or(0); + // For positive exponents, effectively expand the number. Ignore negative + // exponents. Also ignore overflowed exponents (unwrap_or(0)). + if exp > 0 { + int_digits += exp.try_into().unwrap_or(0); + } + frac_digits = if exp < frac_digits as i64 { + // Subtract from i128 to avoid any overflow + (frac_digits as i128 - exp as i128).try_into().unwrap_or(0) + } else { + 0 + } + } + + PreciseNumber { + number: ebd, + num_integral_digits: int_digits, + num_fractional_digits: Some(frac_digits), + } +} + +// Note: We could also have provided an `ExtendedParser` implementation for +// PreciseNumber, but we want a simpler custom error. +impl FromStr for PreciseNumber { + type Err = ParseNumberError; + + fn from_str(input: &str) -> Result { + let ebd = match ExtendedBigDecimal::extended_parse(input) { + Ok(ebd) => match ebd { + // Handle special values + ExtendedBigDecimal::BigDecimal(_) | ExtendedBigDecimal::MinusZero => { + // TODO: GNU `seq` treats small numbers < 1e-4950 as 0, we could do the same + // to avoid printing senselessly small numbers. + ebd + }, + ExtendedBigDecimal::Infinity | ExtendedBigDecimal::MinusInfinity => { + return Ok(Self { + number: ebd, + num_integral_digits: 0, + num_fractional_digits: Some(0), + }); + }, + ExtendedBigDecimal::Nan | ExtendedBigDecimal::MinusNan => { + return Err(ParseNumberError::Nan); + }, + }, + Err(ExtendedParserError::Underflow(ebd)) => ebd, // Treat underflow as 0 + Err(_) => return Err(ParseNumberError::Float), + }; + + Ok(compute_num_digits(input, ebd)) + } +} + +#[cfg(test)] +mod tests { + use bigdecimal::BigDecimal; + use uucore::extendedbigdecimal::ExtendedBigDecimal; + + use crate::{number::PreciseNumber, numberparse::ParseNumberError}; + + /// Convenience function for parsing a [`Number`] and unwrapping. + fn parse(s: &str) -> ExtendedBigDecimal { + s.parse::().unwrap().number + } + + /// Convenience function for getting the number of integral digits. + fn num_integral_digits(s: &str) -> usize { + s.parse::().unwrap().num_integral_digits + } + + /// Convenience function for getting the number of fractional digits. + fn num_fractional_digits(s: &str) -> usize { + s.parse::() + .unwrap() + .num_fractional_digits + .unwrap() + } + + /// Convenience function for making sure the number of fractional digits is + /// "None" + fn num_fractional_digits_is_none(s: &str) -> bool { + s.parse::() + .unwrap() + .num_fractional_digits + .is_none() + } + + #[test] + fn test_parse_minus_zero_int() { + assert_eq!(parse("-0e0"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0e-0"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0e1"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0e+1"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0.0e1"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0x0"), ExtendedBigDecimal::MinusZero); + } + + #[test] + fn test_parse_minus_zero_float() { + assert_eq!(parse("-0.0"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0e-1"), ExtendedBigDecimal::MinusZero); + assert_eq!(parse("-0.0e-1"), ExtendedBigDecimal::MinusZero); + } + + #[test] + fn test_parse_big_int() { + assert_eq!(parse("0"), ExtendedBigDecimal::zero()); + assert_eq!(parse("0.1e1"), ExtendedBigDecimal::one()); + assert_eq!(parse("0.1E1"), ExtendedBigDecimal::one()); + assert_eq!( + parse("1.0e1"), + ExtendedBigDecimal::BigDecimal("10".parse::().unwrap()) + ); + } + + #[test] + fn test_parse_hexadecimal_big_int() { + assert_eq!(parse("0x0"), ExtendedBigDecimal::zero()); + assert_eq!( + parse("0x10"), + ExtendedBigDecimal::BigDecimal("16".parse::().unwrap()) + ); + } + + #[test] + fn test_parse_big_decimal() { + assert_eq!( + parse("0.0"), + ExtendedBigDecimal::BigDecimal("0.0".parse::().unwrap()) + ); + assert_eq!(parse(".0"), ExtendedBigDecimal::BigDecimal("0.0".parse::().unwrap())); + assert_eq!( + parse("1.0"), + ExtendedBigDecimal::BigDecimal("1.0".parse::().unwrap()) + ); + assert_eq!( + parse("10e-1"), + ExtendedBigDecimal::BigDecimal("1.0".parse::().unwrap()) + ); + assert_eq!( + parse("-1e-3"), + ExtendedBigDecimal::BigDecimal("-0.001".parse::().unwrap()) + ); + } + + #[test] + fn test_parse_inf() { + assert_eq!(parse("inf"), ExtendedBigDecimal::Infinity); + assert_eq!(parse("infinity"), ExtendedBigDecimal::Infinity); + assert_eq!(parse("+inf"), ExtendedBigDecimal::Infinity); + assert_eq!(parse("+infinity"), ExtendedBigDecimal::Infinity); + assert_eq!(parse("-inf"), ExtendedBigDecimal::MinusInfinity); + assert_eq!(parse("-infinity"), ExtendedBigDecimal::MinusInfinity); + } + + #[test] + fn test_parse_invalid_float() { + assert_eq!("1.2.3".parse::().unwrap_err(), ParseNumberError::Float); + assert_eq!("1e2e3".parse::().unwrap_err(), ParseNumberError::Float); + assert_eq!("1e2.3".parse::().unwrap_err(), ParseNumberError::Float); + assert_eq!("-+-1".parse::().unwrap_err(), ParseNumberError::Float); + } + + #[test] + fn test_parse_invalid_hex() { + assert_eq!("0xg".parse::().unwrap_err(), ParseNumberError::Float); + } + + #[test] + fn test_parse_invalid_nan() { + assert_eq!("nan".parse::().unwrap_err(), ParseNumberError::Nan); + assert_eq!("NAN".parse::().unwrap_err(), ParseNumberError::Nan); + assert_eq!("NaN".parse::().unwrap_err(), ParseNumberError::Nan); + assert_eq!("nAn".parse::().unwrap_err(), ParseNumberError::Nan); + assert_eq!("-nan".parse::().unwrap_err(), ParseNumberError::Nan); + } + + #[test] + #[allow(clippy::cognitive_complexity)] + fn test_num_integral_digits() { + // no decimal, no exponent + assert_eq!(num_integral_digits("123"), 3); + // decimal, no exponent + assert_eq!(num_integral_digits("123.45"), 3); + assert_eq!(num_integral_digits("-0.1"), 2); + assert_eq!(num_integral_digits("-.1"), 2); + // exponent, no decimal + assert_eq!(num_integral_digits("123e4"), 3 + 4); + assert_eq!(num_integral_digits("123e-4"), 3); + assert_eq!(num_integral_digits("-1e-3"), 2); + // decimal and exponent + assert_eq!(num_integral_digits("123.45e6"), 3 + 6); + assert_eq!(num_integral_digits("123.45e-6"), 3); + assert_eq!(num_integral_digits("123.45e-1"), 3); + assert_eq!(num_integral_digits("-0.1e0"), 2); + assert_eq!(num_integral_digits("-0.1e2"), 4); + assert_eq!(num_integral_digits("-.1e0"), 2); + assert_eq!(num_integral_digits("-.1e2"), 4); + assert_eq!(num_integral_digits("-1.e-3"), 2); + assert_eq!(num_integral_digits("-1.0e-4"), 2); + // minus zero int + assert_eq!(num_integral_digits("-0e0"), 2); + assert_eq!(num_integral_digits("-0e-0"), 2); + assert_eq!(num_integral_digits("-0e1"), 3); + assert_eq!(num_integral_digits("-0e+1"), 3); + assert_eq!(num_integral_digits("-0.0e1"), 3); + // minus zero float + assert_eq!(num_integral_digits("-0.0"), 2); + assert_eq!(num_integral_digits("-0e-1"), 2); + assert_eq!(num_integral_digits("-0.0e-1"), 2); + + // TODO In GNU `seq`, the `-w` option does not seem to work with + // hexadecimal arguments. In order to match that behavior, we + // report the number of integral digits as zero for hexadecimal + // inputs. + assert_eq!(num_integral_digits("0xff"), 0); + } + + #[test] + #[allow(clippy::cognitive_complexity)] + fn test_num_fractional_digits() { + // no decimal, no exponent + assert_eq!(num_fractional_digits("123"), 0); + assert_eq!(num_fractional_digits("0xff"), 0); + // decimal, no exponent + assert_eq!(num_fractional_digits("123.45"), 2); + assert_eq!(num_fractional_digits("-0.1"), 1); + assert_eq!(num_fractional_digits("-.1"), 1); + // exponent, no decimal + assert_eq!(num_fractional_digits("123e4"), 0); + assert_eq!(num_fractional_digits("123e-4"), 4); + assert_eq!(num_fractional_digits("123e-1"), 1); + assert_eq!(num_fractional_digits("-1e-3"), 3); + // decimal and exponent + assert_eq!(num_fractional_digits("123.45e6"), 0); + assert_eq!(num_fractional_digits("123.45e1"), 1); + assert_eq!(num_fractional_digits("123.45e-6"), 8); + assert_eq!(num_fractional_digits("123.45e-1"), 3); + assert_eq!(num_fractional_digits("-0.1e0"), 1); + assert_eq!(num_fractional_digits("-0.1e2"), 0); + assert_eq!(num_fractional_digits("-.1e0"), 1); + assert_eq!(num_fractional_digits("-.1e2"), 0); + assert_eq!(num_fractional_digits("-1.e-3"), 3); + assert_eq!(num_fractional_digits("-1.0e-4"), 5); + // minus zero int + assert_eq!(num_fractional_digits("-0e0"), 0); + assert_eq!(num_fractional_digits("-0e-0"), 0); + assert_eq!(num_fractional_digits("-0e1"), 0); + assert_eq!(num_fractional_digits("-0e+1"), 0); + assert_eq!(num_fractional_digits("-0.0e1"), 0); + // minus zero float + assert_eq!(num_fractional_digits("-0.0"), 1); + assert_eq!(num_fractional_digits("-0e-1"), 1); + assert_eq!(num_fractional_digits("-0.0e-1"), 2); + // Hexadecimal numbers + assert_eq!(num_fractional_digits("0xff"), 0); + assert!(num_fractional_digits_is_none("0xff.1")); + } + + #[test] + fn test_parse_min_exponents() { + // Make sure exponents < i64::MIN do not cause errors + assert!("1e-9223372036854775807".parse::().is_ok()); + assert!("1e-9223372036854775808".parse::().is_ok()); + assert!("1e-92233720368547758080".parse::().is_ok()); + } + + #[test] + fn test_parse_max_exponents() { + // Make sure exponents much bigger than i64::MAX cause errors + assert!("1e9223372036854775807".parse::().is_ok()); + assert!("1e92233720368547758070".parse::().is_err()); + } +} diff --git a/crates/vendor/uu-seq/src/seq.rs b/crates/vendor/uu-seq/src/seq.rs new file mode 100644 index 000000000..82cdfd46c --- /dev/null +++ b/crates/vendor/uu-seq/src/seq.rs @@ -0,0 +1,561 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) bigdecimal extendedbigdecimal numberparse +// hexadecimalfloat biguint + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. seq is pure computation + stdout: all process-global +// stdio is routed through `pi_uutils_ctx` (the emission loops write to a +// `BufWriter` around the context stdout handle and poll +// `pi_uutils_ctx::is_cancelled()` periodically, since seq can generate +// unbounded output), `translate!` strings are literalized, SIGPIPE probing is +// dropped, and the entry point no longer calls `std::process::exit`. + +use std::{ + ffi::{OsStr, OsString}, + io::{BufWriter, Write}, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use num_bigint::BigUint; +use num_traits::{ToPrimitive, Zero}; +use pi_uutils_ctx::format_usage; +use uucore::{ + error::{FromIo, UResult}, + extendedbigdecimal::ExtendedBigDecimal, + fast_inc::fast_inc, + format::{Format, num_format, num_format::FloatVariant}, +}; + +mod error; + +mod number; +mod numberparse; +use crate::{error::SeqError, number::PreciseNumber}; + +const OPT_SEPARATOR: &str = "separator"; +const OPT_TERMINATOR: &str = "terminator"; +const OPT_EQUAL_WIDTH: &str = "equal-width"; +const OPT_FORMAT: &str = "format"; + +const ARG_NUMBERS: &str = "numbers"; + +/// pi-uutils: how many emitted numbers to print between cancellation polls in +/// the (potentially unbounded) emission loops. +const CANCEL_POLL_INTERVAL: u64 = 4096; + +#[derive(Clone)] +struct SeqOptions<'a> { + separator: OsString, + terminator: OsString, + equal_width: bool, + format: Option<&'a str>, +} + +/// A range of floats. +/// +/// The elements are (first, increment, last). +type RangeFloat = (ExtendedBigDecimal, ExtendedBigDecimal, ExtendedBigDecimal); + +/// Turn short args with attached value, for example "-s,", into two args "-s" +/// and "," to make them work with clap. +fn split_short_args_with_value(args: impl uucore::Args) -> impl uucore::Args { + let mut v: Vec = Vec::new(); + + for arg in args { + let bytes = arg.as_encoded_bytes(); + + if bytes.len() > 2 + && (bytes.starts_with(b"-f") || bytes.starts_with(b"-s") || bytes.starts_with(b"-t")) + { + let (short_arg, value) = bytes.split_at(2); + // SAFETY: + // Both `short_arg` and `value` only contain content that originated from + // `OsStr::as_encoded_bytes` + v.push(unsafe { OsString::from_encoded_bytes_unchecked(short_arg.to_vec()) }); + v.push(unsafe { OsString::from_encoded_bytes_unchecked(value.to_vec()) }); + } else { + v.push(arg); + } + } + + v.into_iter() +} + +fn select_precision( + first: &PreciseNumber, + increment: &PreciseNumber, + last: &PreciseNumber, +) -> Option { + match (first.num_fractional_digits, increment.num_fractional_digits, last.num_fractional_digits) + { + (Some(0), Some(0), Some(0)) => Some(0), + (Some(f), Some(i), Some(_)) => Some(f.max(i)), + _ => None, + } +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(split_short_args_with_value(argv.into_iter())) + { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match seq_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "seq: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn seq_main(matches: &ArgMatches) -> UResult<()> { + let numbers_option = matches.get_many::(ARG_NUMBERS); + + if numbers_option.is_none() { + return Err(SeqError::NoArguments.into()); + } + + let numbers = numbers_option.unwrap().collect::>(); + + let options = SeqOptions { + separator: matches + .get_one::(OPT_SEPARATOR) + .cloned() + .unwrap_or_else(|| OsString::from("\n")), + terminator: matches + .get_one::(OPT_TERMINATOR) + .cloned() + .unwrap_or_else(|| OsString::from("\n")), + equal_width: matches.get_flag(OPT_EQUAL_WIDTH), + format: matches.get_one::(OPT_FORMAT).map(String::as_str), + }; + + if options.equal_width && options.format.is_some() { + return Err(SeqError::FormatAndEqualWidth.into()); + } + + let first = if numbers.len() > 1 { + match numbers[0].parse() { + Ok(num) => num, + Err(e) => return Err(SeqError::ParseError(numbers[0].to_owned(), e).into()), + } + } else { + PreciseNumber::one() + }; + let increment = if numbers.len() > 2 { + match numbers[1].parse() { + Ok(num) => num, + Err(e) => return Err(SeqError::ParseError(numbers[1].to_owned(), e).into()), + } + } else { + PreciseNumber::one() + }; + if increment.is_zero() { + return Err(SeqError::ZeroIncrement(numbers[1].to_owned()).into()); + } + let last: PreciseNumber = { + // We are guaranteed that `numbers.len()` is greater than zero + // and at most three because of the argument specification in + // `uu_app()`. + let n: usize = numbers.len(); + match numbers[n - 1].parse() { + Ok(num) => num, + Err(e) => return Err(SeqError::ParseError(numbers[n - 1].to_owned(), e).into()), + } + }; + + // If a format was passed on the command line, use that. + // If not, use some default format based on parameters precision. + let (format, padding, fast_allowed) = if let Some(str) = options.format { + (Format::::parse(str)?, 0, false) + } else { + let precision = select_precision(&first, &increment, &last); + + let padding = if options.equal_width { + let precision_value = precision.unwrap_or(0); + first + .num_integral_digits + .max(increment.num_integral_digits) + .max(last.num_integral_digits) + + if precision_value > 0 { + precision_value + 1 + } else { + 0 + } + } else { + 0 + }; + + let formatter = match precision { + // format with precision: decimal floats and integers + Some(precision) => num_format::Float { + variant: FloatVariant::Decimal, + width: padding, + alignment: num_format::NumberAlignment::RightZero, + precision: Some(precision), + ..Default::default() + }, + // format without precision: hexadecimal floats + None => num_format::Float { variant: FloatVariant::Shortest, ..Default::default() }, + }; + // Allow fast printing if precision is 0 (integer inputs), `print_seq` will do + // further checks. + (Format::from_formatter(formatter), padding, precision == Some(0)) + }; + + let result = print_seq( + (first.number, increment.number, last.number), + &options.separator, + &options.terminator, + &format, + fast_allowed, + padding, + ); + + match result { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { + // GNU seq prints the Broken pipe message but still exits with status 0 + // unless SIGPIPE was explicitly ignored, in which case it should fail. + // pi-uutils: the in-process builtin does not manipulate process + // signal dispositions, so the upstream `sigpipe_was_ignored` probe + // is dropped and the message goes to the context stderr. + let err = err.map_err_context(|| "write error".into()); + let _ = writeln!(pi_uutils_ctx::stderr(), "seq: {err}"); + Ok(()) + }, + Err(err) => Err(err.map_err_context(|| "write error".into())), + } +} + +pub fn uu_app() -> Command { + Command::new("seq") + .trailing_var_arg(true) + .infer_long_args(true) + .version(uucore::crate_version!()) + .about("Display numbers from FIRST to LAST, in steps of INCREMENT.") + .override_usage(format_usage( + "seq [OPTION]... LAST\nseq [OPTION]... FIRST LAST\nseq [OPTION]... FIRST INCREMENT LAST", + )) + .arg( + Arg::new(OPT_SEPARATOR) + .short('s') + .long("separator") + .help("Separator character (defaults to \\n)") + .value_parser(clap::value_parser!(OsString)), + ) + .arg( + Arg::new(OPT_TERMINATOR) + .short('t') + .long("terminator") + .help("Terminator character (defaults to \\n)") + .value_parser(clap::value_parser!(OsString)), + ) + .arg( + Arg::new(OPT_EQUAL_WIDTH) + .short('w') + .long("equal-width") + .help("Equalize widths of all numbers by padding with zeros") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_FORMAT) + .short('f') + .long(OPT_FORMAT) + .help("use printf style floating-point FORMAT"), + ) + .arg( + // we use allow_hyphen_values instead of allow_negative_numbers because clap removed + // the support for "exotic" negative numbers like -.1 (see https://github.com/clap-rs/clap/discussions/5837) + Arg::new(ARG_NUMBERS) + .allow_hyphen_values(true) + .action(ArgAction::Append) + .num_args(1..=3), + ) +} + +/// Integer print, default format, positive increment: fast code path +/// that avoids reformatting digit at all iterations. +fn fast_print_seq( + mut stdout: impl Write, + first: &BigUint, + increment: u64, + last: &BigUint, + separator: &OsStr, + terminator: &OsStr, + padding: usize, +) -> std::io::Result<()> { + // Nothing to do, just return. + if last < first { + return Ok(()); + } + + // Do at most u64::MAX loops. We can print in the order of 1e8 digits per + // second, u64::MAX is 1e19, so it'd take hundreds of years for this to + // complete anyway. TODO: we can move this test to `print_seq` if we care about + // this case. + let loop_cnt = ((last - first) / increment).to_u64().unwrap_or(u64::MAX); + + // Format the first number. + let first_str = first.to_string(); + + // Makeshift log10.ceil + let last_length = last.to_string().len(); + + // Allocate a large u8 buffer, that contains a preformatted string + // of the number followed by the `separator`. + // + // | ... head space ... | number | separator | + // ^0 ^ start ^ num_end ^ size (==buf.len()) + // + // We keep track of start in this buffer, as the number grows. + // When printing, we take a slice between start and end. + let size = last_length.max(padding) + separator.len(); + // Fill with '0', this is needed for equal_width, and harmless otherwise. + let mut buf = vec![b'0'; size]; + let buf = buf.as_mut_slice(); + + let num_end = buf.len() - separator.len(); + let mut start = num_end - first_str.len(); + + // Initialize buf with first and separator. + buf[start..num_end].copy_from_slice(first_str.as_bytes()); + buf[num_end..].copy_from_slice(separator.as_encoded_bytes()); + + // Normally, if padding is > 0, it should be equal to last_length, + // so start would be == 0, but there are corner cases. + start = start.min(num_end - padding); + + // Prepare the number to increment with as a string + let inc_str = increment.to_string(); + let inc_str = inc_str.as_bytes(); + + for i in 0..loop_cnt { + // pi-uutils: seq can generate effectively unbounded output; poll the + // host cancel flag periodically so shell abort/timeout is observed. + if i % CANCEL_POLL_INTERVAL == 0 && pi_uutils_ctx::is_cancelled() { + return Ok(()); + } + stdout.write_all(&buf[start..])?; + fast_inc(buf, &mut start, num_end, inc_str); + } + // Write the last number without separator, but with terminator. + stdout.write_all(&buf[start..num_end])?; + stdout.write_all(terminator.as_encoded_bytes())?; + stdout.flush()?; + Ok(()) +} + +fn done_printing(next: &T, increment: &T, last: &T) -> bool { + if increment >= &T::zero() { + next > last + } else { + next < last + } +} + +/// Arbitrary precision decimal number code path ("slow" path) +fn print_seq( + range: RangeFloat, + separator: &OsStr, + terminator: &OsStr, + format: &Format, + fast_allowed: bool, + padding: usize, // Used by fast path only +) -> std::io::Result<()> { + // pi-uutils: buffer the context stdout handle instead of the (locked) + // process stdout. + let mut stdout = BufWriter::new(pi_uutils_ctx::stdout()); + let (first, increment, last) = range; + + if fast_allowed { + // Test if we can use fast code path. + // First try to convert the range to BigUint (u64 for the increment). + let (first_bui, increment_u64, last_bui) = + (first.to_biguint(), increment.to_biguint().and_then(|x| x.to_u64()), last.to_biguint()); + if let (Some(first_bui), Some(increment_u64), Some(last_bui)) = + (first_bui, increment_u64, last_bui) + { + return fast_print_seq( + stdout, + &first_bui, + increment_u64, + &last_bui, + separator, + terminator, + padding, + ); + } + } + + let mut value = first; + + let mut is_first_iteration = true; + // pi-uutils: iteration counter for periodic cancellation polling. + let mut iterations: u64 = 0; + while !done_printing(&value, &increment, &last) { + // pi-uutils: seq can generate effectively unbounded output; poll the + // host cancel flag periodically so shell abort/timeout is observed. + if iterations.is_multiple_of(CANCEL_POLL_INTERVAL) && pi_uutils_ctx::is_cancelled() { + return Ok(()); + } + iterations += 1; + if !is_first_iteration { + stdout.write_all(separator.as_encoded_bytes())?; + } + format.fmt(&mut stdout, &value)?; + // TODO Implement augmenting addition. + value = value + increment.clone(); + is_first_iteration = false; + } + if !is_first_iteration { + stdout.write_all(terminator.as_encoded_bytes())?; + } + stdout.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_scoped(args: Vec<&str>, cancelled: bool) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: PathBuf::from("."), + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(cancelled)), + }; + + let argv: Vec = std::iter::once("seq") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + fn run_in(args: Vec<&str>) -> (i32, String, String) { + run_scoped(args, false) + } + + #[test] + fn single_operand_counts_from_one() { + let (code, stdout, stderr) = run_in(vec!["3"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "1\n2\n3\n", "")); + } + + #[test] + fn first_increment_last_arithmetic() { + let (code, stdout, stderr) = run_in(vec!["2", "2", "10"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "2\n4\n6\n8\n10\n", "")); + } + + #[test] + fn separator_joins_values_terminator_ends_them() { + let (code, stdout, stderr) = run_in(vec!["-s", ",", "1", "3"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "1,2,3\n", "")); + + // Attached short-arg value goes through `split_short_args_with_value`. + let (code, stdout, _) = run_in(vec!["-s,", "1", "3"]); + assert_eq!((code, stdout.as_str()), (0, "1,2,3\n")); + } + + #[test] + fn equal_width_pads_with_zeros() { + let (code, stdout, stderr) = run_in(vec!["-w", "8", "10"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "08\n09\n10\n", "")); + } + + #[test] + fn float_increment_selects_widest_precision() { + let (code, stdout, stderr) = run_in(vec!["1", "0.5", "2"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "1.0\n1.5\n2.0\n", "")); + } + + #[test] + fn invalid_operand_reports_error_and_fails() { + let (code, stdout, stderr) = run_in(vec!["foo"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "seq: invalid floating point argument: 'foo'\n"); + } + + #[test] + fn zero_increment_is_rejected() { + let (code, stdout, stderr) = run_in(vec!["1", "0", "5"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, "seq: invalid Zero increment value: '0'\n"); + } + + #[test] + fn cancelled_scope_stops_emission() { + // pi-specific contract: a pre-cancelled scope aborts the (potentially + // unbounded) emission loop instead of printing the full range. + let (code, stdout, stderr) = run_scoped(vec!["1", "1000000"], true); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("steps of INCREMENT")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-stat/Cargo.toml b/crates/vendor/uu-stat/Cargo.toml new file mode 100644 index 000000000..5c7f30de8 --- /dev/null +++ b/crates/vendor/uu-stat/Cargo.toml @@ -0,0 +1,23 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/stat), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/stat.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_stat" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "stat ~ (uutils) display FILE status (vendored + patched for in-process embedding)" + +[lib] +path = "src/stat.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +thiserror = "2.0.3" +uucore = { version = "0.8.0", features = ["entries", "libc", "fs", "fsext", "time"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-stat/LICENSE b/crates/vendor/uu-stat/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-stat/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-stat/src/stat.rs b/crates/vendor/uu-stat/src/stat.rs new file mode 100644 index 000000000..f26131eed --- /dev/null +++ b/crates/vendor/uu-stat/src/stat.rs @@ -0,0 +1,2175 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore datetime + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Every filesystem syscall (stat/lstat/statfs/readlink) +// resolves its path operand against the shell working directory via +// `pi_uutils_ctx::resolve` AT THE CALL SITE, while the original operands are +// kept for display/error messages and `%n` output (GNU prints operands as +// typed). All process-global stdio is routed through `pi_uutils_ctx`, +// `translate!` strings are literalized from locales/en-US.ftl, QUOTING_STYLE is +// read from the scope environment, SELinux support is dropped, and the entry +// point no longer calls `std::process::exit`. The upstream implementation is +// unix-only (it relies on `std::os::unix`), so it lives behind `#[cfg(unix)]`; +// non-unix targets get a stub that reports the builtin as unsupported. +// BSD-style invocations (`stat -f FORMAT`, macOS muscle memory) are detected +// and translated to the GNU format language before argument parsing; see +// `rewrite_bsd_invocation`. + +#[cfg(unix)] +pub use imp::{run, uu_app}; + +/// pi-uutils: non-unix stub — upstream stat cannot be built off unix. +#[cfg(not(unix))] +pub fn run(_argv: Vec) -> i32 { + use std::io::Write; + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: unsupported on this platform"); + 1 +} + +/// pi-uutils: minimal non-unix counterpart of the real `uu_app`. +#[cfg(not(unix))] +pub fn uu_app() -> clap::Command { + clap::Command::new("stat") + .version(uucore::crate_version!()) + .about("Display file or file system status.") + .override_usage(pi_uutils_ctx::format_usage("stat [OPTION]... FILE...")) +} + +#[cfg(unix)] +mod imp { + use std::{ + borrow::Cow, + cell::OnceCell, + ffi::{OsStr, OsString}, + fs::{self, FileType, Metadata}, + io::Write, + os::unix::fs::{FileTypeExt, MetadataExt}, + path::Path, + }; + + use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; + use pi_uutils_ctx::format_usage; + use thiserror::Error; + use uucore::{ + display::Quotable, + entries, + error::{UError, UResult, USimpleError}, + fs::{display_permissions, major, minor}, + fsext::{ + FsMeta, MetadataTimeField, StatFs, metadata_get_time, pretty_filetype, pretty_fstype, + read_fs_list, statfs, + }, + libc::mode_t, + time::{FormatSystemTimeFallback, format_system_time, system_time_to_sec}, + }; + + const ABOUT: &str = "Display file or file system status."; + const USAGE: &str = "stat [OPTION]... FILE..."; + // pi-uutils: literalized from locales/en-US.ftl (`stat-after-help`). + const AFTER_HELP: &str = "Valid format sequences for files (without `--file-system`): + +-`%a`: access rights in octal (note '#' and '0' printf flags) +-`%A`: access rights in human readable form +-`%b`: number of blocks allocated (see %B) +-`%B`: the size in bytes of each block reported by %b +-`%C`: SELinux security context string +-`%d`: device number in decimal +-`%D`: device number in hex +-`%f`: raw mode in hex +-`%F`: file type +-`%g`: group ID of owner +-`%G`: group name of owner +-`%h`: number of hard links +-`%i`: inode number +-`%m`: mount point +-`%n`: file name +-`%N`: quoted file name with dereference (follow) if symbolic link +-`%o`: optimal I/O transfer size hint +-`%s`: total size, in bytes +-`%t`: major device type in hex, for character/block device special files +-`%T`: minor device type in hex, for character/block device special files +-`%u`: user ID of owner +-`%U`: user name of owner +-`%w`: time of file birth, human-readable; - if unknown +-`%W`: time of file birth, seconds since Epoch; 0 if unknown +-`%x`: time of last access, human-readable +-`%X`: time of last access, seconds since Epoch +-`%y`: time of last data modification, human-readable + +-`%Y`: time of last data modification, seconds since Epoch +-`%z`: time of last status change, human-readable +-`%Z`: time of last status change, seconds since Epoch + +Valid format sequences for file systems: + +-`%a`: free blocks available to non-superuser +-`%b`: total data blocks in file system +-`%c`: total file nodes in file system +-`%d`: free file nodes in file system +-`%f`: free blocks in file system +-`%i`: file system ID in hex +-`%l`: maximum length of filenames +-`%n`: file name +-`%s`: block size (for faster transfers) +-`%S`: fundamental block size (for block counts) +-`%t`: file system type in hex +-`%T`: file system type in human readable form + +NOTE: your shell may have its own version of stat, which usually supersedes +the version described here. Please refer to your shell's documentation +for details about the options it supports."; + + // pi-uutils: `translate!` error strings literalized from locales/en-US.ftl. + #[derive(Debug, Error)] + enum StatError { + #[error("Invalid quoting style: {style}")] + InvalidQuotingStyle { style: String }, + #[error("missing operand\nTry 'stat --help' for more information.")] + MissingOperand, + #[error("{directive}: invalid directive")] + InvalidDirective { directive: String }, + #[error("cannot read table of mounted file systems: {error}")] + CannotReadFilesystem { error: String }, + #[error("using '-' to denote standard input does not work in file system mode")] + StdinFilesystemMode, + #[error("cannot read file system information for {file}: {error}")] + CannotReadFilesystemInfo { file: String, error: String }, + #[error("cannot stat {file}: {error}")] + CannotStat { file: String, error: String }, + } + + impl UError for StatError { + fn code(&self) -> i32 { + 1 + } + } + + mod options { + pub const DEREFERENCE: &str = "dereference"; + pub const FILE_SYSTEM: &str = "file-system"; + pub const FORMAT: &str = "format"; + pub const PRINTF: &str = "printf"; + pub const TERSE: &str = "terse"; + pub const FILES: &str = "files"; + } + + #[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] + struct Flags { + alter: bool, + zero: bool, + left: bool, + space: bool, + sign: bool, + group: bool, + major: bool, + minor: bool, + } + + /// checks if the string is within the specified bound, + /// if it gets out of bound, error out by printing sub-string from index + /// `beg` to`end`, where `beg` & `end` is the beginning and end index of + /// sub-string, respectively + fn check_bound(slice: &str, bound: usize, beg: usize, end: usize) -> UResult<()> { + if end >= bound { + return Err(USimpleError::new( + 1, + StatError::InvalidDirective { directive: slice[beg..end].quote().to_string() } + .to_string(), + )); + } + Ok(()) + } + + enum Padding { + Zero, + Space, + } + + /// pads the string with zeroes or spaces and prints it + /// + /// # Example + /// ```ignore + /// uu_stat::pad_and_print("1", false, 5, Padding::Zero) == "00001"; + /// ``` + /// currently only supports '0' & ' ' as the padding character + /// because the format specification of print! does not support general + /// fill characters. + fn pad_and_print(result: &str, left: bool, width: usize, padding: Padding) { + // pi-uutils: write to the context stdout instead of `print!`. + let mut out = pi_uutils_ctx::stdout(); + let _ = match (left, padding) { + (false, Padding::Zero) => write!(out, "{result:0>width$}"), + (false, Padding::Space) => write!(out, "{result:>width$}"), + (true, Padding::Zero) => write!(out, "{result:0 write!(out, "{result:( + mut writer: W, + bytes: &[u8], + left: bool, + width: usize, + precision: Precision, + ) -> Result<(), std::io::Error> { + let display_bytes = match precision { + Precision::Number(p) if p < bytes.len() => &bytes[..p], + _ => bytes, + }; + + let display_len = display_bytes.len(); + let padding_needed = width.saturating_sub(display_len); + + let (left_pad, right_pad) = if left { + (0, padding_needed) + } else { + (padding_needed, 0) + }; + + if left_pad > 0 { + write_padding(&mut writer, left_pad)?; + } + writer.write_all(display_bytes)?; + if right_pad > 0 { + write_padding(&mut writer, right_pad)?; + } + + Ok(()) + } + + /// write padding based on a writer W and n size + /// writer is genric to be any buffer like: `std::io::stdout` + /// n is the calculated padding size + fn write_padding(writer: &mut W, n: usize) -> Result<(), std::io::Error> { + for _ in 0..n { + writer.write_all(b" ")?; + } + Ok(()) + } + + #[derive(Debug)] + pub enum OutputType<'a> { + Str(String), + OsStr(&'a OsString), + Integer(i64), + Unsigned(u64), + UnsignedHex(u64), + UnsignedOct(u32), + Float(f64), + Unknown, + } + + #[derive(Default)] + enum QuotingStyle { + Locale, + Shell, + #[default] + ShellEscapeAlways, + Quote, + } + + impl std::str::FromStr for QuotingStyle { + type Err = StatError; + + fn from_str(s: &str) -> Result { + match s { + "locale" => Ok(Self::Locale), + "shell" => Ok(Self::Shell), + "shell-escape-always" => Ok(Self::ShellEscapeAlways), + // The others aren't exposed to the user + _ => Err(StatError::InvalidQuotingStyle { style: s.to_string() }), + } + } + } + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + enum Precision { + NotSpecified, + NoNumber, + Number(usize), + } + + #[derive(Debug, PartialEq, Eq)] + enum Token { + Char(char), + Byte(u8), + Directive { flag: Flags, width: usize, precision: Precision, format: char }, + } + + trait ScanUtil { + fn scan_num(&self) -> Option<(F, usize)> + where + F: std::str::FromStr; + fn scan_char(&self, radix: u32) -> Option<(char, usize)>; + } + + impl ScanUtil for str { + /// Scans for a number at the beginning of the string + /// Returns the parsed number and the character count + /// Since we only deal with ASCII characters (+, -, 0-9), character count + /// equals byte count + fn scan_num(&self) -> Option<(F, usize)> + where + F: std::str::FromStr, + { + let mut chars = self.chars(); + let count = chars + .next() + .filter(|&c| c.is_ascii_digit() || c == '-' || c == '+') + .map_or(0, |_| 1 + chars.take_while(char::is_ascii_digit).count()); + + if count > 0 { + F::from_str(&self[..count]).ok().map(|x| (x, count)) + } else { + None + } + } + + fn scan_char(&self, radix: u32) -> Option<(char, usize)> { + let count = match radix { + 8 => 3, + 16 => 2, + _ => return None, + }; + let chars = self.chars().enumerate(); + let mut res = 0; + let mut offset = 0; + for (i, c) in chars { + if i >= count { + break; + } + match c.to_digit(radix) { + Some(digit) => { + let tmp = res * radix + digit; + if tmp < 256 { + res = tmp; + } else { + break; + } + }, + None => break, + } + offset = i + 1; + } + if offset > 0 { + Some((res as u8 as char, offset)) + } else { + None + } + } + } + + fn group_num(s: &str) -> Cow<'_, str> { + let is_negative = s.starts_with('-'); + assert!(is_negative || s.chars().take(1).all(|c| c.is_ascii_digit())); + assert!(s.chars().skip(1).all(|c| c.is_ascii_digit())); + if s.len() < 4 { + return s.into(); + } + let mut res = String::with_capacity((s.len() - 1) / 3); + let s = if is_negative { + res.push('-'); + &s[1..] + } else { + s + }; + let mut alone = (s.len() - 1) % 3 + 1; + res.push_str(&s[..alone]); + while alone != s.len() { + res.push(','); + res.push_str(&s[alone..alone + 3]); + alone += 3; + } + res.into() + } + + struct Stater { + follow: bool, + show_fs: bool, + from_user: bool, + files: Vec, + mount_list: OnceCell>>, + mount_list_needed: bool, + default_tokens: Vec, + default_dev_tokens: Vec, + } + + /// Prints a formatted output based on the provided output type, flags, + /// width, and precision. + /// + /// # Arguments + /// + /// * `output` - A reference to the [`OutputType`] enum containing the value + /// to be printed. + /// * `flags` - A Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed output. + /// * `precision` - How many digits of precision, if any. + /// + /// This function delegates the printing process to more specialized + /// functions depending on the output type. + fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Precision) { + // If the precision is given as just '.', the precision is taken to be zero. + // A negative precision is taken as if the precision were omitted. + // This gives the minimum number of digits to appear for d, i, o, u, x, and X + // conversions, the maximum number of characters to be printed from a string + // for s and S conversions. + + // # + // The value should be converted to an "alternate form". + // For o conversions, the first character of the output string is made zero + // (by prefixing a 0 if it was not zero already). For x and X conversions, a + // nonzero result has the string "0x" (or "0X" for X conversions) prepended to + // it. + + // 0 + // The value should be zero padded. + // For d, i, o, u, x, X, a, A, e, E, f, F, g, and G conversions, the converted + // value is padded on the left with zeros rather than blanks. If the 0 and - + // flags both appear, the 0 flag is ignored. If a precision is given with a + // numeric conversion (d, i, o, u, x, and X), the 0 flag is ignored. For other + // conversions, the behavior is undefined. + + // - + // The converted value is to be left adjusted on the field boundary. (The + // default is right justification.) The converted value is padded on the + // right with blanks, rather than on the left with blanks or zeros. + // A - overrides a 0 if both are given. + + // ' ' (a space) + // A blank should be left before a positive number (or empty string) produced by + // a signed conversion. + + // + + // A sign (+ or -) should always be placed before a number produced by a signed + // conversion. By default, a sign is used only for negative numbers. + // A + overrides a space if both are used. + let padding_char = determine_padding_char(flags); + + match output { + OutputType::Str(s) => print_str(s, flags, width, precision), + OutputType::OsStr(s) => print_os_str(s, flags, width, precision), + OutputType::Integer(num) => print_integer(*num, flags, width, precision, padding_char), + OutputType::Unsigned(num) => { + print_unsigned(*num, flags, width, precision, padding_char); + }, + OutputType::UnsignedOct(num) => { + print_unsigned_oct(*num, flags, width, precision, padding_char); + }, + OutputType::UnsignedHex(num) => { + print_unsigned_hex(*num, flags, width, precision, padding_char); + }, + OutputType::Float(num) => { + print_float(*num, flags, width, precision, padding_char); + }, + // pi-uutils: context stdout instead of `print!`. + OutputType::Unknown => { + let _ = write!(pi_uutils_ctx::stdout(), "?"); + }, + } + } + + /// Determines the padding character based on the provided flags and + /// precision. + /// + /// # Arguments + /// + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// + /// # Returns + /// + /// * Padding - An instance of the Padding enum representing the padding + /// character. + fn determine_padding_char(flags: Flags) -> Padding { + if flags.zero && !flags.left { + Padding::Zero + } else { + Padding::Space + } + } + + /// Prints a string value based on the provided flags, width, and precision. + /// + /// # Arguments + /// + /// * `s` - The string to be printed. + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed string. + /// * `precision` - How many digits of precision, if any. + fn print_str(s: &str, flags: Flags, width: usize, precision: Precision) { + let s = match precision { + Precision::Number(p) if p < s.len() => &s[..p], + _ => s, + }; + pad_and_print(s, flags.left, width, Padding::Space); + } + + /// Prints a `OsString` value based on the provided flags, width, and + /// precision. It converts the value to bytes and prints them; if that + /// fails, it prints the lossy string version. + /// + /// # Arguments + /// + /// * `s` - The `OsString` to be printed. + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed string. + /// * `precision` - How many digits of precision, if any. + fn print_os_str(s: &OsString, flags: Flags, width: usize, precision: Precision) { + // pi-uutils: this module is unix-only, so upstream's `cfg(not(unix))` + // lossy fallback branch is dropped; bytes go to the context stdout. + use std::os::unix::ffi::OsStrExt; + + let bytes = s.as_bytes(); + + if pad_and_print_bytes(pi_uutils_ctx::stdout(), bytes, flags.left, width, precision).is_err() + { + // if an error occurred while trying to print bytes fall back to normal lossy + // string so it can be printed + let fallback_string = s.to_string_lossy(); + print_str(&fallback_string, flags, width, precision); + } + } + + fn quote_file_name(file_name: &str, quoting_style: &QuotingStyle) -> String { + match quoting_style { + QuotingStyle::Locale | QuotingStyle::Shell => { + let escaped = file_name.replace('\'', r"\'"); + format!("'{escaped}'") + }, + QuotingStyle::ShellEscapeAlways => { + let quote = if file_name.contains('\'') { '"' } else { '\'' }; + format!("{quote}{file_name}{quote}") + }, + QuotingStyle::Quote => file_name.to_string(), + } + } + + fn get_quoted_file_name( + display_name: &str, + // pi-uutils: takes the operand resolved against the shell working + // directory for the `readlink` syscall; `display_name` stays as typed. + resolved: &Path, + file_type: FileType, + from_user: bool, + ) -> Result { + // pi-uutils: QUOTING_STYLE comes from the scope environment (the + // shell's exported variables), not the host process environment. + let quoting_style = pi_uutils_ctx::var("QUOTING_STYLE") + .and_then(|style| style.parse().ok()) + .unwrap_or_default(); + + if file_type.is_symlink() { + let quoted_display_name = quote_file_name(display_name, "ing_style); + match fs::read_link(resolved) { + Ok(dst) => { + let quoted_dst = quote_file_name(&dst.to_string_lossy(), "ing_style); + Ok(format!("{quoted_display_name} -> {quoted_dst}")) + }, + Err(e) => { + // pi-uutils: `show_error!` replaced with a context-stderr write. + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: {e}"); + Err(1) + }, + } + } else { + let style = if from_user { + quoting_style + } else { + QuotingStyle::Quote + }; + Ok(quote_file_name(display_name, &style)) + } + } + + fn process_token_filesystem(t: &Token, meta: &StatFs, display_name: &str) { + match *t { + Token::Byte(byte) => write_raw_byte(byte), + // pi-uutils: context stdout instead of `print!`. + Token::Char(c) => { + let _ = write!(pi_uutils_ctx::stdout(), "{c}"); + }, + Token::Directive { flag, width, precision, format } => { + let output = match format { + // free blocks available to non-superuser + 'a' => OutputType::Unsigned(meta.avail_blocks()), + // total data blocks in file system + 'b' => OutputType::Unsigned(meta.total_blocks()), + // total file nodes in file system + 'c' => OutputType::Unsigned(meta.total_file_nodes()), + // free file nodes in file system + 'd' => OutputType::Unsigned(meta.free_file_nodes()), + // free blocks in file system + 'f' => OutputType::Unsigned(meta.free_blocks()), + // file system ID in hex + 'i' => OutputType::UnsignedHex(meta.fsid()), + // maximum length of filenames + 'l' => OutputType::Unsigned(meta.namelen()), + // file name + 'n' => OutputType::Str(display_name.to_string()), + // block size (for faster transfers) + 's' => OutputType::Unsigned(meta.io_size()), + // fundamental block size (for block counts) + 'S' => OutputType::Integer(meta.block_size()), + // file system type in hex + 't' => OutputType::UnsignedHex(meta.fs_type() as u64), + // file system type in human readable form + 'T' => OutputType::Str(pretty_fstype(meta.fs_type()).into()), + _ => OutputType::Unknown, + }; + + print_it(&output, flag, width, precision); + }, + } + } + + /// Prints an integer value based on the provided flags, width, and + /// precision. + /// + /// # Arguments + /// + /// * `num` - The integer value to be printed. + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed integer. + /// * `precision` - How many digits of precision, if any. + /// * `padding_char` - The padding character as determined by + /// `determine_padding_char`. + fn print_integer( + num: i64, + flags: Flags, + width: usize, + precision: Precision, + padding_char: Padding, + ) { + let num = num.to_string(); + let arg = if flags.group { + group_num(&num) + } else { + Cow::Borrowed(num.as_str()) + }; + let prefix = if flags.sign { + "+" + } else if flags.space { + " " + } else { + "" + }; + let extended = match precision { + Precision::NotSpecified => format!("{prefix}{arg}"), + Precision::NoNumber => format!("{prefix}{arg}"), + Precision::Number(p) => format!("{prefix}{arg:0>p$}"), + }; + pad_and_print(&extended, flags.left, width, padding_char); + } + + /// Truncate a float to the given number of digits after the decimal point. + fn precision_trunc(num: f64, precision: Precision) -> String { + // GNU `stat` doesn't round, it just seems to truncate to the + // given precision: + // + // $ stat -c "%.5Y" /dev/pts/ptmx + // 1736344012.76399 + // $ stat -c "%.4Y" /dev/pts/ptmx + // 1736344012.7639 + // $ stat -c "%.3Y" /dev/pts/ptmx + // 1736344012.763 + // + // Contrast this with `printf`, which seems to round the + // numbers: + // + // $ printf "%.5f\n" 1736344012.76399 + // 1736344012.76399 + // $ printf "%.4f\n" 1736344012.76399 + // 1736344012.7640 + // $ printf "%.3f\n" 1736344012.76399 + // 1736344012.764 + // + let num_str = num.to_string(); + let n = num_str.len(); + match (num_str.find('.'), precision) { + (None, Precision::NotSpecified) => num_str, + (None, Precision::NoNumber) => num_str, + (None, Precision::Number(0)) => num_str, + (None, Precision::Number(p)) => format!("{num_str}.{zeros}", zeros = "0".repeat(p)), + (Some(i), Precision::NotSpecified) => num_str[..i].to_string(), + (Some(_), Precision::NoNumber) => num_str, + (Some(i), Precision::Number(0)) => num_str[..i].to_string(), + (Some(i), Precision::Number(p)) if p < n - i => num_str[..i + 1 + p].to_string(), + (Some(i), Precision::Number(p)) => { + format!("{num_str}{zeros}", zeros = "0".repeat(p - (n - i - 1))) + }, + } + } + + fn print_float( + num: f64, + flags: Flags, + width: usize, + precision: Precision, + padding_char: Padding, + ) { + let prefix = if flags.sign { + "+" + } else if flags.space { + " " + } else { + "" + }; + let num_str = precision_trunc(num, precision); + let extended = format!("{prefix}{num_str}"); + pad_and_print(&extended, flags.left, width, padding_char); + } + + /// Prints an unsigned integer value based on the provided flags, width, and + /// precision. + /// + /// # Arguments + /// + /// * `num` - The unsigned integer value to be printed. + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed unsigned integer. + /// * `precision` - How many digits of precision, if any. + /// * `padding_char` - The padding character as determined by + /// `determine_padding_char`. + fn print_unsigned( + num: u64, + flags: Flags, + width: usize, + precision: Precision, + padding_char: Padding, + ) { + let num = num.to_string(); + let s = if flags.group { + group_num(&num) + } else { + Cow::Borrowed(num.as_str()) + }; + let s = match precision { + Precision::NotSpecified => s, + Precision::NoNumber => s, + Precision::Number(p) => format!("{s:0>p$}").into(), + }; + pad_and_print(&s, flags.left, width, padding_char); + } + + /// Prints an unsigned octal integer value based on the provided flags, + /// width, and precision. + /// + /// # Arguments + /// + /// * `num` - The unsigned octal integer value to be printed. + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed unsigned octal + /// integer. + /// * `precision` - How many digits of precision, if any. + /// * `padding_char` - The padding character as determined by + /// `determine_padding_char`. + fn print_unsigned_oct( + num: u32, + flags: Flags, + width: usize, + precision: Precision, + padding_char: Padding, + ) { + let prefix = if flags.alter { "0" } else { "" }; + let s = match precision { + Precision::NotSpecified => format!("{prefix}{num:o}"), + Precision::NoNumber => format!("{prefix}{num:o}"), + Precision::Number(p) => format!("{prefix}{num:0>p$o}"), + }; + pad_and_print(&s, flags.left, width, padding_char); + } + + /// Prints an unsigned hexadecimal integer value based on the provided flags, + /// width, and precision. + /// + /// # Arguments + /// + /// * `num` - The unsigned hexadecimal integer value to be printed. + /// * `flags` - A reference to the Flags struct containing formatting flags. + /// * `width` - The width of the field for the printed unsigned hexadecimal + /// integer. + /// * `precision` - How many digits of precision, if any. + /// * `padding_char` - The padding character as determined by + /// `determine_padding_char`. + fn print_unsigned_hex( + num: u64, + flags: Flags, + width: usize, + precision: Precision, + padding_char: Padding, + ) { + let prefix = if flags.alter { "0x" } else { "" }; + let s = match precision { + Precision::NotSpecified => format!("{prefix}{num:x}"), + Precision::NoNumber => format!("{prefix}{num:x}"), + Precision::Number(p) => format!("{prefix}{num:0>p$x}"), + }; + pad_and_print(&s, flags.left, width, padding_char); + } + + fn write_raw_byte(byte: u8) { + // pi-uutils: context stdout instead of the process stdout, and no + // `unwrap` — an in-process builtin must not panic on a broken pipe. + let _ = pi_uutils_ctx::stdout().write_all(&[byte]); + } + + impl Stater { + fn process_flags(chars: &[char], i: &mut usize, bound: usize, flag: &mut Flags) { + while *i < bound { + match chars[*i] { + '#' => flag.alter = true, + '0' => flag.zero = true, + '-' => flag.left = true, + ' ' => flag.space = true, + // This is not documented but the behavior seems to be + // the same as a space. For example `stat -c "%I5s" f` + // prints " 0". + 'I' => flag.space = true, + '+' => flag.sign = true, + '\'' => flag.group = true, + _ => break, + } + *i += 1; + } + } + + /// Converts a character index to a byte index in a UTF-8 string + /// This is necessary because Rust strings are UTF-8 encoded, so character + /// positions don't always align with byte positions for multi-byte + /// characters + fn char_index_to_byte_index(format_str: &str, char_index: usize) -> usize { + format_str + .char_indices() + .nth(char_index) + .map_or(format_str.len(), |(byte_idx, _)| byte_idx) + } + + fn handle_percent_case( + chars: &[char], + i: &mut usize, + bound: usize, + format_str: &str, + ) -> UResult { + let old = *i; + + *i += 1; + if *i >= bound { + return Ok(Token::Char('%')); + } + if chars[*i] == '%' { + return Ok(Token::Char('%')); + } + + let mut flag = Flags::default(); + + Self::process_flags(chars, i, bound, &mut flag); + + let mut width = 0; + let mut precision = Precision::NotSpecified; + let mut j = *i; + + let j_byte = Self::char_index_to_byte_index(format_str, j); + if let Some((field_width, offset)) = format_str[j_byte..].scan_num::() { + width = field_width; + j += offset; + + // Reject directives like `%` by checking if width has been parsed. + if j >= bound || chars[j] == '%' { + let invalid_directive: String = chars[old..=j.min(bound - 1)].iter().collect(); + return Err(USimpleError::new( + 1, + StatError::InvalidDirective { directive: invalid_directive.quote().to_string() } + .to_string(), + )); + } + } + check_bound(format_str, bound, old, j)?; + + if chars[j] == '.' { + j += 1; + check_bound(format_str, bound, old, j)?; + + let j_byte = Self::char_index_to_byte_index(format_str, j); + match format_str[j_byte..].scan_num::() { + Some((value, offset)) => { + if value >= 0 { + precision = Precision::Number(value as usize); + } + j += offset; + }, + None => precision = Precision::NoNumber, + } + check_bound(format_str, bound, old, j)?; + } + + *i = j; + + // Check for multi-character specifiers (e.g., `%Hd`, `%Lr`) + if *i + 1 < bound + && let Some(&next_char) = chars.get(*i + 1) + && (chars[*i] == 'H' || chars[*i] == 'L') + && (next_char == 'd' || next_char == 'r') + { + flag.major = chars[*i] == 'H'; + flag.minor = chars[*i] == 'L'; + *i += 1; + return Ok(Token::Directive { flag, width, precision, format: next_char }); + } + + Ok(Token::Directive { flag, width, precision, format: chars[*i] }) + } + + fn handle_escape_sequences( + chars: &[char], + i: &mut usize, + bound: usize, + format_str: &str, + ) -> Token { + *i += 1; + if *i >= bound { + // pi-uutils: `show_warning!` replaced with a context-stderr + // write; message literalized from locales/en-US.ftl. + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: warning: backslash at end of format"); + return Token::Char('\\'); + } + match chars[*i] { + 'a' => Token::Byte(0x07), // BEL + 'b' => Token::Byte(0x08), // Backspace + 'f' => Token::Byte(0x0c), // Form feed + 'n' => Token::Byte(0x0a), // Line feed + 'r' => Token::Byte(0x0d), // Carriage return + 't' => Token::Byte(0x09), // Horizontal tab + '\\' => Token::Byte(b'\\'), // Backslash + '\'' => Token::Byte(b'\''), // Single quote + '"' => Token::Byte(b'"'), // Double quote + '0'..='7' => { + // Parse octal escape sequence (up to 3 digits) + let mut value = 0u8; + let mut count = 0; + while *i < bound && count < 3 { + if let Some(digit) = chars[*i].to_digit(8) { + value = value * 8 + digit as u8; + *i += 1; + count += 1; + } else { + break; + } + } + *i -= 1; // Adjust index to account for the outer loop increment + Token::Byte(value) + }, + 'x' => { + // Parse hexadecimal escape sequence (\xNN format) + // Uses UTF-8 safe byte indexing to handle multi-byte characters properly + if *i + 1 < bound { + let byte_index = Self::char_index_to_byte_index(format_str, *i + 1); + if let Some((c, offset)) = format_str[byte_index..].scan_char(16) { + *i += offset; + Token::Byte(c as u8) + } else { + // pi-uutils: `show_warning!` replaced with a + // context-stderr write. + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: warning: unrecognized escape '\\x'" + ); + Token::Byte(b'x') + } + } else { + // pi-uutils: `show_warning!` replaced with a + // context-stderr write. + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: warning: incomplete hex escape '\\x'" + ); + Token::Byte(b'x') + } + }, + other => { + // pi-uutils: `show_warning!` replaced with a context-stderr + // write. + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: warning: unrecognized escape '\\{other}'" + ); + Token::Byte(other as u8) + }, + } + } + + fn generate_tokens(format_str: &str, use_printf: bool) -> UResult> { + let mut tokens = Vec::new(); + let chars = format_str.chars().collect::>(); + let bound = chars.len(); + let mut i = 0; + while i < bound { + match chars.get(i) { + Some('%') => { + tokens.push(Self::handle_percent_case(&chars, &mut i, bound, format_str)?); + }, + Some('\\') => { + if use_printf { + tokens.push(Self::handle_escape_sequences(&chars, &mut i, bound, format_str)); + } else { + tokens.push(Token::Char('\\')); + } + }, + Some(c) => tokens.push(Token::Char(*c)), + None => break, + } + i += 1; + } + if !use_printf && !format_str.ends_with('\n') { + tokens.push(Token::Char('\n')); + } + Ok(tokens) + } + + fn populate_mount_list() -> UResult> { + let mut mount_list = read_fs_list() + .map_err(|e| { + USimpleError::new( + e.code(), + StatError::CannotReadFilesystem { error: e.to_string() }.to_string(), + ) + })? + .iter() + .map(|mi| mi.mount_dir.clone()) + .collect::>(); + + // Reverse sort. The longer comes first. + mount_list.sort(); + mount_list.reverse(); + + Ok(mount_list) + } + + fn new(matches: &ArgMatches) -> UResult { + let files: Vec = matches + .get_many::(options::FILES) + .map(|v| v.map(OsString::from).collect()) + .unwrap_or_default(); + if files.is_empty() { + return Err(Box::new(StatError::MissingOperand) as Box); + } + let format_str = if matches.contains_id(options::PRINTF) { + matches + .get_one::(options::PRINTF) + .expect("Invalid format string") + } else { + matches + .get_one::(options::FORMAT) + .map_or("", |s| s.as_str()) + }; + + let use_printf = matches.contains_id(options::PRINTF); + let terse = matches.get_flag(options::TERSE); + let show_fs = matches.get_flag(options::FILE_SYSTEM); + + let default_tokens = if format_str.is_empty() { + Self::generate_tokens(&Self::default_format(show_fs, terse, false), use_printf)? + } else { + Self::generate_tokens(format_str, use_printf)? + }; + let default_dev_tokens = + Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf)?; + + // mount points aren't displayed when showing filesystem information, or + // whenever the format string does not request the mount point. + let mount_list_needed = !show_fs + && default_tokens + .iter() + .any(|tok| matches!(tok, Token::Directive { format: 'm', .. })); + + Ok(Self { + follow: matches.get_flag(options::DEREFERENCE), + show_fs, + from_user: !format_str.is_empty(), + files, + mount_list: OnceCell::new(), + mount_list_needed, + default_tokens, + default_dev_tokens, + }) + } + + fn find_mount_point>(&self, p: P) -> Option<&OsString> { + if !self.mount_list_needed { + return None; + } + + let mount_list = self.mount_list.get_or_init(|| { + match Self::populate_mount_list() { + Ok(list) => Some(list), + Err(e) => { + // Show warning like GNU does when mount information cannot be read + // pi-uutils: `show_warning!` replaced with a + // context-stderr write. + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: warning: cannot read table of mounted file systems: {e}" + ); + None + }, + } + }); + + let path = p.as_ref().canonicalize().ok()?; + mount_list + .as_ref()? + .iter() + .find(|root| path.starts_with(root)) + } + + fn exec(&self) -> i32 { + let mut stdin_is_fifo = false; + if let Ok(md) = fs::metadata("/dev/stdin") { + stdin_is_fifo = md.file_type().is_fifo(); + } + + let mut ret = 0; + for f in &self.files { + ret |= self.do_stat(f, stdin_is_fifo); + } + ret + } + + fn process_token_files( + &self, + t: &Token, + meta: &Metadata, + display_name: &str, + // pi-uutils: takes the operand resolved against the shell working + // directory for the `%m`/`%N` syscalls (upstream passed the raw + // operand); display output keeps `display_name` as typed. The + // SELinux `follow_symbolic_links` parameter is dropped along with + // SELinux support. + resolved: &Path, + file_type: FileType, + from_user: bool, + ) -> Result<(), i32> { + match *t { + Token::Byte(byte) => write_raw_byte(byte), + // pi-uutils: context stdout instead of `print!`. + Token::Char(c) => { + let _ = write!(pi_uutils_ctx::stdout(), "{c}"); + }, + + Token::Directive { flag, width, precision, format } => { + let output = match format { + // access rights in octal + 'a' => OutputType::UnsignedOct(0o7777 & meta.mode()), + // access rights in human readable form + 'A' => OutputType::Str(display_permissions(meta, true)), + // number of blocks allocated (see %B) + 'b' => OutputType::Unsigned(meta.blocks()), + + // the size in bytes of each block reported by %b + // FIXME: blocksize differs on various platform + // See coreutils/gnulib/lib/stat-size.h ST_NBLOCKSIZE // + // spell-checker:disable-line + 'B' => OutputType::Unsigned(512), + // SELinux security context string + // pi-uutils: SELinux support is dropped; this is + // upstream's non-SELinux fallback string. + 'C' => OutputType::Str("unsupported for this operating system".to_string()), + // device number in decimal + 'd' if flag.major => OutputType::Unsigned(major(meta.dev() as _) as u64), + 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev() as _) as u64), + 'd' => OutputType::Unsigned(meta.dev()), + // device number in hex + 'D' => OutputType::UnsignedHex(meta.dev()), + // raw mode in hex + 'f' => OutputType::UnsignedHex(meta.mode() as u64), + // file type + 'F' => OutputType::Str(pretty_filetype(meta.mode() as mode_t, meta.len())), + // group ID of owner + 'g' => OutputType::Unsigned(meta.gid() as u64), + // group name of owner + 'G' => { + let group_name = + entries::gid2grp(meta.gid()).unwrap_or_else(|_| "UNKNOWN".to_owned()); + OutputType::Str(group_name) + }, + // number of hard links + 'h' => OutputType::Unsigned(meta.nlink()), + // inode number + 'i' => OutputType::Unsigned(meta.ino()), + // mount point + 'm' => match self.find_mount_point(resolved) { + Some(s) => OutputType::OsStr(s), + None => OutputType::Str(String::new()), + }, + // file name + 'n' => OutputType::Str(display_name.to_string()), + // quoted file name with dereference if symbolic link + 'N' => { + let file_name = + get_quoted_file_name(display_name, resolved, file_type, from_user)?; + OutputType::Str(file_name) + }, + // optimal I/O transfer size hint + 'o' => OutputType::Unsigned(meta.blksize()), + // total size, in bytes + 's' => OutputType::Integer(meta.len() as i64), + // major device type in hex, for character/block device special + // files + 't' => OutputType::UnsignedHex(major(meta.rdev() as _) as u64), + // minor device type in hex, for character/block device special + // files + 'T' => OutputType::UnsignedHex(minor(meta.rdev() as _) as u64), + // user ID of owner + 'u' => OutputType::Unsigned(meta.uid() as u64), + // user name of owner + 'U' => { + let user_name = + entries::uid2usr(meta.uid()).unwrap_or_else(|_| "UNKNOWN".to_owned()); + OutputType::Str(user_name) + }, + + // time of file birth, human-readable; - if unknown + 'w' => OutputType::Str(pretty_time(meta, MetadataTimeField::Birth)), + + // time of file birth, seconds since Epoch; 0 if unknown + 'W' => OutputType::Integer( + metadata_get_time(meta, MetadataTimeField::Birth) + .map_or(0, |x| system_time_to_sec(x).0), + ), + + // time of last access, human-readable + 'x' => OutputType::Str(pretty_time(meta, MetadataTimeField::Access)), + // time of last access, seconds since Epoch + 'X' => { + let (sec, nsec) = metadata_get_time(meta, MetadataTimeField::Access) + .map_or((0, 0), system_time_to_sec); + OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + }, + // time of last data modification, human-readable + 'y' => OutputType::Str(pretty_time(meta, MetadataTimeField::Modification)), + // time of last data modification, seconds since Epoch + 'Y' => { + let (sec, nsec) = metadata_get_time(meta, MetadataTimeField::Modification) + .map_or((0, 0), system_time_to_sec); + OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + }, + // time of last status change, human-readable + 'z' => OutputType::Str(pretty_time(meta, MetadataTimeField::Change)), + // time of last status change, seconds since Epoch + 'Z' => { + let (sec, nsec) = metadata_get_time(meta, MetadataTimeField::Change) + .map_or((0, 0), system_time_to_sec); + OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + }, + 'R' => OutputType::UnsignedHex(meta.rdev()), + 'r' if flag.major => OutputType::Unsigned(major(meta.rdev() as _) as u64), + 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev() as _) as u64), + 'r' => OutputType::Unsigned(meta.rdev()), + _ => OutputType::Unknown, + }; + print_it(&output, flag, width, precision); + }, + } + Ok(()) + } + + fn do_stat(&self, file: &OsStr, stdin_is_fifo: bool) -> i32 { + let display_name = file.to_string_lossy(); + let file = if display_name == "-" { + if self.show_fs { + // pi-uutils: `show_error!` replaced with a context-stderr + // write. + let _ = + writeln!(pi_uutils_ctx::stderr(), "stat: {}", StatError::StdinFilesystemMode); + return 1; + } + if let Ok(p) = Path::new("/dev/stdin").canonicalize() { + p.into_os_string() + } else { + OsString::from("/dev/stdin") + } + } else { + OsString::from(file) + }; + // pi-uutils: resolve the operand against the shell working + // directory for every syscall below; `display_name` keeps the + // operand as typed for `%n` and error messages. + let resolved = pi_uutils_ctx::resolve(&file); + if self.show_fs { + match statfs(resolved.as_os_str()) { + Ok(meta) => { + let tokens = &self.default_tokens; + + // Usage + for t in tokens { + process_token_filesystem(t, &meta, &display_name); + } + }, + Err(error) => { + // pi-uutils: `show_error!` replaced with a + // context-stderr write. + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: {}", + StatError::CannotReadFilesystemInfo { + file: display_name.quote().to_string(), + error, + } + ); + return 1; + }, + } + } else { + let follow_symbolic_links = self.follow || stdin_is_fifo && display_name == "-"; + let result = if follow_symbolic_links { + fs::metadata(&resolved) + } else { + fs::symlink_metadata(&resolved) + }; + match result { + Ok(meta) => { + let file_type = meta.file_type(); + let tokens = if self.from_user + || !(file_type.is_char_device() || file_type.is_block_device()) + { + &self.default_tokens + } else { + &self.default_dev_tokens + }; + + for t in tokens { + if let Err(code) = self.process_token_files( + t, + &meta, + &display_name, + &resolved, + file_type, + self.from_user, + ) { + return code; + } + } + }, + Err(e) => { + // pi-uutils: `show_error!` replaced with a + // context-stderr write. + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: {}", StatError::CannotStat { + file: display_name.quote().to_string(), + error: e.to_string(), + }); + return 1; + }, + } + } + 0 + } + + fn default_format(show_fs: bool, terse: bool, show_dev_type: bool) -> String { + // SELinux related format is *ignored* + // pi-uutils: `translate!` word lookups literalized from + // locales/en-US.ftl. + + if show_fs { + if terse { + "%n %i %l %t %s %S %b %f %a %c %d\n".into() + } else { + " File: \"%n\"\n ID: %-8i Namelen: %-7l Type: %T\nBlock size: %-10s Fundamental \ + block size: %S\nBlocks: Total: %-10b Free: %-10f Available: %a\nInodes: Total: \ + %-10c Free: %d\n" + .into() + } + } else if terse { + "%n %s %b %f %u %g %D %i %h %t %T %X %Y %Z %W %o\n".into() + } else { + let device_line = if show_dev_type { + "Device: %Hd,%Ld\tInode: %-10i Links: %-5h Device type: %t,%T\n" + } else { + "Device: %Hd,%Ld\tInode: %-10i Links: %h\n" + }; + + format!( + " File: %N\n size: %-10s\tBlocks: %-10b IO Block: %-6o %F\n{device_line}Access: \ + (%04a/%10.10A) Uid: (%5u/%8U) Gid: (%5g/%8G)\nAccess: %x\nModify: %y\nChange: \ + %z\n Birth: %w\n" + ) + } + } + } + + /// pi-uutils: BSD `stat -f FORMAT` compatibility (macOS muscle memory). + /// + /// BSD stat's `-f` takes a format string (`stat -f "%Sm %N" file`), while + /// GNU's `-f` is `--file-system`; parsed as GNU, a BSD invocation prints + /// filesystem info for each real operand and errors on the format operand. + /// An invocation is treated as BSD when a `-f` cluster (optionally with the + /// BSD boolean flags `L`/`n`/`q`/`F`) carries a format value containing + /// `%` — GNU filesystem mode would have to target a file literally named + /// like a format string, which never happens in practice. Detected + /// invocations are rewritten to the GNU equivalent (`-c`/`--printf` plus a + /// translated format) before clap parsing. + /// + /// Returns `None` when the invocation is not BSD-shaped, `Some(Err(_))` + /// when it is BSD-shaped but uses an option or directive with no GNU + /// counterpart. + fn rewrite_bsd_invocation(argv: &[OsString]) -> Option, String>> { + let toks: Vec> = argv.iter().map(|a| a.to_string_lossy()).collect(); + let mut detected = false; + for (idx, tok) in toks.iter().enumerate().skip(1) { + if tok.as_ref() == "--" { + break; + } + let Some(cluster) = tok.strip_prefix('-') else { + continue; + }; + if cluster.is_empty() || cluster.starts_with('-') { + continue; + } + let Some(fpos) = cluster.find('f') else { + continue; + }; + if !cluster[..fpos] + .chars() + .all(|c| matches!(c, 'L' | 'n' | 'q' | 'F')) + { + continue; + } + let attached = &cluster[fpos + 1..]; + let format = if attached.is_empty() { + toks.get(idx + 1).map(Cow::as_ref) + } else { + Some(attached) + }; + if format.is_some_and(|f| f.contains('%')) { + detected = true; + break; + } + } + if !detected { + return None; + } + Some(bsd_to_gnu_argv(argv, &toks)) + } + + /// Parses a detected BSD invocation and produces the equivalent GNU argv. + fn bsd_to_gnu_argv(argv: &[OsString], toks: &[Cow<'_, str>]) -> Result, String> { + let mut follow = false; + let mut no_newline = false; + let mut format = None; + let mut timefmt_ignored = false; + let mut files: Vec = Vec::new(); + + let mut i = 1; + while i < toks.len() { + if toks[i].as_ref() == "--" { + files.extend_from_slice(&argv[i + 1..]); + break; + } + let cluster: Vec = match toks[i].strip_prefix('-') { + Some(c) if !c.is_empty() && !c.starts_with('-') => c.chars().collect(), + // Operands keep the original (possibly non-UTF8) bytes. + _ => { + files.push(argv[i].clone()); + i += 1; + continue; + }, + }; + let mut consumed_next = false; + let mut k = 0; + while k < cluster.len() { + match cluster[k] { + 'L' => follow = true, + 'n' => no_newline = true, + // `-q` (suppress error messages) and `-F` (ls -F type + // decorations) have no GNU counterpart worth emulating. + 'q' | 'F' => {}, + c @ ('f' | 't') => { + // The rest of the cluster is the attached value, + // otherwise the next token is. + let value: String = if k + 1 < cluster.len() { + cluster[k + 1..].iter().collect() + } else { + consumed_next = true; + match toks.get(i + 1) { + Some(v) => v.to_string(), + None => return Err(format!("option '-{c}' requires an argument")), + } + }; + if c == 'f' { + format = Some(value); + } else { + timefmt_ignored = true; + } + break; + }, + other => { + return Err(format!( + "option '-{other}' is not supported (BSD stat compatibility)" + )); + }, + } + k += 1; + } + i += 1 + usize::from(consumed_next); + } + + let Some(format) = format else { + return Err("BSD-style '-f' expects a format string".to_string()); + }; + if timefmt_ignored { + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: warning: BSD '-t' time format is ignored; human-readable times use the GNU \ + default format" + ); + } + let translated = translate_bsd_format(&format, no_newline)?; + + let mut out: Vec = Vec::with_capacity(files.len() + 4); + out.push(argv[0].clone()); + if follow { + out.push("-L".into()); + } + // `--printf` suppresses the mandatory trailing newline (BSD `-n`); the + // translator escapes literal backslashes so text survives printf mode. + out.push(if no_newline { + "--printf".into() + } else { + "-c".into() + }); + out.push(translated.into()); + out.extend(files); + Ok(out) + } + + /// Translates a BSD stat format string into the GNU format language used by + /// this implementation. Directives with no GNU counterpart (`%f` user + /// flags, `%v` inode generation, `%Y` symlink target, ...) are rejected. + /// With `printf_mode` set, literal backslashes are escaped so the result + /// survives `--printf` escape processing unchanged. + fn translate_bsd_format(fmt: &str, printf_mode: bool) -> Result { + let chars: Vec = fmt.chars().collect(); + let mut out = String::with_capacity(fmt.len() + 8); + let mut i = 0; + while i < chars.len() { + if chars[i] != '%' { + if printf_mode && chars[i] == '\\' { + out.push_str(r"\\"); + } else { + out.push(chars[i]); + } + i += 1; + continue; + } + let start = i; + i += 1; + if i >= chars.len() { + out.push('%'); + break; + } + if chars[i] == '%' { + out.push_str("%%"); + i += 1; + continue; + } + // Flags, width, and precision use the same syntax in both format + // languages; copy them through verbatim. + let mut spec = String::new(); + while i < chars.len() && matches!(chars[i], '#' | '+' | '-' | '0' | ' ') { + spec.push(chars[i]); + i += 1; + } + while i < chars.len() && chars[i].is_ascii_digit() { + spec.push(chars[i]); + i += 1; + } + if i < chars.len() && chars[i] == '.' { + spec.push('.'); + i += 1; + while i < chars.len() && chars[i].is_ascii_digit() { + spec.push(chars[i]); + i += 1; + } + } + // BSD grammar: %[flags][width][.prec][fmt][sub]datum, with + // fmt ∈ {D,O,U,X,F,S} (output representation) and sub ∈ {H,M,L} + // (datum sub-field). Only `S` ("string form") changes the GNU + // mapping; the numeric representations keep GNU's defaults. + let mut string_form = false; + if i < chars.len() && matches!(chars[i], 'D' | 'O' | 'U' | 'X' | 'F' | 'S') { + string_form = chars[i] == 'S'; + i += 1; + } + let mut sub = None; + if i < chars.len() && matches!(chars[i], 'H' | 'M' | 'L') { + sub = Some(chars[i]); + i += 1; + } + let Some(&datum) = chars.get(i) else { + return Err(unsupported_bsd_directive(&chars[start..])); + }; + i += 1; + let gnu: &str = match datum { + // Times: mtime / atime / ctime / birth; `S` selects the + // human-readable form, otherwise seconds since Epoch. + 'm' => { + if string_form { + "y" + } else { + "Y" + } + }, + 'a' => { + if string_form { + "x" + } else { + "X" + } + }, + 'c' => { + if string_form { + "z" + } else { + "Z" + } + }, + 'B' => { + if string_form { + "w" + } else { + "W" + } + }, + // File name as typed. + 'N' => "n", + // Size in bytes. + 'z' => "s", + // Owner / group: numeric, or (`S`) by name. + 'u' => { + if string_form { + "U" + } else { + "u" + } + }, + 'g' => { + if string_form { + "G" + } else { + "g" + } + }, + // Permissions: octal bits, or (`S`) the human-readable form. + 'p' if string_form => "A", + 'p' if matches!(sub, None | Some('L')) => "a", + // Inode, hard links, device, rdev, blocks, block size. + 'i' => "i", + 'l' => "h", + 'd' => match sub { + Some('H') => "Hd", + Some('L') => "Ld", + None => "d", + Some(_) => return Err(unsupported_bsd_directive(&chars[start..i])), + }, + 'r' => match sub { + Some('H') => "Hr", + Some('L') => "Lr", + None => "r", + Some(_) => return Err(unsupported_bsd_directive(&chars[start..i])), + }, + 'b' => "b", + 'k' => "o", + // File type, human readable (`%HT` / `%T`). + 'T' => "F", + // `%n` and `%t` are literal newline / tab in BSD formats. + 'n' => { + out.push('\n'); + continue; + }, + 't' => { + out.push('\t'); + continue; + }, + _ => return Err(unsupported_bsd_directive(&chars[start..i])), + }; + out.push('%'); + out.push_str(&spec); + out.push_str(gnu); + } + Ok(out) + } + + fn unsupported_bsd_directive(directive: &[char]) -> String { + let directive: String = directive.iter().collect(); + format!("unsupported BSD format directive '{directive}'") + } + + /// In-process builtin entry point. Unlike upstream's `uumain`, this parses + /// the arguments directly (without the uucore clap-localization helper that + /// would terminate the process), renders clap help/usage/version to the + /// context streams, and maps the `UResult` to an exit code, so it is safe + /// to run inside the host shell process. + pub fn run(argv: Vec) -> i32 { + // pi-uutils: translate BSD-style `stat -f FORMAT` invocations into GNU + // form before parsing; see `rewrite_bsd_invocation`. + let argv = match rewrite_bsd_invocation(&argv) { + None => argv, + Some(Ok(rewritten)) => rewritten, + Some(Err(msg)) => { + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: {msg}"); + return 1; + }, + }; + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match stat_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + // pi-uutils: `do_stat` already reports its errors to the + // context stderr and surfaces a bare exit-code error that + // renders to an empty message; don't emit a dangling + // "stat: " prefix for it. + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } + } + + fn stat_main(matches: &ArgMatches) -> UResult<()> { + let stater = Stater::new(matches)?; + let exit_status = stater.exec(); + if exit_status == 0 { + Ok(()) + } else { + Err(exit_status.into()) + } + } + + pub fn uu_app() -> Command { + Command::new("stat") + .version(uucore::crate_version!()) + .about(ABOUT) + .after_help(AFTER_HELP) + .override_usage(format_usage(USAGE)) + .infer_long_args(true) + .arg( + Arg::new(options::DEREFERENCE) + .short('L') + .long(options::DEREFERENCE) + .help("follow links") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::FILE_SYSTEM) + .short('f') + .long(options::FILE_SYSTEM) + .help("display file system status instead of file status") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::TERSE) + .short('t') + .long(options::TERSE) + .help("print the information in terse form") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::FORMAT) + .short('c') + .long(options::FORMAT) + .help( + "use the specified FORMAT instead of the default;\noutput a newline after each \ + use of FORMAT", + ) + .value_name("FORMAT"), + ) + .arg( + Arg::new(options::PRINTF) + .long(options::PRINTF) + .value_name("FORMAT") + .help( + "like --format, but interpret backslash escapes,\nand do not output a mandatory \ + trailing newline;\nif you want a newline, include \\n in FORMAT", + ), + ) + .arg( + Arg::new(options::FILES) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()) + .value_hint(clap::ValueHint::FilePath), + ) + } + + const PRETTY_DATETIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S.%N %z"; + + fn pretty_time(meta: &Metadata, md_time_field: MetadataTimeField) -> String { + if let Some(time) = metadata_get_time(meta, md_time_field) { + let mut tmp = Vec::new(); + if format_system_time( + &mut tmp, + time, + PRETTY_DATETIME_FORMAT, + FormatSystemTimeFallback::Float, + ) + .is_ok() + { + return String::from_utf8(tmp).unwrap(); + } + } + "-".to_string() + } + + /// Upstream format-parser unit tests, kept because the token parser is the + /// most intricate part of the utility and the print paths were repatched. + #[cfg(test)] + mod unit_tests { + use super::{Flags, Precision, ScanUtil, Stater, Token, group_num, precision_trunc}; + + #[test] + fn test_scanners() { + assert_eq!(Some((-5, 2)), "-5zxc".scan_num::()); + assert_eq!(Some((51, 2)), "51zxc".scan_num::()); + assert_eq!(Some((192, 4)), "+192zxc".scan_num::()); + assert_eq!(None, "z192zxc".scan_num::()); + + assert_eq!(Some(('a', 3)), "141zxc".scan_char(8)); + assert_eq!(Some(('\n', 2)), "12qzxc".scan_char(8)); // spell-checker:disable-line + assert_eq!(Some(('\r', 1)), "dqzxc".scan_char(16)); // spell-checker:disable-line + assert_eq!(None, "z2qzxc".scan_char(8)); // spell-checker:disable-line + } + + #[test] + fn test_group_num() { + assert_eq!("12,379,821,234", group_num("12379821234")); + assert_eq!("821,234", group_num("821234")); + assert_eq!("1,234", group_num("1234")); + assert_eq!("234", group_num("234")); + assert_eq!("", group_num("")); + assert_eq!("-5", group_num("-5")); + assert_eq!("-1,234", group_num("-1234")); + } + + #[test] + fn normal_format() { + let s = "%'010.2ac%-#5.w\n"; + let expected = vec![ + Token::Directive { + flag: Flags { group: true, zero: true, ..Default::default() }, + width: 10, + precision: Precision::Number(2), + format: 'a', + }, + Token::Char('c'), + Token::Directive { + flag: Flags { left: true, alter: true, ..Default::default() }, + width: 5, + precision: Precision::NoNumber, + format: 'w', + }, + Token::Char('\n'), + ]; + assert_eq!(&expected, &Stater::generate_tokens(s, false).unwrap()); + } + + #[test] + fn printf_format() { + let s = r#"%-# 15a\t\r\"\\\a\b\x1B\f\x0B%+020.-23w\x12\167\132\112\n"#; + let expected = vec![ + Token::Directive { + flag: Flags { left: true, alter: true, space: true, ..Default::default() }, + width: 15, + precision: Precision::NotSpecified, + format: 'a', + }, + Token::Byte(b'\t'), + Token::Byte(b'\r'), + Token::Byte(b'"'), + Token::Byte(b'\\'), + Token::Byte(b'\x07'), + Token::Byte(b'\x08'), + Token::Byte(b'\x1B'), + Token::Byte(b'\x0C'), + Token::Byte(b'\x0B'), + Token::Directive { + flag: Flags { sign: true, zero: true, ..Default::default() }, + width: 20, + precision: Precision::NotSpecified, + format: 'w', + }, + Token::Byte(b'\x12'), + Token::Byte(b'w'), + Token::Byte(b'Z'), + Token::Byte(b'J'), + Token::Byte(b'\n'), + ]; + assert_eq!(&expected, &Stater::generate_tokens(s, true).unwrap()); + } + + #[test] + fn test_precision_trunc() { + assert_eq!(precision_trunc(123.456, Precision::NotSpecified), "123"); + assert_eq!(precision_trunc(123.456, Precision::NoNumber), "123.456"); + assert_eq!(precision_trunc(123.456, Precision::Number(0)), "123"); + assert_eq!(precision_trunc(123.456, Precision::Number(1)), "123.4"); + assert_eq!(precision_trunc(123.456, Precision::Number(5)), "123.45600"); + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::{collections::HashMap, ffi::OsString, fs, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::run; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("stat") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var, + /// which mount-point/canonicalize logic would otherwise expand + /// mid-assertion). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[test] + fn resolves_relative_operand_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"hello world!").unwrap(); + + // Relative operand + scope cwd differing from the process cwd: only the + // call-site `pi_uutils_ctx::resolve` patch makes this find the file. + let (code, stdout, stderr) = run_in(root, vec!["-c", "%s", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stdout, "12\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn percent_n_prints_operand_as_typed() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"x").unwrap(); + + // GNU prints the file name exactly as typed, not the resolved path. + let (code, stdout, stderr) = run_in(root, vec!["-c", "%n", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stdout, "data.bin\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn dereference_switches_between_link_and_target() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("target"), b"abc").unwrap(); + std::os::unix::fs::symlink("target", root.join("link")).unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-c", "%F", "link"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "symbolic link\n", "")); + + let (code, stdout, stderr) = run_in(root, vec!["-L", "-c", "%F", "link"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "regular file\n", "")); + } + + #[test] + fn nonexistent_file_reports_cannot_stat() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, vec!["missing"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.starts_with("stat: cannot stat 'missing':"), "unexpected stderr: {stderr:?}"); + } + + #[test] + fn file_system_mode_succeeds() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root, vec!["-f", "-c", "%S", "."]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + assert!( + stdout.trim_end().parse::().is_ok(), + "fundamental block size should be numeric: {stdout:?}" + ); + } + + #[test] + fn printf_controls_trailing_newline_and_escapes() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"hello world!").unwrap(); + + // --printf emits no mandatory trailing newline... + let (code, stdout, _) = run_in(root.clone(), vec!["--printf", "%s", "data.bin"]); + assert_eq!((code, stdout.as_str()), (0, "12")); + + // ...but interprets backslash escapes. + let (code, stdout, _) = run_in(root, vec!["--printf", r"%s\t%n\n", "data.bin"]); + assert_eq!((code, stdout.as_str()), (0, "12\tdata.bin\n")); + } + + #[test] + fn terse_prints_name_as_typed_and_size() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"hello world!").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-t", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let fields: Vec<&str> = stdout.split_whitespace().collect(); + assert_eq!(fields[0], "data.bin"); + assert_eq!(fields[1], "12"); + assert_eq!(fields.len(), 16, "terse format has 16 fields: {stdout:?}"); + } + + #[test] + fn missing_operand_is_error() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec![]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.contains("stat: missing operand"), "unexpected stderr: {stderr:?}"); + assert!(stderr.contains("Try 'stat --help'"), "unexpected stderr: {stderr:?}"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("file system status")); + assert_eq!(stderr, ""); + } + + #[test] + fn bsd_dash_f_format_is_translated() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"hello world!").unwrap(); + + // macOS `stat -f "%Sm %N"`: BSD `-f` takes a format; the invocation is + // detected and translated instead of being parsed as `--file-system`. + let (code, stdout, stderr) = run_in(root.clone(), vec!["-f", "%Sm %N", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + assert!(stdout.ends_with(" data.bin\n"), "unexpected stdout: {stdout:?}"); + assert!( + stdout.chars().next().is_some_and(|c| c.is_ascii_digit()), + "human-readable mtime should lead: {stdout:?}" + ); + + // Size, name-as-typed, and epoch mtime. + let (code, stdout, stderr) = run_in(root, vec!["-f", "%N: %z (%m)", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + assert!(stdout.starts_with("data.bin: 12 ("), "unexpected stdout: {stdout:?}"); + let epoch = stdout + .trim_end() + .trim_end_matches(')') + .rsplit('(') + .next() + .unwrap(); + assert!(epoch.parse::().is_ok(), "epoch mtime should be numeric: {stdout:?}"); + } + + #[test] + fn bsd_flag_cluster_follows_symlink_and_suppresses_newline() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("target"), b"abc").unwrap(); + std::os::unix::fs::symlink("target", root.join("link")).unwrap(); + + // `-Lnf`: BSD boolean flags clustered with `-f`; `-n` drops the + // trailing newline (mapped to --printf), `-L` follows the link. + let (code, stdout, stderr) = run_in(root, vec!["-Lnf", "%z", "link"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "3", "")); + } + + #[test] + fn bsd_string_form_and_subfield_directives() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"x").unwrap(); + + // %HT → %F (file type), %Lp → %a (permission bits, octal). + let (code, stdout, stderr) = run_in(root, vec!["-f", "%HT/%Lp", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let (kind, perms) = stdout.trim_end().rsplit_once('/').unwrap(); + assert_eq!(kind, "regular file"); + assert!(perms.chars().all(|c| c.is_digit(8)), "octal perms expected: {stdout:?}"); + } + + #[test] + fn bsd_unsupported_directive_is_rejected() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("data.bin"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-f", "%v", "data.bin"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!( + stderr.contains("unsupported BSD format directive '%v'"), + "unexpected stderr: {stderr:?}" + ); + } +} diff --git a/crates/vendor/uu-tac/Cargo.toml b/crates/vendor/uu-tac/Cargo.toml new file mode 100644 index 000000000..c7bd7c526 --- /dev/null +++ b/crates/vendor/uu-tac/Cargo.toml @@ -0,0 +1,26 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/tac), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/tac.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_tac" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "tac ~ (uutils) concatenate and display input lines in reverse order (vendored + patched for in-process embedding)" + +[lib] +path = "src/tac.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +memchr = "2.7.4" +memmap2 = "0.9" +regex = "1.11" +thiserror = "2.0.3" +uucore = "0.8.0" +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-tac/LICENSE b/crates/vendor/uu-tac/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-tac/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-tac/src/error.rs b/crates/vendor/uu-tac/src/error.rs new file mode 100644 index 000000000..cc58fd224 --- /dev/null +++ b/crates/vendor/uu-tac/src/error.rs @@ -0,0 +1,47 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +//! Errors returned by tac during processing of a file. + +// pi-uutils: vendored from uutils/coreutils 0.8.0; `translate!` strings are +// literalized with the en-US locale text. + +use std::ffi::OsString; + +use thiserror::Error; +use uucore::{ + display::Quotable, + error::{UError, strip_errno}, +}; + +#[derive(Debug, Error)] +pub enum TacError { + /// A regular expression given by the user is invalid. + #[error("invalid regular expression: {0}")] + InvalidRegex(regex::Error), + /// An error opening a file for reading. + /// + /// The parameters are the name of the file and the underlying + /// [`std::io::Error`] that caused this error. + #[error("failed to open {} for reading: {}", .0.quote(), strip_errno(.1))] + OpenError(OsString, std::io::Error), + /// An error reading the contents of a file or stdin. + /// + /// The parameters are the name of the file and the underlying + /// [`std::io::Error`] that caused this error. + #[error("{}: read error: {}", .0.maybe_quote(), strip_errno(.1))] + ReadError(OsString, std::io::Error), + /// An error writing the (reversed) contents of a file or stdin. + /// + /// The parameter is the underlying [`std::io::Error`] that caused + /// this error. + #[error("failed to write to stdout: {}", strip_errno(.0))] + WriteError(std::io::Error), +} + +impl UError for TacError { + fn code(&self) -> i32 { + 1 + } +} diff --git a/crates/vendor/uu-tac/src/tac.rs b/crates/vendor/uu-tac/src/tac.rs new file mode 100644 index 000000000..90c124e61 --- /dev/null +++ b/crates/vendor/uu-tac/src/tac.rs @@ -0,0 +1,634 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) sbytes slen dlen memmem memmap Mmap mmap SIGBUS + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. FILE operands resolve against the shell working directory +// via `pi_uutils_ctx::resolve` at the open/mmap call site (the original operand +// is kept for error messages), `-`/no-operand read the context stdin, output is +// written through the context stdout, recoverable per-file errors go to the +// context stderr with `pi_uutils_ctx::set_exit_code` (upstream `show!`), the +// `translate!` strings are literalized, and the process-global signal handling +// plus the stdin mmap/tempfile buffering (which target the process stdin fd) +// are removed. + +mod error; + +use std::{ + ffi::{OsStr, OsString}, + fs::File, + io::{BufWriter, Read, Write}, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use memchr::memmem; +use memmap2::Mmap; +use pi_uutils_ctx::format_usage; +use uucore::error::UResult; + +use crate::error::TacError; + +mod options { + pub static BEFORE: &str = "before"; + pub static REGEX: &str = "regex"; + pub static SEPARATOR: &str = "separator"; + pub static FILE: &str = "file"; +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match tac_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "tac: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn tac_main(matches: &ArgMatches) -> UResult<()> { + let before = matches.get_flag(options::BEFORE); + let regex = matches.get_flag(options::REGEX); + let raw_separator = matches + .get_one::(options::SEPARATOR) + .map_or(OsStr::new("\n"), |s| s.as_os_str()); + + let separator = if raw_separator.is_empty() { + OsStr::new("\0") + } else { + raw_separator + }; + + let files: Vec = match matches.get_many::(options::FILE) { + Some(v) => v.cloned().collect(), + None => vec![OsString::from("-")], + }; + + tac(&files, before, regex, separator) +} + +pub fn uu_app() -> Command { + Command::new("tac") + .version(uucore::crate_version!()) + .override_usage(format_usage("tac [OPTION]... [FILE]...")) + .about("Write each file to standard output, last line first.") + .infer_long_args(true) + .arg( + Arg::new(options::BEFORE) + .short('b') + .long(options::BEFORE) + .help("attach the separator before instead of after") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::REGEX) + .short('r') + .long(options::REGEX) + .help("interpret the sequence as a regular expression") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::SEPARATOR) + .short('s') + .long(options::SEPARATOR) + .help("use STRING as the separator instead of newline") + .value_parser(clap::value_parser!(OsString)) + .value_name("STRING"), + ) + .arg( + Arg::new(options::FILE) + .hide(true) + .action(ArgAction::Append) + .value_parser(clap::value_parser!(OsString)) + .value_hint(clap::ValueHint::FilePath), + ) +} + +/// pi-uutils: replacement for upstream's `show!` — reports a recoverable +/// per-file error to the context stderr and accumulates a non-zero exit code +/// while processing continues with the next operand. +fn show(err: &TacError) { + let _ = writeln!(pi_uutils_ctx::stderr(), "tac: {err}"); + pi_uutils_ctx::set_exit_code(1); +} + +/// Print lines of a buffer in reverse, with line separator given as a regex. +/// +/// `data` contains the bytes of the file. +/// +/// `pattern` is the regular expression given as a +/// [`regex::bytes::Regex`] (not a [`regex::Regex`], since the input is +/// given as a slice of bytes). If `before` is `true`, then each match +/// of this pattern in `data` is interpreted as the start of a line. If +/// `before` is `false`, then each match of this pattern is interpreted +/// as the end of a line. +/// +/// This function writes each line in `data` to the context stdout in +/// reverse. +/// +/// # Errors +/// +/// If there is a problem writing to stdout, then this function +/// returns [`std::io::Error`]. +fn buffer_tac_regex( + data: &[u8], + pattern: ®ex::bytes::Regex, + before: bool, +) -> std::io::Result<()> { + // pi-uutils: write through the context stdout instead of the process stdout. + let mut out = BufWriter::new(pi_uutils_ctx::stdout()); + + // The index of the line separator for the current line. + // + // As we scan through the `data` from right to left, we update this + // variable each time we find a new line separator. We restrict our + // regular expression search to only those bytes up to the line + // separator. + let mut this_line_end = data.len(); + + // The index of the start of the next line in the `data`. + // + // As we scan through the `data` from right to left, we update this + // variable each time we find a new line. + // + // If `before` is `true`, then each line starts immediately before + // the line separator. Otherwise, each line starts immediately after + // the line separator. + let mut following_line_start = data.len(); + + // Iterate over each byte in the buffer in reverse. When we find a + // line separator, write the line to stdout. + // + // The `before` flag controls whether the line separator appears at + // the end of the line (as in "abc\ndef\n") or at the beginning of + // the line (as in "/abc/def"). + for i in (0..data.len()).rev() { + // Determine if there is a match for `pattern` starting at index + // `i` in `data`. Only search up to the line ending that was + // found previously. + if let Some(match_) = pattern.find_at(&data[..this_line_end], i) + && match_.start() == i + { + // Record this index as the ending of the current line. + this_line_end = i; + + // The length of the match (that is, the line separator), in bytes. + let slen = match_.end() - match_.start(); + + if before { + out.write_all(&data[i..following_line_start])?; + following_line_start = i; + } else { + out.write_all(&data[i + slen..following_line_start])?; + following_line_start = i + slen; + } + } + } + + // After the loop terminates, write whatever bytes are remaining at + // the beginning of the buffer. + out.write_all(&data[0..following_line_start])?; + out.flush()?; + Ok(()) +} + +/// Write lines from `data` to stdout in reverse. +/// +/// This function writes to the context stdout each line appearing in `data`, +/// starting with the last line and ending with the first line. The +/// `separator` parameter defines what characters to use as a line +/// separator. +/// +/// If `before` is `false`, then this function assumes that the +/// `separator` appears at the end of each line, as in `"abc\ndef\n"`. +/// If `before` is `true`, then this function assumes that the +/// `separator` appears at the beginning of each line, as in +/// `"/abc/def"`. +fn buffer_tac(data: &[u8], before: bool, separator: &OsStr) -> std::io::Result<()> { + // pi-uutils: write through the context stdout instead of the process stdout. + let mut out = BufWriter::new(pi_uutils_ctx::stdout()); + + // The number of bytes in the line separator. + let slen = separator.len(); + + // The index of the start of the next line in the `data`. + // + // As we scan through the `data` from right to left, we update this + // variable each time we find a new line. + // + // If `before` is `true`, then each line starts immediately before + // the line separator. Otherwise, each line starts immediately after + // the line separator. + let mut following_line_start = data.len(); + + // Iterate over each byte in the buffer in reverse. When we find a + // line separator, write the line to stdout. + // + // The `before` flag controls whether the line separator appears at + // the end of the line (as in "abc\ndef\n") or at the beginning of + // the line (as in "/abc/def"). + for i in memmem::rfind_iter(data, separator.as_encoded_bytes()) { + if before { + out.write_all(&data[i..following_line_start])?; + following_line_start = i; + } else { + out.write_all(&data[i + slen..following_line_start])?; + following_line_start = i + slen; + } + } + + // After the loop terminates, write whatever bytes are remaining at + // the beginning of the buffer. + out.write_all(&data[0..following_line_start])?; + out.flush()?; + Ok(()) +} + +/// Make the regex flavor compatible with `regex` crate +/// +/// Concretely: +/// - Toggle escaping of (), |, {} +/// - Escape ^ and $ when not at edges +/// - Leave only ASCII bytes inside [] +/// - Escape non-ASCII bytes as `(?-u:\xFF)` outside [] +fn translate_regex_flavor(bytes: &[u8]) -> String { + let mut result = Vec::new(); + let mut i = 0; + let mut inside_brackets = false; + let mut prev_was_backslash = false; + let mut last_byte: Option = None; + + while let Some(b) = bytes.get(i) { + let is_escaped = prev_was_backslash; + prev_was_backslash = false; + + match b { + _ if inside_brackets && !b.is_ascii() => { + i += 1; + continue; + }, + // Unescape escaped (), |, {} when not inside brackets + b'\\' if !inside_brackets && !is_escaped => { + if let Some(next) = bytes.get(i + 1) + && matches!(next, b'(' | b')' | b'|' | b'{' | b'}') + { + result.push(*next); + last_byte = Some(*next); + i += 2; + continue; + } + + result.push(b'\\'); + last_byte = Some(b'\\'); + prev_was_backslash = true; + }, + // Bracket tracking + b'[' => { + inside_brackets = true; + result.push(*b); + last_byte = Some(*b); + }, + b']' => { + inside_brackets = false; + result.push(*b); + last_byte = Some(*b); + }, + // Escape (), |, {} when not escaped and outside brackets + b'(' | b')' | b'|' | b'{' | b'}' if !inside_brackets && !is_escaped => { + result.push(b'\\'); + result.push(*b); + last_byte = Some(*b); + }, + b'^' if !inside_brackets && !is_escaped => { + let is_anchor_position = result.is_empty() || matches!(last_byte, Some(b'(' | b'|')); + if !is_anchor_position { + result.push(b'\\'); + } + result.push(*b); + last_byte = Some(*b); + }, + b'$' if !inside_brackets && !is_escaped => { + let next_is_anchor_position = match bytes.get(i + 1) { + None => true, + Some(b')' | b'|') => true, + Some(b'\\') => { + // Peek two ahead to see if it's \) or \| + matches!(bytes.get(i + 2), Some(b')' | b'|')) + }, + _ => false, + }; + if !next_is_anchor_position { + result.push(b'\\'); + } + result.push(*b); + last_byte = Some(*b); + }, + _ if !b.is_ascii() => { + let _ = write!(result, r"(?-u:\x{b:02x})"); + last_byte = None; + }, + _ => { + result.push(*b); + last_byte = Some(*b); + }, + } + + i += 1; + } + + String::from_utf8(result).expect("produces ASCII bytes") +} + +#[allow(clippy::cognitive_complexity)] +fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) -> UResult<()> { + // Compile the regular expression pattern if it is provided. + let maybe_pattern = if regex { + match regex::bytes::RegexBuilder::new(&translate_regex_flavor(separator.as_encoded_bytes())) + .multi_line(true) + .build() + { + Ok(p) => Some(p), + Err(e) => return Err(TacError::InvalidRegex(e).into()), + } + } else { + None + }; + + for filename in filenames { + let mmap; + let buf; + + let data: &[u8] = if filename == "-" { + // pi-uutils: in-process stdin is a context stream, not the process + // stdin fd; upstream's stdin mmap / tempfile buffering and the + // `stdin_was_closed` signal check do not apply. Read it fully. + let mut contents = Vec::new(); + match pi_uutils_ctx::stdin().read_to_end(&mut contents) { + Ok(_) => { + buf = contents; + &buf + }, + Err(e) => { + show(&TacError::ReadError(OsString::from("stdin"), e)); + continue; + }, + } + } else { + // pi-uutils: resolve the operand against the shell working + // directory at the open site; `filename` is kept for errors. + let path = pi_uutils_ctx::resolve(filename); + let mut file = match File::open(&path) { + Ok(f) => f, + Err(e) => { + show(&TacError::OpenError(filename.clone(), e)); + continue; + }, + }; + + if let Some(mmap1) = try_mmap_file(&file) { + mmap = mmap1; + &mmap + } else { + let mut contents = Vec::new(); + match file.read_to_end(&mut contents) { + Ok(_) => { + buf = contents; + &buf + }, + Err(e) => { + show(&TacError::ReadError(filename.clone(), e)); + continue; + }, + } + } + }; + + // Select the appropriate `tac` algorithm based on whether the + // separator is given as a regular expression or a fixed string. + // pi-uutils: match ergonomics instead of upstream's `Some(ref pattern)`. + let result = match &maybe_pattern { + Some(pattern) => buffer_tac_regex(data, pattern, before), + None => buffer_tac(data, before, separator), + }; + + // If there is any error in writing the output, terminate immediately. + if let Err(e) = result { + return Err(TacError::WriteError(e).into()); + } + } + Ok(()) +} + +fn try_mmap_file(file: &File) -> Option { + // SAFETY: If the file is truncated while we map it, SIGBUS will be raised + // and our process will be terminated, thus preventing access of invalid memory. + unsafe { Mmap::map(file).ok() } +} + +#[cfg(test)] +mod tests_hybrid_flavor { + use super::translate_regex_flavor; + + #[test] + fn test_grouping_and_alternation() { + assert_eq!(translate_regex_flavor(br"\(abc\)"), r"(abc)"); + + assert_eq!(translate_regex_flavor(br"(abc)"), r"\(abc\)"); + + assert_eq!(translate_regex_flavor(br"a\|b"), r"a|b"); + + assert_eq!(translate_regex_flavor(br"a|b"), r"a\|b"); + } + + #[test] + fn test_anchors_context() { + assert_eq!(translate_regex_flavor(br"^abc$"), r"^abc$"); + + assert_eq!(translate_regex_flavor(br"a^b"), r"a\^b"); + assert_eq!(translate_regex_flavor(br"a$b"), r"a\$b"); + + // Anchors inside groups (reset by \(...\) regardless of position) + assert_eq!(translate_regex_flavor(br"\(^abc\)"), r"(^abc)"); + assert_eq!(translate_regex_flavor(br"\(abc$\)"), r"(abc$)"); + + // Anchors inside alternation (reset by \| regardless of position) + assert_eq!(translate_regex_flavor(br"^a\|^b"), r"^a|^b"); + assert_eq!(translate_regex_flavor(br"a$\|b$"), r"a$|b$"); + } + + #[test] + fn test_character_classes() { + assert_eq!(translate_regex_flavor(br"[a-z]"), r"[a-z]"); + + assert_eq!(translate_regex_flavor(br"[.]"), r"[.]"); + + assert_eq!(translate_regex_flavor(br"[]abc]"), r"[]abc]"); + + assert_eq!(translate_regex_flavor(br"[^]abc]"), r"[^]abc]"); + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, fs, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_with(cwd: PathBuf, stdin: &[u8], args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::Cursor::new(stdin.to_vec())), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("tac") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[test] + fn resolves_relative_operand_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("input.txt"), b"a\nb\nc\n").unwrap(); + + // Relative operand + scope cwd differing from the process cwd: only the + // call-site `pi_uutils_ctx::resolve` patch makes this find the file. + let (code, stdout, stderr) = run_with(root, b"", vec!["input.txt"]); + assert_eq!(code, 0); + assert_eq!(stdout, "c\nb\na\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn no_operand_reads_context_stdin() { + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"one\ntwo\nthree\n", vec![]); + assert_eq!(code, 0); + assert_eq!(stdout, "three\ntwo\none\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn dash_operand_reads_context_stdin() { + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"x\ny\n", vec!["-"]); + assert_eq!(code, 0); + assert_eq!(stdout, "y\nx\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn custom_separator_reverses_fields() { + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"a,b,c,", vec!["-s", ","]); + assert_eq!(code, 0); + assert_eq!(stdout, "c,b,a,"); + assert_eq!(stderr, ""); + } + + #[test] + fn before_flag_attaches_separator_before_each_line() { + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"/abc/def", vec!["-b", "-s", "/"]); + assert_eq!(code, 0); + assert_eq!(stdout, "/def/abc"); + assert_eq!(stderr, ""); + } + + #[test] + fn regex_separator_splits_on_character_class() { + // `[,;]` treats either byte as a separator; records are emitted in + // reverse with each separator kept attached to its preceding record. + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"a,b;c", vec!["-r", "-s", "[,;]"]); + assert_eq!(code, 0); + assert_eq!(stdout, "cb;a,"); + assert_eq!(stderr, ""); + } + + #[test] + fn invalid_regex_is_fatal_error() { + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"abc", vec!["-r", "-s", "["]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.starts_with("tac: invalid regular expression:"), "stderr: {stderr}"); + } + + #[test] + fn missing_file_continues_with_next_operand_and_exits_nonzero() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("good.txt"), b"1\n2\n").unwrap(); + + let (code, stdout, stderr) = run_with(root, b"", vec!["nope.txt", "good.txt"]); + assert_eq!(code, 1); + assert_eq!(stdout, "2\n1\n", "valid operand still printed after the failure"); + assert!(stderr.contains("tac: failed to open 'nope.txt' for reading:"), "stderr: {stderr}"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_with(PathBuf::from("."), b"", vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("last line first")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-tail/Cargo.toml b/crates/vendor/uu-tail/Cargo.toml index 62e6cdd0d..0d5c2719e 100644 --- a/crates/vendor/uu-tail/Cargo.toml +++ b/crates/vendor/uu-tail/Cargo.toml @@ -16,6 +16,7 @@ clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } memchr = "2.7.2" uucore = { version = "0.8.0", features = ["fs", "parser-size", "signals"] } pi-uutils-ctx = { path = "../../pi-uutils-ctx" } +uu_tac = { path = "../uu-tac" } [target.'cfg(not(target_os = "wasi"))'.dependencies] libc = "0.2.172" @@ -32,3 +33,5 @@ windows-sys = { version = "0.61.0", default-features = false, features = [ [dev-dependencies] rstest = "0.26.0" +tempfile = "3" +parking_lot = "0.12" diff --git a/crates/vendor/uu-tail/src/tail.rs b/crates/vendor/uu-tail/src/tail.rs index 7f83f8e22..83ae6e229 100644 --- a/crates/vendor/uu-tail/src/tail.rs +++ b/crates/vendor/uu-tail/src/tail.rs @@ -40,12 +40,105 @@ use uucore::{ error::{FromIo, UError, UResult, USimpleError}, }; +/// pi-uutils: BSD `tail -r` compatibility (macOS muscle memory). +/// +/// BSD tail reverses line order with `-r`; GNU tail has no such option. A +/// short-option cluster containing `r` is therefore unambiguously BSD-shaped, +/// except after `--`, where it is an operand. Plain reverse invocations are +/// delegated to `tac` before clap parsing. Combinations with byte, line, or +/// follow options have no cheap equivalent here and fail explicitly rather +/// than silently changing their meaning. +/// +/// Returns `None` when the invocation is not BSD-shaped, `Some(Err(_))` for a +/// BSD-shaped invocation this builtin cannot safely emulate, and `Some(Ok(_))` +/// with argv suitable for `uu_tac::run` when it can. +fn rewrite_bsd_invocation(argv: &[OsString]) -> Option, String>> { + let mut has_reverse = false; + let mut incompatible = false; + let mut unsupported = None; + + for arg in argv.iter().skip(1) { + let token = arg.to_string_lossy(); + if token == "--" { + break; + } + let Some(cluster) = token.strip_prefix('-') else { + continue; + }; + if cluster.is_empty() { + continue; + } + if cluster.starts_with('-') { + unsupported = Some(token.into_owned()); + continue; + } + + for flag in cluster.chars() { + match flag { + 'r' => has_reverse = true, + 'n' | 'c' | 'b' | 'f' => incompatible = true, + _ => unsupported = Some(format!("-{flag}")), + } + } + } + + if !has_reverse { + return None; + } + if incompatible { + return Some(Err( + "-r with -n, -c, -b, or -f is not supported by this builtin; pipe through tac".to_owned(), + )); + } + if let Some(option) = unsupported { + return Some(Err(format!( + "-r with {option} is not supported by this builtin; pipe through tac" + ))); + } + + // pi-uutils: `uu_tac` owns its clap command name and error prefix, so this + // intentionally uses `tac` as argv[0]; file errors consequently say `tac:`. + let mut tac_argv = vec![OsString::from("tac")]; + let mut operands_only = false; + for arg in argv.iter().skip(1) { + let token = arg.to_string_lossy(); + if operands_only { + tac_argv.push(arg.clone()); + continue; + } + if token == "--" { + operands_only = true; + tac_argv.push(arg.clone()); + continue; + } + if let Some(cluster) = token.strip_prefix('-') + && !cluster.is_empty() + && !cluster.starts_with('-') + && cluster.chars().all(|flag| flag == 'r') + { + continue; + } + tac_argv.push(arg.clone()); + } + Some(Ok(tac_argv)) +} + /// In-process builtin entry point. Unlike upstream's `#[uucore::main] uumain`, /// this renders clap help/usage/version to the context streams and never calls /// `std::process::exit`, so it is safe inside the long-lived host shell /// process. The default (non-follow) path reads stdin/files through /// [`pi_uutils_ctx`]. pub fn run(args: Vec) -> i32 { + // pi-uutils: translate BSD-style `tail -r` before GNU clap parsing; see + // `rewrite_bsd_invocation`. + let args = match rewrite_bsd_invocation(&args) { + None => args, + Some(Ok(tac_args)) => return uu_tac::run(tac_args), + Some(Err(msg)) => { + let _ = writeln!(pi_uutils_ctx::stderr(), "tail: {msg}"); + return 1; + }, + }; let settings = match parse_settings(args) { Ok(settings) => settings, Err(ArgsError::Clap(err)) => { @@ -572,9 +665,105 @@ where #[cfg(test)] mod tests { - use std::io::Cursor; + use std::{ + collections::HashMap, + ffi::OsString, + fs, + io::{self, Cursor, Write}, + path::PathBuf, + sync::{Arc, atomic::AtomicBool}, + }; - use crate::forwards_thru_file; + use parking_lot::Mutex; + + use crate::{forwards_thru_file, run}; + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.buf.lock().flush() + } + } + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + let io = pi_uutils_ctx::ScopeIo { + stdin: Box::new(io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(AtomicBool::new(false)), + }; + let argv = std::iter::once("tail") + .chain(args) + .map(OsString::from) + .collect(); + let code = pi_uutils_ctx::scope(io, || run(argv)); + + ( + code, + String::from_utf8(stdout_buf.lock().clone()).unwrap(), + String::from_utf8(stderr_buf.lock().clone()).unwrap(), + ) + } + + /// Canonicalized temp dir avoids macOS's `/var` → `/private/var` alias. + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + #[test] + fn bsd_reverse_delegates_to_tac() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("file"), b"first\nsecond\nthird\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-r", "file"]); + + assert_eq!(code, 0); + assert_eq!(stdout, "third\nsecond\nfirst\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn bsd_reverse_with_line_count_fails_loudly() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("file"), b"first\nsecond\nthird\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-r", "-n", "2", "file"]); + + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!( + stderr, + "tail: -r with -n, -c, -b, or -f is not supported by this builtin; pipe through tac\n" + ); + } + + #[test] + fn gnu_line_count_is_unchanged() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("file"), b"first\nsecond\nthird\n").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-n", "1", "file"]); + + assert_eq!(code, 0); + assert_eq!(stdout, "third\n"); + assert_eq!(stderr, ""); + } #[test] fn test_forwards_thru_file_zero() { diff --git a/crates/vendor/uu-touch/Cargo.toml b/crates/vendor/uu-touch/Cargo.toml new file mode 100644 index 000000000..679bfcb87 --- /dev/null +++ b/crates/vendor/uu-touch/Cargo.toml @@ -0,0 +1,36 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/touch), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/touch.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_touch" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "touch ~ (uutils) change FILE timestamps (vendored + patched for in-process embedding)" + +[lib] +path = "src/touch.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +filetime = "0.2.23" +jiff = "0.2.18" +parse_datetime = "0.14.0" +thiserror = "2.0.3" +uucore = { version = "0.8.0", features = ["libc", "parser"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[target.'cfg(unix)'.dependencies] +libc = "0.2.172" +rustix = { version = "1.1.4", features = ["fs"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.0", default-features = false, features = [ + "Win32_Storage_FileSystem", + "Win32_Foundation", +] } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-touch/LICENSE b/crates/vendor/uu-touch/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-touch/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-touch/src/touch.rs b/crates/vendor/uu-touch/src/touch.rs new file mode 100644 index 000000000..4b874d06e --- /dev/null +++ b/crates/vendor/uu-touch/src/touch.rs @@ -0,0 +1,1170 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) datelike datetime filetime lpszfilepath mktime +// strtime timelike utime DATETIME UTIME futimens spell-checker:ignore (FORMATS) +// MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Every filesystem syscall resolves its path operand +// against the shell working directory via `pi_uutils_ctx::resolve` AT THE CALL +// SITE, while the original operands are kept for display/error messages (GNU +// prints operands as typed). All process-global stdio is routed through +// `pi_uutils_ctx`, `translate!` strings are literalized, `_POSIX2_VERSION` is +// read from the scope environment, `show!` accumulation goes through +// `pi_uutils_ctx::set_exit_code`, and the entry point no longer calls +// `std::process::exit`. Upstream's `src/error.rs` is inlined below as +// `pub mod error`. jiff's `TimeZone::system()` (and thus `TZ`) intentionally +// stays process-global. + +#[cfg(unix)] +use std::fs::OpenOptions; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::{ + borrow::Cow, + ffi::{OsStr, OsString}, + fs::{self, File}, + io::{Error, ErrorKind, Write}, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use clap::{ + Arg, ArgAction, ArgGroup, ArgMatches, Command, + builder::{PossibleValue, ValueParser}, +}; +use filetime::{FileTime, set_file_times, set_symlink_file_times}; +use jiff::{Timestamp, ToSpan, Zoned, civil::Time, fmt::strtime, tz::TimeZone}; +#[cfg(unix)] +use libc::O_NONBLOCK; +use pi_uutils_ctx::format_usage; +#[cfg(unix)] +use rustix::fs::Timestamps; +#[cfg(unix)] +use rustix::fs::futimens; +#[cfg(target_os = "linux")] +use uucore::libc; +use uucore::{ + display::Quotable, + error::{FromIo, UError, UResult, USimpleError}, + parser::shortcut_value_parser::ShortcutValueParser, +}; + +use crate::error::TouchError; + +// pi-uutils: upstream `src/error.rs`, inlined so the vendored crate is a +// single source file. `translate!` message templates are literalized with the +// en-US strings. +pub mod error { + use std::path::PathBuf; + + use filetime::FileTime; + use thiserror::Error; + use uucore::{ + display::Quotable, + error::{UError, UIoError}, + }; + + #[derive(Debug, Error)] + pub enum TouchError { + #[error("Unable to parse date: {0}")] + InvalidDateFormat(String), + + /// The source time couldn't be converted to a [`jiff::Zoned`] + #[error("Source has invalid access or modification time: {0}")] + InvalidFiletime(FileTime), + + /// The reference file's attributes could not be found or read + #[error("failed to get attributes of {}: {}", .0.quote(), to_uioerror(.1))] + ReferenceFileInaccessible(PathBuf, std::io::Error), + + /// An error getting a path to stdout on Windows + #[error("GetFinalPathNameByHandleW failed with code {0}")] + WindowsStdoutPathError(String), + + /// An error encountered on a specific file + #[error("{error}")] + TouchFileError { path: PathBuf, index: usize, error: Box }, + } + + fn to_uioerror(err: &std::io::Error) -> UIoError { + let copy = if let Some(code) = err.raw_os_error() { + std::io::Error::from_raw_os_error(code) + } else { + std::io::Error::from(err.kind()) + }; + UIoError::from(copy) + } + + impl UError for TouchError {} +} + +/// Options contains all the possible behaviors and flags for touch. +/// +/// All options are public so that the options can be programmatically +/// constructed by other crates, such as nushell. That means that this struct is +/// part of our public API. It should therefore not be changed without good +/// reason. +/// +/// The fields are documented with the arguments that determine their value. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Options { + /// Do not create any files. Set by `-c`/`--no-create`. + pub no_create: bool, + + /// Affect each symbolic link instead of any referenced file. Set by + /// `-h`/`--no-dereference`. + pub no_deref: bool, + + /// Where to get access and modification times from + pub source: Source, + + /// If given, uses time from `source` but on given date + pub date: Option, + + /// Whether to change access time only, modification time only, or both + pub change_times: ChangeTimes, + + /// When true, error when file doesn't exist and either `--no-dereference` + /// was passed or the file couldn't be created + pub strict: bool, +} + +pub enum InputFile { + /// A regular file + Path(PathBuf), + /// Touch stdout. `--no-dereference` will be ignored in this case. + Stdout, +} + +/// Whether to set access time only, modification time only, or both +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ChangeTimes { + /// Change only access time + AtimeOnly, + /// Change only modification time + MtimeOnly, + /// Change both access and modification times + Both, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Source { + /// Use access/modification times of given file + Reference(PathBuf), + Timestamp(FileTime), + /// Use current time + Now, +} + +pub mod options { + // Both SOURCES and sources are needed as we need to be able to refer to the + // ArgGroup. + pub static SOURCES: &str = "sources"; + pub mod sources { + pub static DATE: &str = "date"; + pub static REFERENCE: &str = "reference"; + pub static TIMESTAMP: &str = "timestamp"; + } + pub static HELP: &str = "help"; + pub static ACCESS: &str = "access"; + pub static MODIFICATION: &str = "modification"; + pub static NO_CREATE: &str = "no-create"; + pub static NO_DEREF: &str = "no-dereference"; + pub static TIME: &str = "time"; + pub static FORCE: &str = "force"; +} + +static ARG_FILES: &str = "files"; + +mod format { + pub(crate) const POSIX_LOCALE: &str = "%a %b %e %H:%M:%S %Y"; + pub(crate) const ISO_8601: &str = "%Y-%m-%d"; + // "%Y%m%d%H%M.%S" 15 chars + pub(crate) const YYYYMMDDHHMM_DOT_SS: &str = "%Y%m%d%H%M.%S"; + // "%Y-%m-%d %H:%M:%S.%SS" 12 chars + pub(crate) const YYYYMMDDHHMMSS: &str = "%Y-%m-%d %H:%M:%S.%f"; + // "%Y-%m-%d %H:%M:%S" 12 chars + pub(crate) const YYYYMMDDHHMMS: &str = "%Y-%m-%d %H:%M:%S"; + // "%Y-%m-%d %H:%M" 12 chars + // Used for example in tests/touch/no-rights.sh + pub(crate) const YYYY_MM_DD_HH_MM: &str = "%Y-%m-%d %H:%M"; + // "%Y%m%d%H%M" 12 chars + pub(crate) const YYYYMMDDHHMM: &str = "%Y%m%d%H%M"; + // "%Y-%m-%d %H:%M +offset" + // Used for example in tests/touch/relative.sh + pub(crate) const YYYYMMDDHHMM_OFFSET: &str = "%Y-%m-%d %H:%M %z"; +} + +fn timestamp_to_filetime(ts: Timestamp) -> FileTime { + FileTime::from_system_time(SystemTime::from(ts)) +} + +fn filetime_to_zoned(ft: &FileTime) -> Option { + let ts = Timestamp::new(ft.unix_seconds(), ft.nanoseconds() as i32).ok()?; + Some(Zoned::new(ts, TimeZone::system())) +} + +/// Whether all characters in the string are digits. +fn all_digits(s: &str) -> bool { + s.as_bytes().iter().all(u8::is_ascii_digit) +} + +/// Convert a two-digit year string to the corresponding number. +/// +/// `s` must be of length two or more. The last two bytes of `s` are +/// assumed to be the two digits of the year. +fn get_year(s: &str) -> u8 { + let bytes = s.as_bytes(); + let n = bytes.len(); + let y1 = bytes[n - 2] - b'0'; + let y2 = bytes[n - 1] - b'0'; + 10 * y1 + y2 +} + +/// Whether the first filename should be interpreted as a timestamp. +fn is_first_filename_timestamp( + reference: Option<&OsString>, + date: Option<&str>, + timestamp: Option<&str>, + files: &[&OsString], +) -> bool { + timestamp.is_none() + && reference.is_none() + && date.is_none() + && files.len() >= 2 + // pi-uutils: `_POSIX2_VERSION` comes from the scope environment (the + // shell's exported variables), not the host process environment. + // env check is last as the slowest op + && pi_uutils_ctx::var("_POSIX2_VERSION").as_deref() == Some("199209") + && files[0].to_str().is_some_and(is_timestamp) +} + +// Check if string is a valid POSIX timestamp (8 digits or 10 digits with valid +// year range) +fn is_timestamp(s: &str) -> bool { + all_digits(s) && (s.len() == 8 || (s.len() == 10 && (69..=99).contains(&get_year(s)))) +} + +/// Cycle the last two characters to the beginning of the string. +/// +/// `s` must have length at least two. +fn shr2(s: &str) -> String { + let n = s.len(); + let (a, b) = s.split_at(n - 2); + let mut result = String::with_capacity(n); + result.push_str(b); + result.push_str(a); + result +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match touch_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "touch: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn touch_main(matches: &ArgMatches) -> UResult<()> { + let mut filenames: Vec<&OsString> = matches + .get_many::(ARG_FILES) + .ok_or_else(|| { + // pi-uutils: literalized; `uucore::execution_phrase()` is "touch" + // when running as a builtin. + USimpleError::new(1, "missing file operand\nTry 'touch --help' for more information.") + })? + .collect(); + + let no_deref = matches.get_flag(options::NO_DEREF); + + let reference = matches.get_one::(options::sources::REFERENCE); + let date = matches + .get_one::(options::sources::DATE) + .map(ToOwned::to_owned); + + let mut timestamp = matches + .get_one::(options::sources::TIMESTAMP) + .map(ToOwned::to_owned); + + if is_first_filename_timestamp(reference, date.as_deref(), timestamp.as_deref(), &filenames) { + let first_file = filenames[0].to_str().unwrap(); + timestamp = if first_file.len() == 10 { + Some(shr2(first_file)) + } else { + Some(first_file.to_string()) + }; + filenames = filenames[1..].to_vec(); + } + + let source = if let Some(reference) = reference { + Source::Reference(PathBuf::from(reference)) + } else if let Some(ts) = timestamp { + Source::Timestamp(parse_timestamp(&ts)?) + } else { + Source::Now + }; + + let files: Vec = filenames + .into_iter() + .map(|filename| { + if filename == "-" { + InputFile::Stdout + } else { + InputFile::Path(PathBuf::from(filename)) + } + }) + .collect(); + + let opts = Options { + no_create: matches.get_flag(options::NO_CREATE), + no_deref, + source, + date, + change_times: determine_atime_mtime_change(matches), + strict: false, + }; + + touch(&files, &opts)?; + + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("touch") + .version(uucore::crate_version!()) + .about("Update the access and modification times of each FILE to the current time.") + .override_usage(format_usage("touch [OPTION]... [FILE]...")) + .infer_long_args(true) + .disable_help_flag(true) + .arg( + Arg::new(options::HELP) + .long(options::HELP) + .help("Print help information.") + .action(ArgAction::Help), + ) + .arg( + Arg::new(options::ACCESS) + .short('a') + .help("change only the access time") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::sources::TIMESTAMP) + .short('t') + .help("use [[CC]YY]MMDDhhmm[.ss] instead of the current time") + .value_name("STAMP"), + ) + .arg( + Arg::new(options::sources::DATE) + .short('d') + .long(options::sources::DATE) + .allow_hyphen_values(true) + .help("parse argument and use it instead of current time") + .value_name("STRING") + .conflicts_with(options::sources::TIMESTAMP), + ) + .arg( + Arg::new(options::FORCE) + .short('f') + .help("(ignored)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::MODIFICATION) + .short('m') + .help("change only the modification time") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::NO_CREATE) + .short('c') + .long(options::NO_CREATE) + .help("do not create any files") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::NO_DEREF) + .short('h') + .long(options::NO_DEREF) + .help( + "affect each symbolic link instead of any referenced file (only for systems that \ + can change the timestamps of a symlink)", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::sources::REFERENCE) + .short('r') + .long(options::sources::REFERENCE) + .help("use this file's times instead of the current time") + .value_name("FILE") + .value_parser(ValueParser::os_string()) + .value_hint(clap::ValueHint::AnyPath) + .conflicts_with(options::sources::TIMESTAMP), + ) + .arg( + Arg::new(options::TIME) + .long(options::TIME) + .help( + "change only the specified time: \"access\", \"atime\", or \"use\" are equivalent \ + to -a; \"modify\" or \"mtime\" are equivalent to -m", + ) + .value_name("WORD") + .value_parser(ShortcutValueParser::new([ + PossibleValue::new("atime").alias("access").alias("use"), + PossibleValue::new("mtime").alias("modify"), + ])), + ) + .arg( + Arg::new(ARG_FILES) + .action(ArgAction::Append) + .num_args(1..) + .value_parser(clap::value_parser!(OsString)) + .value_hint(clap::ValueHint::AnyPath), + ) + .group( + ArgGroup::new(options::SOURCES) + .args([ + options::sources::TIMESTAMP, + options::sources::DATE, + options::sources::REFERENCE, + ]) + .multiple(true), + ) +} + +/// Execute the touch command. +/// +/// # Errors +/// +/// Possible causes: +/// - The user doesn't have permission to access the file +/// - One of the directory components of the file path doesn't exist. +/// - Dangling symlink is given and -r/--reference is used. +/// +/// It will return an `Err` on the first error. However, for any of the files, +/// if all of the following are true, it will print the error and continue +/// touching the rest of the files. +/// - `opts.strict` is `false` +/// - The file doesn't already exist +/// - `-c`/`--no-create` was passed (`opts.no_create`) +/// - Either `-h`/`--no-dereference` was passed (`opts.no_deref`) or the file +/// couldn't be created +pub fn touch(files: &[InputFile], opts: &Options) -> Result<(), TouchError> { + let (atime, mtime) = match &opts.source { + Source::Reference(reference) => { + // pi-uutils: resolve the reference operand against the shell + // working directory at the syscall site; the original operand is + // kept for the error message. + let (atime, mtime) = stat(&pi_uutils_ctx::resolve(reference), !opts.no_deref) + .map_err(|e| TouchError::ReferenceFileInaccessible(reference.to_owned(), e))?; + + (atime, mtime) + }, + Source::Now => { + let now: FileTime; + #[cfg(target_os = "linux")] + { + if opts.date.is_none() { + now = FileTime::from_unix_time(0, libc::UTIME_NOW as u32); + } else { + now = timestamp_to_filetime(Timestamp::now()); + } + } + #[cfg(not(target_os = "linux"))] + { + now = timestamp_to_filetime(Timestamp::now()); + } + (now, now) + }, + &Source::Timestamp(ts) => (ts, ts), + }; + + let (atime, mtime) = if let Some(date) = &opts.date { + ( + parse_date( + filetime_to_zoned(&atime).ok_or_else(|| TouchError::InvalidFiletime(atime))?, + date, + )?, + parse_date( + filetime_to_zoned(&mtime).ok_or_else(|| TouchError::InvalidFiletime(mtime))?, + date, + )?, + ) + } else { + (atime, mtime) + }; + + for (ind, file) in files.iter().enumerate() { + let (path, is_stdout) = match file { + InputFile::Stdout => (Cow::Owned(pathbuf_from_stdout()?), true), + InputFile::Path(path) => (Cow::Borrowed(path), false), + }; + touch_file(&path, is_stdout, opts, atime, mtime).map_err(|e| TouchError::TouchFileError { + path: path.into_owned(), + index: ind, + error: e, + })?; + } + + Ok(()) +} + +/// Create or update the timestamp for a single file. +/// +/// # Arguments +/// +/// - `path` - The path to the file to create/update timestamp for +/// - `is_stdout` - Stdout is handled specially, see [`update_times`] for more +/// info +/// - `atime` - Access time to set for the file +/// - `mtime` - Modification time to set for the file +fn touch_file( + path: &Path, + is_stdout: bool, + opts: &Options, + atime: FileTime, + mtime: FileTime, +) -> UResult<()> { + let filename = if is_stdout { + OsStr::new("-") + } else { + path.as_os_str() + }; + + // pi-uutils: resolve the operand against the shell working directory for + // every syscall below; `path`/`filename` keep the operand as typed for + // error messages. + let resolved = pi_uutils_ctx::resolve(path); + + let metadata_result = if opts.no_deref { + resolved.symlink_metadata() + } else { + resolved.metadata() + }; + + if let Err(e) = metadata_result { + if e.kind() != ErrorKind::NotFound { + return Err(e.map_err_context(|| format!("setting times of {}", filename.quote()))); + } + + if opts.no_create { + return Ok(()); + } + + if opts.no_deref { + let e = USimpleError::new( + 1, + format!("setting times of {}: No such file or directory", filename.quote()), + ); + if opts.strict { + return Err(e); + } + // pi-uutils: upstream `show!` — print the error and accumulate the + // exit code in the scope instead of process-global state. + let _ = writeln!(pi_uutils_ctx::stderr(), "touch: {e}"); + pi_uutils_ctx::set_exit_code(e.code()); + return Ok(()); + } + + if let Err(e) = File::create(&resolved) { + // we need to check if the path is the path to a directory (ends with a + // separator) we can't use File::create to create a directory + // we cannot use path.is_dir() because it calls fs::metadata which we already + // called when stable, we can change to use e.kind() == + // std::io::ErrorKind::IsADirectory + let is_directory = if let Some(last_char) = path.to_string_lossy().chars().last() { + last_char == std::path::MAIN_SEPARATOR + } else { + false + }; + if is_directory { + let custom_err = Error::other("No such file or directory"); + return Err( + custom_err.map_err_context(|| format!("cannot touch {}", filename.quote())), + ); + } + let e = e.map_err_context(|| format!("cannot touch {}", path.quote())); + if opts.strict { + return Err(e); + } + // pi-uutils: upstream `show!` — see above. + let _ = writeln!(pi_uutils_ctx::stderr(), "touch: {e}"); + pi_uutils_ctx::set_exit_code(e.code()); + return Ok(()); + } + + // Minor optimization: if no reference time, timestamp, or date was specified, + // we're done. + if opts.source == Source::Now && opts.date.is_none() { + return Ok(()); + } + } + + update_times(path, is_stdout, opts, atime, mtime) +} + +/// Returns which of the times (access, modification) are to be changed. +/// +/// Note that "-a" and "-m" may be passed together; this is not an xor. +/// - If `-a` is passed but not `-m`, only access time is changed +/// - If `-m` is passed but not `-a`, only modification time is changed +/// - If neither or both are passed, both times are changed +fn determine_atime_mtime_change(matches: &ArgMatches) -> ChangeTimes { + // If `--time` is given, Some(true) if equivalent to `-a`, Some(false) if + // equivalent to `-m` If `--time` not given, None + let time_access_only = if matches.contains_id(options::TIME) { + matches + .get_one::(options::TIME) + .map(|time| time.contains("access") || time.contains("atime") || time.contains("use")) + } else { + None + }; + + let atime_only = matches.get_flag(options::ACCESS) || time_access_only.unwrap_or_default(); + let mtime_only = matches.get_flag(options::MODIFICATION) || !time_access_only.unwrap_or(true); + + if atime_only && !mtime_only { + ChangeTimes::AtimeOnly + } else if mtime_only && !atime_only { + ChangeTimes::MtimeOnly + } else { + ChangeTimes::Both + } +} + +/// Updating file access and modification times based on user-specified options +/// +/// If the file is not stdout (`!is_stdout`) and `-h`/`--no-dereference` was +/// passed, then, if the given file is a symlink, its own times will be updated, +/// rather than the file it points to. +fn update_times( + path: &Path, + is_stdout: bool, + opts: &Options, + atime: FileTime, + mtime: FileTime, +) -> UResult<()> { + // pi-uutils: resolve the operand against the shell working directory for + // every syscall below; `path` keeps the operand as typed for error + // messages. + let resolved = pi_uutils_ctx::resolve(path); + + // If changing "only" atime or mtime, grab the existing value of the other. + let (atime, mtime) = match opts.change_times { + ChangeTimes::AtimeOnly => ( + atime, + stat(&resolved, !opts.no_deref) + .map_err_context(|| format!("failed to get attributes of {}", path.quote()))? + .1, + ), + ChangeTimes::MtimeOnly => ( + stat(&resolved, !opts.no_deref) + .map_err_context(|| format!("failed to get attributes of {}", path.quote()))? + .0, + mtime, + ), + ChangeTimes::Both => (atime, mtime), + }; + + // sets the file access and modification times for a file or a symbolic link. + // The filename, access time (atime), and modification time (mtime) are provided + // as inputs. + + if opts.no_deref && !is_stdout { + return set_symlink_file_times(&resolved, atime, mtime) + .map_err_context(|| format!("setting times of {}", path.quote())); + } + + #[cfg(unix)] + { + // Open write-only and use futimens to trigger IN_CLOSE_WRITE on Linux. + if !is_stdout && try_futimens_via_write_fd(&resolved, atime, mtime).is_ok() { + return Ok(()); + } + } + + set_file_times(&resolved, atime, mtime) + .map_err_context(|| format!("setting times of {}", path.quote())) +} + +#[cfg(unix)] +/// Set file times via file descriptor using `futimens`. +/// +/// This opens the file write-only and uses the POSIX `futimens` call to set +/// access and modification times on the open FD (not by path), which also +/// triggers `IN_CLOSE_WRITE` on Linux when the FD is closed. +fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> { + let file = OpenOptions::new() + .write(true) + // Avoid blocking on special files (e.g. FIFOs) before we can inspect metadata. + .custom_flags(O_NONBLOCK) + .open(path)?; + + let timestamps = Timestamps { + last_access: rustix::fs::Timespec { + tv_sec: atime.unix_seconds(), + tv_nsec: atime.nanoseconds() as _, + }, + last_modification: rustix::fs::Timespec { + tv_sec: mtime.unix_seconds(), + tv_nsec: mtime.nanoseconds() as _, + }, + }; + + futimens(&file, ×tamps).map_err(|e| Error::from_raw_os_error(e.raw_os_error())) +} + +/// Get metadata of the provided path +/// If `follow` is `true`, the function will try to follow symlinks. Errors if +/// the symlink is dangling, otherwise defaults to symlink metadata. If `follow` +/// is `false`, the function will return metadata of the symlink itself +fn stat(path: &Path, follow: bool) -> std::io::Result<(FileTime, FileTime)> { + let metadata = if follow { + match fs::metadata(path) { + // Successfully followed symlink + Ok(meta) => meta, + // Dangling symlink + Err(e) if e.kind() == ErrorKind::NotFound => return Err(e), + // Other error (?), try to get the symlink metadata + Err(_) => fs::symlink_metadata(path)?, + } + } else { + fs::symlink_metadata(path)? + }; + + Ok(( + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )) +} + +fn parse_date(ref_zoned: Zoned, s: &str) -> Result { + // This isn't actually compatible with GNU touch, but there doesn't seem to + // be any simple specification for what format this parameter allows and I'm + // not about to implement GNU parse_datetime. + // http://git.savannah.gnu.org/gitweb/?p=gnulib.git;a=blob_plain;f=lib/parse-datetime.y + + // TODO: match on char count? + + // "The preferred date and time representation for the current locale." + // "(In the POSIX locale this is equivalent to %a %b %e %H:%M:%S %Y.)" + // time 0.1.43 parsed this as 'a b e T Y' + // which is equivalent to the POSIX locale: %a %b %e %H:%M:%S %Y + // Tue Dec 3 ... + // ("%c", POSIX_LOCALE_FORMAT), + // + if let Ok(parsed) = strtime::parse(format::POSIX_LOCALE, s) + .and_then(|tm| tm.to_datetime()) + .and_then(|dt| TimeZone::UTC.to_zoned(dt)) + { + return Ok(timestamp_to_filetime(parsed.timestamp())); + } + + // Also support other formats found in the GNU tests like + // in tests/misc/stat-nanoseconds.sh + // or tests/touch/no-rights.sh + for fmt in [ + format::YYYYMMDDHHMMS, + format::YYYYMMDDHHMMSS, + format::YYYY_MM_DD_HH_MM, + format::YYYYMMDDHHMM_OFFSET, + ] { + if let Ok(parsed) = strtime::parse(fmt, s) + .and_then(|tm| tm.to_datetime()) + .and_then(|dt| TimeZone::UTC.to_zoned(dt)) + { + return Ok(timestamp_to_filetime(parsed.timestamp())); + } + } + + // "Equivalent to %Y-%m-%d (the ISO 8601 date format). (C99)" + // ("%F", ISO_8601_FORMAT), + // pi-uutils: `TimeZone::system()` (and the `TZ` variable it consults) + // intentionally stays process-global; jiff reads it internally. + if let Ok(filetime) = strtime::parse(format::ISO_8601, s) + .and_then(|tm| tm.to_date()) + .and_then(|date| { + TimeZone::system() + .to_ambiguous_zoned(date.to_datetime(Time::midnight())) + .unambiguous() + }) + .map(|zdt| timestamp_to_filetime(zdt.timestamp())) + { + return Ok(filetime); + } + + // "@%s" is "The number of seconds since the Epoch, 1970-01-01 00:00:00 +0000 + // (UTC). (TZ) (Calculated from mktime(tm).)" + if s.bytes().next() == Some(b'@') + && let Ok(ts) = &s[1..].parse::() + { + return Ok(FileTime::from_unix_time(*ts, 0)); + } + + if let Ok(zoned) = parse_datetime::parse_datetime_at_date(ref_zoned, s) { + return Ok(timestamp_to_filetime(zoned.timestamp())); + } + + Err(TouchError::InvalidDateFormat(s.to_owned())) +} + +/// Prepends 19 or 20 to the year if it is a 2 digit year +/// +/// GNU `touch` behavior: +/// +/// - 68 and before is interpreted as 20xx +/// - 69 and after is interpreted as 19xx +fn prepend_century(s: &str) -> UResult { + let first_two_digits = s[..2] + .parse::() + .map_err(|_| USimpleError::new(1, format!("invalid date ts format {}", s.quote())))?; + Ok(format!("{}{s}", if first_two_digits > 68 { 19 } else { 20 })) +} + +/// Parses a timestamp string into a [`FileTime`]. +/// +/// This function attempts to parse a string into a [`FileTime`] +/// As expected by gnu touch -t : `[[cc]yy]mmddhhmm[.ss]` +/// +/// Note that If the year is specified with only two digits, +/// then cc is 20 for years in the range 0 … 68, and 19 for years in 69 … 99. +/// in order to be compatible with GNU `touch`. +fn parse_timestamp(s: &str) -> UResult { + use format::{YYYYMMDDHHMM, YYYYMMDDHHMM_DOT_SS}; + + // pi-uutils: `TimeZone::system()` intentionally stays process-global. + let current_year = || Timestamp::now().to_zoned(TimeZone::system()).year(); + + let (format, ts) = match s.chars().count() { + 15 => (YYYYMMDDHHMM_DOT_SS, s.to_owned()), + 12 => (YYYYMMDDHHMM, s.to_owned()), + // If we don't add "19" or "20", we have insufficient information to parse + 13 => (YYYYMMDDHHMM_DOT_SS, prepend_century(s)?), + 10 => (YYYYMMDDHHMM, prepend_century(s)?), + 11 => (YYYYMMDDHHMM_DOT_SS, format!("{}{s}", current_year())), + 8 => (YYYYMMDDHHMM, format!("{}{s}", current_year())), + _ => { + return Err(USimpleError::new(1, format!("invalid date format {}", s.quote()))); + }, + }; + + let mut dt = strtime::parse(format, &ts) + .and_then(|parsed| parsed.to_datetime()) + .map_err(|_| USimpleError::new(1, format!("invalid date ts format {}", ts.quote())))?; + + // Jiff caps seconds at 59, but 60 is valid. It might be a leap second + // or wrap to the next minute. But that doesn't really matter, because we + // only care about the timestamp anyway. + // Tested in gnu/tests/touch/60-seconds + if dt.second() == 59 && ts.ends_with(".60") { + dt += 1.second(); + } + + // Due to daylight saving time switch, local time can jump from 1:59 AM to + // 3:00 AM, in which case any time between 2:00 AM and 2:59 AM is not valid. + // Jiff's `to_ambiguous_zoned(...).unambiguous()` handles this case. + let local = TimeZone::system() + .to_ambiguous_zoned(dt) + .unambiguous() + .map_err(|_| USimpleError::new(1, format!("invalid date ts format {}", ts.quote())))?; + + Ok(timestamp_to_filetime(local.timestamp())) +} + +// TODO: this may be a good candidate to put in fsext.rs +/// Returns a [`PathBuf`] to stdout. +/// +/// On Windows, uses `GetFinalPathNameByHandleW` to attempt to get the path +/// from the stdout handle. +#[cfg_attr(not(windows), expect(clippy::unnecessary_wraps))] +fn pathbuf_from_stdout() -> Result { + #[cfg(all(unix, not(target_os = "android")))] + { + Ok(PathBuf::from("/dev/stdout")) + } + #[cfg(target_os = "android")] + { + Ok(PathBuf::from("/proc/self/fd/1")) + } + #[cfg(windows)] + { + use std::os::windows::prelude::AsRawHandle; + + use windows_sys::Win32::{ + Foundation::{ + ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, GetLastError, + HANDLE, MAX_PATH, + }, + Storage::FileSystem::{FILE_NAME_OPENED, GetFinalPathNameByHandleW}, + }; + + let handle = std::io::stdout().lock().as_raw_handle() as HANDLE; + let mut file_path_buffer: [u16; MAX_PATH as usize] = [0; MAX_PATH as usize]; + + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea#examples + // SAFETY: We transmute the handle to be able to cast *mut c_void into a + // HANDLE (i32) so rustc will let us call GetFinalPathNameByHandleW. The + // reference example code for GetFinalPathNameByHandleW implies that + // it is safe for us to leave lpszfilepath uninitialized, so long as + // the buffer size is correct. We know the buffer size (MAX_PATH) at + // compile time. MAX_PATH is a small number (260) so we can cast it + // to a u32. + let ret = unsafe { + GetFinalPathNameByHandleW( + handle, + file_path_buffer.as_mut_ptr(), + file_path_buffer.len() as u32, + FILE_NAME_OPENED, + ) + }; + + // pi-uutils: literalized error strings; the variant's Display supplies + // the "GetFinalPathNameByHandleW failed with code" prefix, so only the + // code payload is stored. + let buffer_size = match ret { + ERROR_PATH_NOT_FOUND | ERROR_NOT_ENOUGH_MEMORY | ERROR_INVALID_PARAMETER => { + return Err(TouchError::WindowsStdoutPathError(ret.to_string())); + }, + 0 => { + return Err(TouchError::WindowsStdoutPathError(format!( + "{}", + // SAFETY: GetLastError is thread-safe and has no documented memory unsafety. + unsafe { GetLastError() } + ))); + }, + e => e as usize, + }; + + // Don't include the null terminator + Ok(String::from_utf16(&file_path_buffer[0..buffer_size]) + .map_err(|e| TouchError::WindowsStdoutPathError(e.to_string()))? + .into()) + } + #[cfg(target_os = "wasi")] + { + Ok(PathBuf::from("/dev/stdout")) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("touch") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + fn times_of(path: &Path) -> (FileTime, FileTime) { + let metadata = fs::metadata(path).unwrap(); + (FileTime::from_last_access_time(&metadata), FileTime::from_last_modification_time(&metadata)) + } + + #[test] + fn relative_operand_creates_file_in_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + + // Relative operand + scope cwd differing from the process cwd: only + // the call-site `pi_uutils_ctx::resolve` patch makes the file land in + // the scope cwd instead of the process cwd. + let (code, stdout, stderr) = run_in(root.clone(), vec!["created.txt"]); + assert_eq!(code, 0); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + assert!(root.join("created.txt").is_file()); + assert!( + !std::env::current_dir() + .unwrap() + .join("created.txt") + .exists() + ); + } + + #[test] + fn no_create_on_missing_file_is_silent_success() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-c", "missing.txt"]); + assert_eq!(code, 0); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + assert!(!root.join("missing.txt").exists()); + } + + #[test] + fn reference_copies_times_from_relative_reference() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("ref"), b"x").unwrap(); + let ref_atime = FileTime::from_unix_time(1_000_000, 0); + let ref_mtime = FileTime::from_unix_time(2_000_000, 0); + set_file_times(root.join("ref"), ref_atime, ref_mtime).unwrap(); + + // Both the `-r` reference and the FILE operand are relative: each is + // resolved against the scope cwd at its own syscall site. + let (code, stdout, stderr) = run_in(root.clone(), vec!["-r", "ref", "new"]); + assert_eq!(code, 0); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + let (atime, mtime) = times_of(&root.join("new")); + assert_eq!(atime, ref_atime); + assert_eq!(mtime, ref_mtime); + } + + #[test] + fn date_sets_mtime_to_fixed_utc_instant() { + let (_dir, root) = canonical_tempdir(); + + // "%Y-%m-%d %H:%M:%S" dates are interpreted in UTC, so the expected + // epoch is timezone-independent: 2001-02-03T04:05:06Z. + let (code, stdout, stderr) = run_in(root.clone(), vec!["-d", "2001-02-03 04:05:06", "f"]); + assert_eq!(code, 0); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + let (atime, mtime) = times_of(&root.join("f")); + assert_eq!(mtime.unix_seconds(), 981_173_106); + assert_eq!(atime.unix_seconds(), 981_173_106); + } + + #[test] + fn modification_only_preserves_existing_atime() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"x").unwrap(); + let old_atime = FileTime::from_unix_time(1_111, 0); + let old_mtime = FileTime::from_unix_time(2_222, 0); + set_file_times(root.join("f"), old_atime, old_mtime).unwrap(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-m", "-d", "@981173106", "f"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + let (atime, mtime) = times_of(&root.join("f")); + assert_eq!(atime, old_atime, "-m must not change atime"); + assert_eq!(mtime, FileTime::from_unix_time(981_173_106, 0)); + } + + #[test] + fn missing_operand_is_usage_error() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec![]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.contains("missing file operand"), "stderr: {stderr}"); + assert!(stderr.contains("Try 'touch --help'"), "stderr: {stderr}"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("access and modification times")); + assert_eq!(stderr, ""); + } + + #[test] + fn time_word_and_flags_select_change_times() { + assert_eq!( + ChangeTimes::Both, + determine_atime_mtime_change(&uu_app().try_get_matches_from(vec!["touch", "f"]).unwrap()) + ); + assert_eq!( + ChangeTimes::Both, + determine_atime_mtime_change( + &uu_app() + .try_get_matches_from(vec!["touch", "-a", "-m", "--time", "modify", "f"]) + .unwrap() + ) + ); + assert_eq!( + ChangeTimes::AtimeOnly, + determine_atime_mtime_change( + &uu_app() + .try_get_matches_from(vec!["touch", "--time", "access", "f"]) + .unwrap() + ) + ); + assert_eq!( + ChangeTimes::MtimeOnly, + determine_atime_mtime_change( + &uu_app() + .try_get_matches_from(vec!["touch", "-m", "f"]) + .unwrap() + ) + ); + } +} diff --git a/crates/vendor/uu-truncate/Cargo.toml b/crates/vendor/uu-truncate/Cargo.toml new file mode 100644 index 000000000..4c05a9e67 --- /dev/null +++ b/crates/vendor/uu-truncate/Cargo.toml @@ -0,0 +1,22 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/truncate), patched to resolve +# path arguments against the shell working directory and route I/O through +# pi-uutils-ctx so it can run in-process as a shell builtin. See src/truncate.rs +# for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_truncate" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "truncate ~ (uutils) truncate (or extend) FILE to SIZE (vendored + patched for in-process embedding)" + +[lib] +path = "src/truncate.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { version = "0.8.0", features = ["parser-size"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" +tempfile = "3" diff --git a/crates/vendor/uu-truncate/LICENSE b/crates/vendor/uu-truncate/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-truncate/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-truncate/src/truncate.rs b/crates/vendor/uu-truncate/src/truncate.rs new file mode 100644 index 000000000..0b6a35ff7 --- /dev/null +++ b/crates/vendor/uu-truncate/src/truncate.rs @@ -0,0 +1,557 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) RFILE refsize rfilename fsize tsize + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Every filesystem syscall resolves its path operand +// against the shell working directory via `pi_uutils_ctx::resolve` AT THE CALL +// SITE, while the original operands are kept for display/error messages (GNU +// prints operands as typed). All process-global stdio is routed through +// `pi_uutils_ctx`, `translate!` strings are literalized, per-file errors are +// reported through the context stderr with `set_exit_code` (continue-on-error +// like GNU truncate), and the entry point no longer calls `std::process::exit`. + +#[cfg(unix)] +use std::os::unix::fs::FileTypeExt; +use std::{ + ffi::OsString, + fs::{OpenOptions, metadata}, + io::{ErrorKind, Write}, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use uucore::{ + display::Quotable, + error::{FromIo, UResult, USimpleError, UUsageError}, + parser::parse_size::{ParseSizeError, Parser, allow_list_with_all_suffixes}, +}; + +#[derive(Debug, Eq, PartialEq)] +enum TruncateMode { + Absolute(u64), + Extend(u64), + Reduce(u64), + AtMost(u64), + AtLeast(u64), + RoundDown(u64), + RoundUp(u64), +} + +impl TruncateMode { + /// Compute a target size in bytes for this truncate mode. + /// + /// `fsize` is the size of the reference file, in bytes. + /// + /// If the mode is [`TruncateMode::Reduce`] and the value to + /// reduce by is greater than `fsize`, then this function returns + /// 0 (since it cannot return a negative number). + /// + /// # Returns + /// + /// `None` if rounding by 0, else the target size. + fn to_size(&self, fsize: u64) -> Option { + match self { + Self::Absolute(size) => Some(*size), + Self::Extend(size) => Some(fsize + size), + Self::Reduce(size) => Some(fsize.saturating_sub(*size)), + Self::AtMost(size) => Some(fsize.min(*size)), + Self::AtLeast(size) => Some(fsize.max(*size)), + Self::RoundDown(size) => fsize.checked_rem(*size).map(|remainder| fsize - remainder), + Self::RoundUp(size) => fsize.checked_next_multiple_of(*size), + } + } + + /// Determine if mode is absolute + /// + /// # Returns + /// + /// `true` is self matches Self::Absolute(_), `false` otherwise. + fn is_absolute(&self) -> bool { + matches!(self, Self::Absolute(_)) + } +} + +pub mod options { + pub static IO_BLOCKS: &str = "io-blocks"; + pub static NO_CREATE: &str = "no-create"; + pub static REFERENCE: &str = "reference"; + pub static SIZE: &str = "size"; + pub static ARG_FILES: &str = "files"; +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match truncate_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + // pi-uutils: don't emit a dangling "truncate: " prefix when the + // error renders to an empty message. + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "truncate: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn truncate_main(matches: &ArgMatches) -> UResult<()> { + let files: Vec = matches + .get_many::(options::ARG_FILES) + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + + if files.is_empty() { + Err(UUsageError::new(1, "missing file operand".to_string())) + } else { + let io_blocks = matches.get_flag(options::IO_BLOCKS); + let no_create = matches.get_flag(options::NO_CREATE); + let reference = matches + .get_one::(options::REFERENCE) + .map(String::from); + let size = matches.get_one::(options::SIZE).map(String::from); + truncate(no_create, io_blocks, reference, size, &files) + } +} + +pub fn uu_app() -> Command { + Command::new("truncate") + .version(uucore::crate_version!()) + .about("Shrink or extend the size of each file to the specified size.") + .override_usage(format_usage("truncate [OPTION]... [FILE]...")) + .after_help( + "SIZE is an integer with an optional prefix and optional unit.\nThe available units (K, \ + M, G, T, P, E, Z, and Y) use the following format:\n 'KB' => 1000 (kilobytes)\n \ + 'K' => 1024 (kibibytes)\n 'MB' => 1000*1000 (megabytes)\n 'M' => 1024*1024 \ + (mebibytes)\n 'GB' => 1000*1000*1000 (gigabytes)\n 'G' => 1024*1024*1024 \ + (gibibytes)\nSIZE may also be prefixed by one of the following to adjust the size of \ + each\nfile based on its current size:\n '+' => extend by\n '-' => reduce by\n \ + '<' => at most\n '>' => at least\n '/' => round down to multiple of\n '%' => \ + round up to multiple of", + ) + .infer_long_args(true) + .arg( + Arg::new(options::IO_BLOCKS) + .short('o') + .long(options::IO_BLOCKS) + .help( + "treat SIZE as the number of I/O blocks of the file rather than bytes (NOT \ + IMPLEMENTED)", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::NO_CREATE) + .short('c') + .long(options::NO_CREATE) + .help("do not create files that do not exist") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::REFERENCE) + .short('r') + .long(options::REFERENCE) + .required_unless_present(options::SIZE) + .help("base the size of each file on the size of RFILE") + .value_name("RFILE") + .value_hint(clap::ValueHint::FilePath), + ) + .arg( + Arg::new(options::SIZE) + .short('s') + .long(options::SIZE) + .required_unless_present(options::REFERENCE) + .help( + "set or adjust the size of each file according to SIZE, which is in bytes unless \ + --io-blocks is specified", + ) + .allow_hyphen_values(true) + .value_name("SIZE"), + ) + .arg( + Arg::new(options::ARG_FILES) + .value_name("FILE") + .action(ArgAction::Append) + .required(true) + .value_hint(clap::ValueHint::FilePath) + .value_parser(clap::value_parser!(OsString)), + ) +} + +/// Truncate the named file to the specified size. +/// +/// If `create` is true, then the file will be created if it does not +/// already exist. If `size` is larger than the number of bytes in the +/// file, then the file will be padded with zeros. If `size` is smaller +/// than the number of bytes in the file, then the file will be +/// truncated and any bytes beyond `size` will be lost. +/// +/// # Errors +/// +/// If the file could not be opened, or there was a problem setting the +/// size of the file. +fn do_file_truncate(filename: &OsString, create: bool, size: u64) -> UResult<()> { + // pi-uutils: resolve the operand against the shell working directory at + // the open site; `filename` is kept for the error message. + let resolved = pi_uutils_ctx::resolve(filename); + + match OpenOptions::new() + .write(true) + .create(create) + .open(&resolved) + { + Ok(file) => file.set_len(size), + Err(e) if e.kind() == ErrorKind::NotFound && !create => Ok(()), + Err(e) => Err(e), + } + .map_err_context(|| format!("cannot open {} for writing", filename.quote())) +} + +fn file_truncate( + no_create: bool, + reference_size: Option, + mode: &TruncateMode, + filename: &OsString, +) -> UResult<()> { + // pi-uutils: resolve the operand against the shell working directory at + // the metadata site; `filename` is kept for the error message. + let resolved = pi_uutils_ctx::resolve(filename); + + // Get the length of the file. + let file_size = match metadata(&resolved) { + Ok(metadata) => { + // A pipe has no length. Do this check here to avoid duplicate `stat()` syscall. + #[cfg(unix)] + if metadata.file_type().is_fifo() { + return Err(USimpleError::new( + 1, + format!( + "cannot open {} for writing: No such device or address", + filename.to_string_lossy().quote() + ), + )); + } + metadata.len() + }, + Err(_) => 0, + }; + + // The reference size can be either: + // + // 1. The size of a given file + // 2. The size of the file to be truncated if no reference has been provided. + let actual_reference_size = reference_size.unwrap_or(file_size); + + let Some(truncate_size) = mode.to_size(actual_reference_size) else { + return Err(USimpleError::new(1, "division by zero".to_string())); + }; + + do_file_truncate(filename, !no_create, truncate_size) +} + +fn truncate( + no_create: bool, + _: bool, + reference: Option, + size: Option, + filenames: &[OsString], +) -> UResult<()> { + let reference_size = match reference { + Some(reference_path) => { + // pi-uutils: resolve the reference operand against the shell + // working directory; `reference_path` is kept for the message. + let reference_metadata = + metadata(pi_uutils_ctx::resolve(&reference_path)).map_err(|error| { + match error.kind() { + ErrorKind::NotFound => USimpleError::new( + 1, + format!("cannot stat {}: No such file or directory", reference_path.quote()), + ), + _ => error.map_err_context(String::new), + } + })?; + + Some(reference_metadata.len()) + }, + None => None, + }; + + let size_string = size.as_deref(); + + // Omitting the mode is equivalent to extending a file by 0 bytes. + let mode = match size_string { + Some(string) => match parse_mode_and_size(string) { + Err(error) => { + return Err(USimpleError::new(1, format!("Invalid number: {error}"))); + }, + Ok(mode) => mode, + }, + None => TruncateMode::Extend(0), + }; + + // If a reference file has been given, the truncate mode cannot be absolute. + if reference_size.is_some() && mode.is_absolute() { + return Err(USimpleError::new( + 1, + "you must specify a relative '--size' with '--reference'".to_string(), + )); + } + + for filename in filenames { + // pi-uutils: upstream aborts on the first failing file; report the + // error through the context stderr and continue with the remaining + // operands (GNU behavior), accumulating the exit code. + if let Err(err) = file_truncate(no_create, reference_size, &mode, filename) { + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "truncate: {msg}"); + } + pi_uutils_ctx::set_exit_code(if err.code() == 0 { 1 } else { err.code() }); + } + } + + Ok(()) +} + +/// Decide whether a character is one of the size modifiers, like '+' or '<'. +fn is_modifier(c: char) -> bool { + c == '+' || c == '-' || c == '<' || c == '>' || c == '/' || c == '%' +} + +/// Parse a size string with optional modifier symbol as its first character. +/// +/// A size string is as described in [`Parser::parse_u64`]. The first character +/// of `size_string` might be a modifier symbol, like `'+'` or +/// `'<'`. The first element of the pair returned by this function +/// indicates which modifier symbol was present, or +/// [`TruncateMode::Absolute`] if none. +fn parse_mode_and_size(size_string: &str) -> Result { + // Trim any whitespace. + let mut size_string = size_string.trim(); + + // Get the modifier character from the size string, if any. For + // example, if the argument is "+123", then the modifier is '+'. + if let Some(c) = size_string.chars().next() { + if is_modifier(c) { + size_string = &size_string[1..]; + } + let allow_list = allow_list_with_all_suffixes("EgGkKmMPQRtTYZ"); + let allow_list_ref = allow_list.iter().map(AsRef::as_ref).collect::>(); + Parser::default() + .with_allow_list(&allow_list_ref) + .parse_u64(size_string) + .map(match c { + '+' => TruncateMode::Extend, + '-' => TruncateMode::Reduce, + '<' => TruncateMode::AtMost, + '>' => TruncateMode::AtLeast, + '/' => TruncateMode::RoundDown, + '%' => TruncateMode::RoundUp, + _ => TruncateMode::Absolute, + }) + } else { + Err(ParseSizeError::ParseFailure(size_string.to_string())) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, fs, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("truncate") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + /// Canonicalized temp dir (macOS tempdirs live behind /var -> /private/var). + fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + fn len(path: &PathBuf) -> u64 { + fs::metadata(path).unwrap().len() + } + + #[test] + fn resolves_relative_operand_against_scope_cwd() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"12345678").unwrap(); + + // Relative operand + scope cwd differing from the process cwd: only the + // call-site `pi_uutils_ctx::resolve` patch makes this find the file. + let (code, stdout, stderr) = run_in(root.clone(), vec!["-s", "5", "f"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + assert_eq!(len(&root.join("f")), 5); + } + + #[test] + fn extend_grows_by_relative_amount() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"1234").unwrap(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-s", "+3", "f"]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert_eq!(len(&root.join("f")), 7); + } + + #[test] + fn at_most_caps_only_larger_files() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("big"), vec![0u8; 20]).unwrap(); + fs::write(root.join("small"), b"abc").unwrap(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-s", "<10", "big", "small"]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert_eq!(len(&root.join("big")), 10); + assert_eq!(len(&root.join("small")), 3); + } + + #[test] + fn no_create_skips_missing_file() { + let (_dir, root) = canonical_tempdir(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-c", "-s", "5", "missing"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "", "")); + assert!(!root.join("missing").exists()); + } + + #[test] + fn missing_file_without_no_create_is_created_at_size() { + let (_dir, root) = canonical_tempdir(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-s", "9", "fresh"]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert_eq!(len(&root.join("fresh")), 9); + } + + #[test] + fn reference_copies_size_of_rfile() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("ref"), b"123456").unwrap(); + fs::write(root.join("f"), b"x").unwrap(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-r", "ref", "f"]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert_eq!(len(&root.join("f")), 6); + } + + #[test] + fn missing_reference_file_fails_with_stat_error() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"x").unwrap(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-r", "nope", "f"]); + assert_eq!(code, 1); + assert!(stderr.contains("cannot stat 'nope': No such file or directory")); + assert_eq!(len(&root.join("f")), 1, "operand must be untouched"); + } + + #[test] + fn invalid_size_reports_error_and_exit_1() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("f"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root.clone(), vec!["-s", "bogus", "f"]); + assert_eq!((code, stdout.as_str()), (1, "")); + assert!(stderr.contains("truncate: Invalid number:")); + assert_eq!(len(&root.join("f")), 1, "operand must be untouched"); + } + + #[test] + fn reference_with_absolute_size_is_rejected() { + let (_dir, root) = canonical_tempdir(); + fs::write(root.join("ref"), b"123").unwrap(); + fs::write(root.join("f"), b"x").unwrap(); + + let (code, _, stderr) = run_in(root.clone(), vec!["-r", "ref", "-s", "5", "f"]); + assert_eq!(code, 1); + assert!(stderr.contains("you must specify a relative '--size' with '--reference'")); + } + + #[test] + fn parse_mode_and_size_prefixes() { + assert_eq!(parse_mode_and_size("10"), Ok(TruncateMode::Absolute(10))); + assert_eq!(parse_mode_and_size("+10"), Ok(TruncateMode::Extend(10))); + assert_eq!(parse_mode_and_size("-10"), Ok(TruncateMode::Reduce(10))); + assert_eq!(parse_mode_and_size("<10"), Ok(TruncateMode::AtMost(10))); + assert_eq!(parse_mode_and_size(">10"), Ok(TruncateMode::AtLeast(10))); + assert_eq!(parse_mode_and_size("/10"), Ok(TruncateMode::RoundDown(10))); + assert_eq!(parse_mode_and_size("%10"), Ok(TruncateMode::RoundUp(10))); + assert_eq!(parse_mode_and_size("1kB"), Ok(TruncateMode::Absolute(1000))); + assert!(parse_mode_and_size("1b").is_err()); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(PathBuf::from("."), vec!["--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("round up to multiple of")); + assert_eq!(stderr, ""); + } +} diff --git a/crates/vendor/uu-uname/Cargo.toml b/crates/vendor/uu-uname/Cargo.toml new file mode 100644 index 000000000..f5884aeee --- /dev/null +++ b/crates/vendor/uu-uname/Cargo.toml @@ -0,0 +1,21 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/uname), patched to route +# output through pi-uutils-ctx so it can run in-process as a shell builtin. See +# src/uname.rs for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_uname" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "uname ~ (uutils) display system information (vendored + patched for in-process embedding)" + +[lib] +path = "src/uname.rs" + +[dependencies] +platform-info = "2.0.3" +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = "0.8.0" +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-uname/LICENSE b/crates/vendor/uu-uname/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-uname/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-uname/src/uname.rs b/crates/vendor/uu-uname/src/uname.rs new file mode 100644 index 000000000..58bafde0a --- /dev/null +++ b/crates/vendor/uu-uname/src/uname.rs @@ -0,0 +1,342 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (API) nodename osname sysname (options) mnrsv mnrsvo + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Output goes to the context stdout (upstream's +// `println_verbatim` writes to the process stdout), `translate!` strings are +// literalized, and the entry point no longer calls `std::process::exit`. + +use std::{ + ffi::{OsStr, OsString}, + io::Write, +}; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use pi_uutils_ctx::format_usage; +use platform_info::{PlatformInfo, PlatformInfoAPI, UNameAPI}; +use uucore::error::{UResult, USimpleError}; + +pub mod options { + pub static ALL: &str = "all"; + pub static KERNEL_NAME: &str = "kernel-name"; + pub static NODENAME: &str = "nodename"; + pub static KERNEL_VERSION: &str = "kernel-version"; + pub static KERNEL_RELEASE: &str = "kernel-release"; + pub static MACHINE: &str = "machine"; + pub static PROCESSOR: &str = "processor"; + pub static HARDWARE_PLATFORM: &str = "hardware-platform"; + pub static OS: &str = "operating-system"; +} + +pub struct UNameOutput { + pub kernel_name: Option, + pub nodename: Option, + pub kernel_release: Option, + pub kernel_version: Option, + pub machine: Option, + pub os: Option, + pub processor: Option, + pub hardware_platform: Option, +} + +impl UNameOutput { + fn display(&self) -> OsString { + [ + self.kernel_name.as_ref(), + self.nodename.as_ref(), + self.kernel_release.as_ref(), + self.kernel_version.as_ref(), + self.machine.as_ref(), + self.processor.as_ref(), + self.hardware_platform.as_ref(), + self.os.as_ref(), + ] + .into_iter() + .flatten() + .map(OsString::as_os_str) + .collect::>() + .join(OsStr::new(" ")) + } + + pub fn new(opts: &Options) -> UResult { + let uname = PlatformInfo::new() + .map_err(|_e| USimpleError::new(1, "cannot get system name".to_string()))?; + let none = !(opts.all + || opts.kernel_name + || opts.nodename + || opts.kernel_release + || opts.kernel_version + || opts.machine + || opts.os + || opts.processor + || opts.hardware_platform); + + let kernel_name = (opts.kernel_name || opts.all || none).then(|| uname.sysname().to_owned()); + + let nodename = (opts.nodename || opts.all).then(|| uname.nodename().to_owned()); + + let kernel_release = (opts.kernel_release || opts.all).then(|| uname.release().to_owned()); + + let kernel_version = (opts.kernel_version || opts.all).then(|| uname.version().to_owned()); + + let machine = (opts.machine || opts.all).then(|| uname.machine().to_owned()); + + let os = (opts.os || opts.all).then(|| uname.osname().to_owned()); + + // This option is unsupported on modern Linux systems + // See: https://lists.gnu.org/archive/html/bug-coreutils/2005-09/msg00063.html + let processor = opts.processor.then(|| "unknown".into()); + + // This option is unsupported on modern Linux systems + // See: https://lists.gnu.org/archive/html/bug-coreutils/2005-09/msg00063.html + let hardware_platform = opts.hardware_platform.then(|| "unknown".into()); + + Ok(Self { + kernel_name, + nodename, + kernel_release, + kernel_version, + machine, + os, + processor, + hardware_platform, + }) + } +} + +pub struct Options { + pub all: bool, + pub kernel_name: bool, + pub nodename: bool, + pub kernel_version: bool, + pub kernel_release: bool, + pub machine: bool, + pub processor: bool, + pub hardware_platform: bool, + pub os: bool, +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + match uname_main(&matches) { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "uname: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn uname_main(matches: &ArgMatches) -> UResult<()> { + let options = Options { + all: matches.get_flag(options::ALL), + kernel_name: matches.get_flag(options::KERNEL_NAME), + nodename: matches.get_flag(options::NODENAME), + kernel_release: matches.get_flag(options::KERNEL_RELEASE), + kernel_version: matches.get_flag(options::KERNEL_VERSION), + machine: matches.get_flag(options::MACHINE), + processor: matches.get_flag(options::PROCESSOR), + hardware_platform: matches.get_flag(options::HARDWARE_PLATFORM), + os: matches.get_flag(options::OS), + }; + let output = UNameOutput::new(&options)?; + // pi-uutils: replacement for upstream's `println_verbatim` — writes the + // output bytes verbatim to the context stdout instead of the process + // stdout. + let mut out = pi_uutils_ctx::stdout(); + out.write_all(uucore::os_str_as_bytes(output.display().as_os_str())?) + .and_then(|()| out.write_all(b"\n")) + .and_then(|()| out.flush()) + .map_err(|e| USimpleError::new(1, e.to_string()))?; + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("uname") + .version(uucore::crate_version!()) + .about("Print certain system information.\nWith no OPTION, same as -s.") + .override_usage(format_usage("uname [OPTION]...")) + .infer_long_args(true) + .arg( + Arg::new(options::ALL) + .short('a') + .long(options::ALL) + .help("Behave as though all of the options -mnrsvo were specified.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::KERNEL_NAME) + .short('s') + .long(options::KERNEL_NAME) + .alias("sysname") // Obsolescent option in GNU uname + .help("print the kernel name.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::NODENAME) + .short('n') + .long(options::NODENAME) + .help( + "print the nodename (the nodename may be a name that the system is known by to a \ + communications network).", + ) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::KERNEL_RELEASE) + .short('r') + .long(options::KERNEL_RELEASE) + .alias("release") // Obsolescent option in GNU uname + .help("print the operating system release.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::KERNEL_VERSION) + .short('v') + .long(options::KERNEL_VERSION) + .help("print the operating system version.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::MACHINE) + .short('m') + .long(options::MACHINE) + .help("print the machine hardware name.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::OS) + .short('o') + .long(options::OS) + .help("print the operating system name.") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::PROCESSOR) + .short('p') + .long(options::PROCESSOR) + .help("print the processor type (non-portable)") + .action(ArgAction::SetTrue) + .hide(true), + ) + .arg( + Arg::new(options::HARDWARE_PLATFORM) + .short('i') + .long(options::HARDWARE_PLATFORM) + .help("print the hardware platform (non-portable)") + .action(ArgAction::SetTrue) + .hide(true), + ) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: PathBuf::from("."), + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("uname") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + #[test] + fn kernel_name_matches_platform() { + let (code, stdout, stderr) = run_in(vec!["-s"]); + assert_eq!((code, stderr.as_str()), (0, "")); + #[cfg(target_os = "macos")] + assert_eq!(stdout, "Darwin\n"); + #[cfg(target_os = "linux")] + assert_eq!(stdout, "Linux\n"); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + assert!(stdout.trim_end().len() > 0); + } + + #[test] + fn no_options_defaults_to_kernel_name() { + let (code, bare, _) = run_in(vec![]); + let (_, with_s, _) = run_in(vec!["-s"]); + assert_eq!(code, 0); + assert_eq!(bare, with_s); + } + + #[test] + fn all_contains_kernel_name_and_more() { + let (code, all, stderr) = run_in(vec!["-a"]); + let (_, kernel, _) = run_in(vec!["-s"]); + assert_eq!((code, stderr.as_str()), (0, "")); + let kernel = kernel.trim_end(); + assert!(all.starts_with(kernel), "-a output {all:?} must start with {kernel:?}"); + assert!(all.trim_end().len() > kernel.len(), "-a must print more fields than -s"); + } + + #[test] + fn processor_prints_unknown() { + let (code, stdout, stderr) = run_in(vec!["-p"]); + assert_eq!((code, stdout.as_str(), stderr.as_str()), (0, "unknown\n", "")); + } +} diff --git a/crates/vendor/uu-whoami/Cargo.toml b/crates/vendor/uu-whoami/Cargo.toml new file mode 100644 index 000000000..d227d61ae --- /dev/null +++ b/crates/vendor/uu-whoami/Cargo.toml @@ -0,0 +1,27 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/whoami), patched to route +# output through pi-uutils-ctx so it can run in-process as a shell builtin. See +# src/whoami.rs for the patch markers (`pi-uutils:` comments). +[package] +name = "uu_whoami" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "whoami ~ (uutils) display user name of current effective user ID (vendored + patched for in-process embedding)" + +[lib] +path = "src/whoami.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { version = "0.8.0", features = ["entries", "process"] } +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.0", features = [ + "Win32_NetworkManagement_NetManagement", + "Win32_System_WindowsProgramming", + "Win32_Foundation", +] } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-whoami/LICENSE b/crates/vendor/uu-whoami/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-whoami/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-whoami/src/whoami.rs b/crates/vendor/uu-whoami/src/whoami.rs new file mode 100644 index 000000000..564f6c7ab --- /dev/null +++ b/crates/vendor/uu-whoami/src/whoami.rs @@ -0,0 +1,198 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) getusername + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. Output goes to the context stdout (upstream's +// `println_verbatim` writes to the process stdout), `translate!` strings are +// literalized, the `platform` module (upstream +// src/platform/{mod,unix,windows}.rs) is inlined, and the entry point no longer +// calls `std::process::exit`. + +use std::{ffi::OsString, io::Write}; + +use clap::Command; +use uucore::error::{FromIo, UResult, USimpleError}; + +// pi-uutils: inlined from upstream src/platform/{mod,unix,windows}.rs (verbatim +// bodies); the platform user lookup itself is process-global state and needs no +// scope patching. +mod platform { + #[cfg(unix)] + pub use self::unix::get_username; + #[cfg(windows)] + pub use self::windows::get_username; + + #[cfg(unix)] + mod unix { + use std::{ffi::OsString, io}; + + use uucore::{entries::uid2usr, process::geteuid}; + + pub fn get_username() -> io::Result { + // uid2usr should arguably return an OsString but currently doesn't + uid2usr(geteuid()).map(Into::into) + } + } + + #[cfg(windows)] + mod windows { + use std::{ffi::OsString, io, os::windows::ffi::OsStringExt}; + + use windows_sys::Win32::{ + NetworkManagement::NetManagement::UNLEN, System::WindowsProgramming::GetUserNameW, + }; + + pub fn get_username() -> io::Result { + const BUF_LEN: u32 = UNLEN + 1; + let mut buffer = [0_u16; BUF_LEN as usize]; + let mut len = BUF_LEN; + // SAFETY: buffer.len() == len + if unsafe { GetUserNameW(buffer.as_mut_ptr(), &raw mut len) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(OsString::from_wide(&buffer[..len as usize - 1])) + } + } +} + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the `UResult` to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + match uu_app().try_get_matches_from(argv) { + Ok(_matches) => {}, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + } + match whoami_main() { + Ok(()) => pi_uutils_ctx::exit_code(), + Err(err) => { + let code = err.code(); + let msg = err.to_string(); + if !msg.is_empty() { + let _ = writeln!(pi_uutils_ctx::stderr(), "whoami: {msg}"); + } + if code == 0 { 1 } else { code } + }, + } +} + +fn whoami_main() -> UResult<()> { + let username = whoami()?; + // pi-uutils: replacement for upstream's `println_verbatim` — writes the + // username bytes verbatim to the context stdout instead of the process + // stdout. + let mut out = pi_uutils_ctx::stdout(); + out.write_all(uucore::os_str_as_bytes(&username)?) + .and_then(|()| out.write_all(b"\n")) + .and_then(|()| out.flush()) + .map_err(|e| USimpleError::new(1, format!("failed to print username: {e}")))?; + Ok(()) +} + +/// Get the current username +pub fn whoami() -> UResult { + platform::get_username().map_err_context(|| "failed to get username".to_string()) +} + +pub fn uu_app() -> Command { + Command::new("whoami") + .version(uucore::crate_version!()) + .about("Print the current username.") + .override_usage("whoami") + .infer_long_args(true) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + fn run_in(args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: PathBuf::from("."), + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("whoami") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + #[test] + fn prints_process_user_with_trailing_newline() { + let (code, stdout, stderr) = run_in(vec![]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert!(stdout.ends_with('\n')); + let name = stdout.trim_end(); + assert!(!name.is_empty()); + // When the host exports USER it names the same effective user the + // platform lookup resolves. + if let Ok(user) = std::env::var("USER") { + assert_eq!(name, user); + } + } + + #[test] + fn rejects_operands() { + let (code, stdout, stderr) = run_in(vec!["extra"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(!stderr.is_empty(), "clap usage error must go to scope stderr"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_in(vec!["--help"]); + assert_eq!((code, stderr.as_str()), (0, "")); + assert!(stdout.contains("Print the current username.")); + } +} diff --git a/crates/vendor/uu-yes/Cargo.toml b/crates/vendor/uu-yes/Cargo.toml new file mode 100644 index 000000000..ff19aa1d0 --- /dev/null +++ b/crates/vendor/uu-yes/Cargo.toml @@ -0,0 +1,23 @@ +# Vendored from uutils/coreutils tag 0.8.0 (src/uu/yes), patched to route +# output through pi-uutils-ctx, handle a closed consumer (broken pipe) as a +# clean in-process exit, and poll the scope cancel flag so it can run +# in-process as a shell builtin. See src/yes.rs for the patch markers +# (`pi-uutils:` comments). +[package] +name = "uu_yes" +version = "0.8.0" +edition = "2024" +license = "MIT" +description = "yes ~ (uutils) repeatedly display a line with STRING (or 'y') (vendored + patched for in-process embedding)" + +[lib] +path = "src/yes.rs" + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +itertools = "0.14.0" +uucore = "0.8.0" +pi-uutils-ctx = { path = "../../pi-uutils-ctx" } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/crates/vendor/uu-yes/LICENSE b/crates/vendor/uu-yes/LICENSE new file mode 100644 index 000000000..21bd44404 --- /dev/null +++ b/crates/vendor/uu-yes/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/vendor/uu-yes/src/yes.rs b/crates/vendor/uu-yes/src/yes.rs new file mode 100644 index 000000000..26dea7f91 --- /dev/null +++ b/crates/vendor/uu-yes/src/yes.rs @@ -0,0 +1,351 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// cSpell:ignore strs + +// pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process +// as a shell builtin. All process-global stdio is routed through +// `pi_uutils_ctx`, `translate!` strings are literalized, and the entry point no +// longer calls `std::process::exit`. Because the utility runs inside the shell +// process there is no SIGPIPE to terminate it when the consumer closes, so a +// broken-pipe write error exits cleanly with code 0 (GNU behaviour) on every +// platform, and the output loop polls the scope cancel flag so shell +// abort/timeout stops it promptly. + +use std::{ + error::Error, + ffi::OsString, + io::{self, Write}, +}; + +use clap::{Arg, ArgAction, Command, builder::ValueParser}; +use pi_uutils_ctx::format_usage; +use uucore::error::strip_errno; + +// it's possible that using a smaller or larger buffer might provide better +// performance on some systems, but honestly this is good enough +const BUF_SIZE: usize = 16 * 1024; + +/// In-process builtin entry point. Unlike upstream's `uumain`, this parses the +/// arguments directly (without the uucore clap-localization helper that would +/// terminate the process), renders clap help/usage/version to the context +/// streams, and maps the outcome to an exit code, so it is safe to run inside +/// the host shell process. +pub fn run(argv: Vec) -> i32 { + let matches = match uu_app().try_get_matches_from(argv) { + Ok(matches) => matches, + Err(err) => { + let rendered = err.to_string(); + if err.use_stderr() { + let _ = write!(pi_uutils_ctx::stderr(), "{rendered}"); + return 1; + } + let _ = write!(pi_uutils_ctx::stdout(), "{rendered}"); + return 0; + }, + }; + + let mut buffer = Vec::with_capacity(BUF_SIZE); + #[allow(clippy::unwrap_used, reason = "clap provides 'y' by default")] + let _ = args_into_buffer(&mut buffer, matches.get_many::("STRING").unwrap()); + prepare_buffer(&mut buffer); + + match exec(&buffer) { + // pi-uutils: a broken pipe means the consumer closed its end; a + // process `yes` would die from SIGPIPE (or handle EPIPE on Windows), + // so the in-process builtin exits cleanly with 0 on every platform. + ExecStop::Io(err) if err.kind() == io::ErrorKind::BrokenPipe => 0, + ExecStop::Io(err) => { + let _ = writeln!(pi_uutils_ctx::stderr(), "yes: standard output: {}", strip_errno(&err)); + 1 + }, + // pi-uutils: the shell asked the scope to cancel (abort/timeout); + // there is no signal-style exit status in-process, so return 1. + ExecStop::Cancelled => 1, + } +} + +pub fn uu_app() -> Command { + Command::new("yes") + .version(uucore::crate_version!()) + .about("Repeatedly display a line with STRING (or 'y')") + .override_usage(format_usage("yes [STRING]...")) + .arg( + Arg::new("STRING") + .default_value("y") + .value_parser(ValueParser::os_string()) + .action(ArgAction::Append), + ) + .infer_long_args(true) +} + +/// Copies words from `i` into `buf`, separated by spaces. +#[allow(clippy::unnecessary_wraps, reason = "needed on some platforms")] +fn args_into_buffer<'a>( + buf: &mut Vec, + i: impl Iterator, +) -> Result<(), Box> { + // On Unix (and wasi), OsStrs are just &[u8]'s underneath... + #[cfg(any(unix, target_os = "wasi"))] + { + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt; + #[cfg(target_os = "wasi")] + use std::os::wasi::ffi::OsStrExt; + + for part in itertools::intersperse(i.map(|a| a.as_bytes()), b" ") { + buf.extend_from_slice(part); + } + } + + // But, on Windows, we must hop through a String. + #[cfg(not(any(unix, target_os = "wasi")))] + { + for part in itertools::intersperse(i.map(|a| a.to_str()), Some(" ")) { + let bytes = match part { + Some(part) => part.as_bytes(), + // pi-uutils: literalized `translate!("yes-error-invalid-utf8")`. + None => return Err("arguments contain invalid UTF-8".into()), + }; + buf.extend_from_slice(bytes); + } + } + + buf.push(b'\n'); + + Ok(()) +} + +/// Assumes buf holds a single output line forged from the command line +/// arguments, copies it repeatedly until the buffer holds as many copies as it +/// can under [`BUF_SIZE`]. +fn prepare_buffer(buf: &mut Vec) { + let line_len = buf.len(); + debug_assert!(line_len > 0, "buffer is not empty since we have newline"); + let target_size = line_len * (BUF_SIZE / line_len); // 0 if line_len is already large enough + + while buf.len() < target_size { + let to_copy = std::cmp::min(target_size - buf.len(), buf.len()); + debug_assert_eq!(to_copy % line_len, 0); + buf.extend_from_within(..to_copy); + } +} + +/// pi-uutils: why the output loop stopped. Upstream's `exec` only ever returns +/// an I/O error (the loop is infinite); in-process we also stop on scope +/// cancellation. +enum ExecStop { + Io(io::Error), + Cancelled, +} + +/// pi-uutils: replacement for upstream's `exec` — writes to the context stdout +/// instead of the process stdout and polls the scope cancel flag every +/// iteration (each iteration writes a full [`BUF_SIZE`]-ish batch, so polling +/// per iteration is cheap) so shell abort/timeout stops the loop promptly. +fn exec(bytes: &[u8]) -> ExecStop { + let mut stdout = pi_uutils_ctx::stdout(); + + loop { + if pi_uutils_ctx::is_cancelled() { + return ExecStop::Cancelled; + } + if let Err(err) = stdout.write_all(bytes) { + return ExecStop::Io(err); + } + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::*; + + /// Writer that accepts up to `budget` bytes into a shared buffer, then + /// fails every further write with `fail_kind` — models a consumer that + /// closes the pipe after reading some output. + struct FailingWriter { + buf: Arc>>, + budget: usize, + fail_kind: io::ErrorKind, + } + impl Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.budget == 0 { + return Err(io::Error::new(self.fail_kind, "consumer gone")); + } + let n = buf.len().min(self.budget); + self.budget -= n; + self.buf.lock().extend_from_slice(&buf[..n]); + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn run_with( + args: Vec<&str>, + budget: usize, + fail_kind: io::ErrorKind, + cancelled: bool, + ) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(FailingWriter { + buf: stdout_buf.clone(), + budget, + fail_kind, + }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd: PathBuf::from("."), + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(cancelled)), + }; + + let argv: Vec = std::iter::once("yes") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + + (code, out_str, err_str) + } + + #[test] + fn broken_pipe_is_clean_exit() { + // Consumer takes 100 bytes then closes: exit 0, like GNU yes dying to + // SIGPIPE without an error status visible to the shell. + let (code, stdout, stderr) = run_with(vec![], 100, io::ErrorKind::BrokenPipe, false); + assert_eq!(code, 0); + assert!(stdout.starts_with("y\ny\n"), "expected default 'y' lines, got {stdout:?}"); + assert_eq!(stdout.len(), 100); + assert_eq!(stderr, ""); + } + + #[test] + fn custom_operands_join_with_spaces_and_repeat() { + // Budget is a multiple of the line length ("hello world\n" = 12 bytes) + // so the captured output is whole lines. + let (code, stdout, stderr) = + run_with(vec!["hello", "world"], 12 * 100, io::ErrorKind::BrokenPipe, false); + assert_eq!(code, 0); + assert_eq!(stdout.lines().count(), 100); + for line in stdout.lines() { + assert_eq!(line, "hello world"); + } + assert_eq!(stderr, ""); + } + + #[test] + fn cancellation_stops_loop_promptly() { + // Pre-set cancel flag: the loop must observe it and return 1 before + // writing anything. The finite write budget is a backstop so a broken + // cancel path fails the test (as exit 0) instead of hanging forever. + let (code, stdout, stderr) = run_with(vec![], 1 << 20, io::ErrorKind::BrokenPipe, true); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert_eq!(stderr, ""); + } + + #[test] + fn non_pipe_write_error_reports_and_fails() { + let (code, stdout, stderr) = run_with(vec![], 2, io::ErrorKind::Other, false); + assert_eq!(code, 1); + assert_eq!(stdout, "y\n"); + assert_eq!(stderr, "yes: standard output: consumer gone\n"); + } + + #[test] + fn help_renders_to_scope_stdout() { + let (code, stdout, stderr) = run_with(vec!["--help"], 1 << 20, io::ErrorKind::Other, false); + assert_eq!(code, 0); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("Repeatedly display a line")); + assert_eq!(stderr, ""); + } + + // Upstream unit tests (uutils/coreutils 0.8.0), kept verbatim apart from + // indentation. + + #[test] + fn test_prepare_buffer() { + let tests = [ + (150, 16350), + (1000, 16000), + (4093, 16372), + (4099, 12297), + (4111, 12333), + (2, 16384), + (3, 16383), + (4, 16384), + (5, 16380), + (8192, 16384), + (8191, 16382), + (8193, 8193), + (10000, 10000), + (15000, 15000), + (25000, 25000), + ]; + + for (line, final_len) in tests { + let mut v = std::iter::repeat_n(b'a', line).collect::>(); + prepare_buffer(&mut v); + assert_eq!(v.len(), final_len); + } + } + + #[test] + fn test_args_into_buf() { + { + let mut v = Vec::with_capacity(BUF_SIZE); + let default_args = ["y".into()]; + args_into_buffer(&mut v, default_args.iter()).unwrap(); + assert_eq!(String::from_utf8(v).unwrap(), "y\n"); + } + + { + let mut v = Vec::with_capacity(BUF_SIZE); + let args = ["foo".into()]; + args_into_buffer(&mut v, args.iter()).unwrap(); + assert_eq!(String::from_utf8(v).unwrap(), "foo\n"); + } + + { + let mut v = Vec::with_capacity(BUF_SIZE); + let args = ["foo".into(), "bar baz".into(), "qux".into()]; + args_into_buffer(&mut v, args.iter()).unwrap(); + assert_eq!(String::from_utf8(v).unwrap(), "foo bar baz qux\n"); + } + } +} diff --git a/docs/settings.md b/docs/settings.md index 655c41ee7..e56b620de 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -385,6 +385,31 @@ retry: maxDelayMs: 300000 modelFallback: true fallbackRevertPolicy: cooldown-expiry + fallbackChains: + # Any role without an explicit chain inherits the "default" chain. + default: + - anthropic/claude-opus-4-5 + - openai/gpt-5.5 + - google/gemini-3-pro + # Per-role chains override the default (roles from `modelRoles`, + # including custom roles). Selectors accept an optional thinking + # suffix, e.g. openai/gpt-5.5:low. + smol: + - openai/gpt-5.5-mini + - anthropic/claude-haiku-4-5 + # Model-selector keys (any key containing "/") attach the chain to the + # model itself: it applies whenever that model is active, no matter + # which role it is assigned to, and survives role reassignment. + google/gemini-3-pro: + - google-vertex/gemini-3-pro + # A `provider/*` KEY covers every model of a provider — current or + # future. A `provider/*` ENTRY keeps the failing model's id and swaps + # the provider: google-antigravity/x -> google/x -> google-vertex/x. + # Ids missing on the target provider are skipped (near-miss ids resolve + # fuzzily); exact model keys override the wildcard for a specific model. + google-antigravity/*: + - google/* + - google-vertex/* ``` | Key | Type | Default | Notes | @@ -394,8 +419,10 @@ retry: | `retry.baseDelayMs` | number | `500` | Initial backoff. | | `retry.maxDelayMs` | number | `300000` | Backoff ceiling (5 min). | | `retry.modelFallback` | boolean | `true` | Fall back to another model when one is unavailable. | -| `retry.fallbackChains` | record | `{}` | Per-model fallback chains. | -| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry`, `never`. | +| `retry.fallbackChains` | record | `{}` | Maps roles, model selectors, or `provider/*` wildcards to ordered fallback selectors. Keys containing `/` are model-oriented and win over roles: `provider/model-id` matches that exact model, `provider/*` matches every model of the provider. A `provider/*` *entry* keeps the failing model's id and swaps the provider. The `default` chain covers every assigned role without its own chain. Unknown models/providers or malformed chains are reported as config warnings at startup. | +| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry` returns to the primary model once its suppression window ends; `never` stays on the fallback until switched manually. | + +When the active model keeps failing (429s, quota walls, provider outages) and `retry.modelFallback` is on, the session picks the chain that owns the failing model, by specificity: an exact `provider/model-id` key, then a `provider/*` wildcard, then the current role's chain, then `default`. It skips models whose selectors are still cooling down and switches for the rest of the turn. Subagents get their own per-spawn chains when their agent definition lists multiple model patterns — the first resolvable pattern is primary and the rest become its fallbacks; there is no `agent:` key in `fallbackChains`. ### Tools and approvals @@ -424,7 +451,7 @@ tools: | `tools.artifactTailBytes` | number | `20` | KB of tail kept inline on spill. | | `tools.artifactTailLines` | number | `500` | Max tail lines kept inline on spill. | -Individual built-in tools are toggled by their own keys, e.g. `bash.enabled`, `eval.py`, `eval.js`, `glob.enabled`, `grep.enabled`, `fetch.enabled`, `browser.enabled`, `astEdit.enabled`, `astGrep.enabled`, `web_search.enabled`, `inspect_image.enabled`. +Individual built-in tools are toggled by their own keys, e.g. `bash.enabled`, `launch.enabled`, `eval.py`, `eval.js`, `glob.enabled`, `grep.enabled`, `fetch.enabled`, `browser.enabled`, `astEdit.enabled`, `astGrep.enabled`, `web_search.enabled`, `inspect_image.enabled`. ### Shell, eval, and LSP @@ -454,6 +481,7 @@ lsp: | Key | Type | Default | Notes | |---|---|---|---| | `bash.enabled` | boolean | `true` | Enable the bash tool. | +| `launch.enabled` | boolean | `true` | Enable the launch tool for shared long-running project processes. | | `bash.autoBackground.enabled` | boolean | `false` | Auto-background long-running commands. | | `bash.autoBackground.thresholdMs` | number | `60000` | Threshold before auto-backgrounding. | | `eval.py` | boolean | `true` | Python eval backend. `PI_PY=0` disables for the process. | diff --git a/docs/tools/launch.md b/docs/tools/launch.md new file mode 100644 index 000000000..1a40a7fca --- /dev/null +++ b/docs/tools/launch.md @@ -0,0 +1,122 @@ +# launch + +> Launch and control long-running project processes shared by every omp instance in the same directory. + +## Source +- Tool: `packages/coding-agent/src/tools/launch.ts` +- Broker client: `packages/coding-agent/src/daemon/client.ts` +- Broker runtime: `packages/coding-agent/src/daemon/broker.ts` +- Omp process presence: `packages/coding-agent/src/daemon/presence.ts` +- Protocol: `packages/coding-agent/src/daemon/protocol.ts` +- Model-facing prompt: `packages/coding-agent/src/prompts/tools/launch.md` + +## When to use it +Use `launch` for processes that stay alive after one tool call or need later interaction: +- web development servers and file watchers +- debuggers such as lldb and gdb +- REPLs and interactive application consoles +- local services whose logs or readiness must be observed + +Use `bash` for commands that finish. Async bash remains appropriate for finite commands that need no later stdin; it is not a process supervisor. + +## Operations + +| Operation | Purpose | Main fields | +| --- | --- | --- | +| `start` | Launch a named process. | `name`, `application`, `args`, `env`, `cwd`, `pty`, `ready`, `restart`, `persist`, `detached` | +| `list` | Snapshot every managed process in the current project scope. | none | +| `logs` | Read, filter, or follow captured combined output. | `name`, `lines`, `head`, `grep`, `follow`, `cursor`, `timeout` | +| `wait` | Wait for readiness, exit, or an output regex. | `name`, `for`, `pattern`, `timeout` | +| `send` | Write stdin, terminal keys, or a process signal. | `name`, `text`, `enter`, `keys`, `signal` | +| `stop` | Gracefully terminate the managed process tree, then hard-kill if needed. | `name`, `timeout` | +| `restart` | Stop and relaunch using the retained launch specification. | `name` | +| `describe` | Show the retained launch specification and live state. | `name` | + +Names are stable and unique within one project directory. A live name must be stopped or restarted; starting a completed name creates a new launch and rotates its prior output log. + +## Starting and readiness +`application` and `args` are separate fields, so callers do not need shell quoting: + +```json +{ + "op": "start", + "name": "web", + "application": "bun", + "args": ["run", "dev"], + "ready": { + "log": "Local:.*http", + "port": 5173, + "timeout": 30 + } +} +``` + +Defaults: +- `cwd`: current coding-agent session directory +- `args`: `[]` +- `env`: `{}` over the broker's inherited environment +- `pty`: `true` +- `restart`: `no` +- `persist`: `false` +- `detached`: `false` +- readiness timeout: 30 seconds + +`detached: true` implies `persist: true`, forces `pty: false`, and disables stdin. Its process survives broker shutdown and every omp exit; a later broker reconnects to its records for logs and explicit `stop`. + +`ready.log` is a regular expression matched against captured output. `ready.port` probes TCP at `ready.host` (default `127.0.0.1`). When both are present, both must pass. A readiness timeout leaves the process running and returns its current state so the caller can inspect logs or stop it. + +Without a readiness condition, a successfully created process enters `running`. With readiness configured, it moves `starting` → `ready`; launch or nonzero-exit failures move to `failed`. + +## Logs and following +stdout and stderr are captured into one ordered stream when possible. PTY output is naturally combined. + +```json +{"op":"logs","name":"web","lines":100} +{"op":"logs","name":"web","grep":"error|warn","lines":50} +{"op":"logs","name":"web","follow":true,"cursor":1842,"timeout":30} +``` + +Each logs result returns a byte cursor. `follow: true` waits until output advances beyond the supplied cursor, the process exits, or the timeout elapses, then returns a fresh window. `head: true` reads from the beginning; the default reads the tail. + +The broker keeps a 25 MiB current log and one 25 MiB rotated log while it owns a process's output stream. A detached process writes directly to its disk log so it survives broker exit; output is not rotated while no broker is running. + +## Input and signals + +```json +{"op":"send","name":"debugger","text":"breakpoint set --name main"} +{"op":"send","name":"debugger","text":"run"} +{"op":"send","name":"debugger","keys":["CTRL_C"]} +``` + +`enter` defaults to true when `text` is present. Supported keys are `ENTER`, `TAB`, `ESCAPE`, `CTRL_C`, `CTRL_D`, `UP`, `DOWN`, `LEFT`, and `RIGHT`. Supported signals are `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT`, and `SIGKILL`. + +All project clients may observe the same managed process. Input is one shared stream: each send operation is serialized, but two clients writing independently still address the same process stdin. + +## Cross-instance lifecycle +Every omp session registers its process in the canonical project scope. The first `launch` call starts a detached broker over a private socket; later `launch` calls from any registered omp process connect to the same broker and see the same names, logs, and state. + +Runtime data lives under `~/.omp/run/daemons//`: +- `broker.sock` (or a Windows named pipe) +- a mode-0600 authentication token +- broker PID metadata +- per-managed-process launch metadata and logs +- live omp process-presence records + +After the last tool socket disconnects, the broker checks the project-presence records. Live omp PIDs keep non-persistent managed processes running even when those omp instances have not called `launch`; dead PIDs are removed. Once no omp process remains, the broker waits three seconds, stops every non-persistent managed process, and exits. This PID check still works when an omp process is killed without JavaScript cleanup. + +`persist: true` explicitly opts a managed process out of last-client teardown. A broker with a live persistent process remains available without clients until another omp reconnects and stops it. Broker recovery terminates stale recorded children and preserves their records as exited instead of adopting an unknown process state. + +## Restart policies +- `no`: never restart automatically (default) +- `on-failure`: restart after a nonzero exit or runtime failure +- `always`: restart after any unexpected exit + +Automatic restarts use bounded exponential backoff up to 30 seconds. Explicit `stop` suppresses restart. `restart` always reuses the retained application, arguments, environment, working directory, PTY, readiness, persistence, and detached settings. + +## Errors and limits +- Names must be 1-48 letters, numbers, dots, underscores, or hyphens. +- `ready.port` must be an integer from 1 through 65535. +- Invalid readiness, wait, or log regular expressions are rejected before use. +- Sending to a stopped managed process or to unavailable stdin is an error. +- `logs`, `wait`, and `stop` timeouts are capped at one hour by the tool. +- PTY process-group signaling is POSIX-native. Windows ConPTY accepts input and Ctrl-C; other POSIX signals become hard termination because Windows has no equivalent signal model. diff --git a/package.json b/package.json index 3256bf5bc..7e88c3232 100644 --- a/package.json +++ b/package.json @@ -25,18 +25,18 @@ "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@napi-rs/cli": "3.7.0", - "@oh-my-pi/hashline": "16.4.5", - "@oh-my-pi/omp-stats": "16.4.5", - "@oh-my-pi/pi-agent-core": "16.4.5", - "@oh-my-pi/pi-ai": "16.4.5", - "@oh-my-pi/pi-catalog": "16.4.5", - "@oh-my-pi/pi-coding-agent": "16.4.5", - "@oh-my-pi/pi-mnemopi": "16.4.5", - "@oh-my-pi/pi-natives": "16.4.5", - "@oh-my-pi/pi-tui": "16.4.5", - "@oh-my-pi/pi-utils": "16.4.5", - "@oh-my-pi/pi-wire": "16.4.5", - "@oh-my-pi/snapcompact": "16.4.5", + "@oh-my-pi/hashline": "16.4.8", + "@oh-my-pi/omp-stats": "16.4.8", + "@oh-my-pi/pi-agent-core": "16.4.8", + "@oh-my-pi/pi-ai": "16.4.8", + "@oh-my-pi/pi-catalog": "16.4.8", + "@oh-my-pi/pi-coding-agent": "16.4.8", + "@oh-my-pi/pi-mnemopi": "16.4.8", + "@oh-my-pi/pi-natives": "16.4.8", + "@oh-my-pi/pi-tui": "16.4.8", + "@oh-my-pi/pi-utils": "16.4.8", + "@oh-my-pi/pi-wire": "16.4.8", + "@oh-my-pi/snapcompact": "16.4.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0", @@ -105,6 +105,7 @@ "collab:relay": "bun --cwd=packages/collab-web run relay", "collab:mock-host": "bun --cwd=packages/collab-web run mock-host", "collab:web:build": "bun --cwd=packages/collab-web run build", + "hmgr": "bun --cwd=packages/harbor-manager run dev", "claude:trace": "bun scripts/claude-trace.ts", "build": "bun run --workspaces --if-present build", "build:native": "bun --cwd=packages/natives run build", @@ -149,7 +150,6 @@ "ci:release:publish": "bun scripts/ci-release-publish.ts", "ci:release:publish-native-leaf": "bun scripts/ci-release-publish.ts --native-leaf", "bench:gen-fixtures": "bun --cwd=packages/typescript-edit-benchmark run src/generate.ts --typescript-dir /tmp/typescript-source --count-per-type 8", - "bench:edit": "bun --cwd=packages/typescript-edit-benchmark run start", "stats:sync": "python3 scripts/session-stats/sync.py", "stats:tools": "python3 scripts/session-stats/analyze.py tools", "stats:edits": "python3 scripts/session-stats/analyze.py edits", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 32b89991a..ba7b65aec 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Added automated image-dropping rescue tier to compaction dead-end recovery +- Added visual warnings to the session timeline when compaction fails to free sufficient space + +### Changed + +- Improved compaction dead-end notifications with specific recovery instructions + ## [16.4.5] - 2026-07-11 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index 24a0b7215..ef3ea4a70 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "16.4.5", + "version": "16.4.8", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/agent/src/compaction/entries.ts b/packages/agent/src/compaction/entries.ts index f06299990..a3735805b 100644 --- a/packages/agent/src/compaction/entries.ts +++ b/packages/agent/src/compaction/entries.ts @@ -43,6 +43,12 @@ export interface CompactionEntry extends SessionEntryBase { preserveData?: Record; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromExtension?: boolean; + /** + * Dead-end warning from the post-pass progress guard: the pass completed + * but freed too little for maintenance to continue. Rendered on the + * compaction divider. + */ + warning?: string; } export interface BranchSummaryEntry extends SessionEntryBase { diff --git a/packages/agent/src/compaction/messages.ts b/packages/agent/src/compaction/messages.ts index c9aa3f282..72474a2bd 100644 --- a/packages/agent/src/compaction/messages.ts +++ b/packages/agent/src/compaction/messages.ts @@ -56,6 +56,8 @@ export interface CompactionSummaryMessage { blocks?: (TextContent | ImageContent)[]; /** Snapcompact image blocks, kept for display counts / legacy consumers. */ images?: ImageContent[]; + /** Post-pass dead-end warning attached to this compaction (progress guard). */ + warning?: string; timestamp: number; } @@ -105,6 +107,7 @@ export function createCompactionSummaryMessage( providerPayload?: ProviderPayload, images?: ImageContent[], blocks?: (TextContent | ImageContent)[], + warning?: string, ): CompactionSummaryMessage { const imageBlocks = blocks?.filter((block): block is ImageContent => block.type === "image") ?? @@ -117,6 +120,7 @@ export function createCompactionSummaryMessage( providerPayload, blocks: blocks && blocks.length > 0 ? blocks : undefined, images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, + warning, timestamp: new Date(timestamp).getTime(), }; } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a3541d305..927ed24cb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +### Fixed + +- Fixed empty provider responses (e.g. "Cloud Code Assist API returned an empty response") being classified as non-retryable: `ProviderResponseError` with kind `empty-body` now carries the transient flag, so session retry and configured model-fallback chains engage instead of hard-failing the turn + +## [16.4.6] - 2026-07-12 + +### Added + +- Added asynchronous `invalidateUsageCache` method to clear cached usage reports +- Added support for cross-service usage cache invalidation between AuthStorage and AuthBroker + +### Fixed + +- Fixed OAuth credential resolution returning "No API key found" when every plan-eligible OpenAI Codex account was rate-limit blocked and the only unblocked account failed the model's plan gate: resolution now runs a last-resort ladder that first yields a plan-fitting account regardless of usage blocks (so callers get real usage-limit retry semantics), then tries every account with the plan filter dropped before reporting no credential + ## [16.4.5] - 2026-07-11 ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 18ead39b3..2ebedffb1 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "16.4.5", + "version": "16.4.8", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/ai/src/auth-broker/client.ts b/packages/ai/src/auth-broker/client.ts index d873acfe6..c90d83f4f 100644 --- a/packages/ai/src/auth-broker/client.ts +++ b/packages/ai/src/auth-broker/client.ts @@ -21,6 +21,7 @@ import type { SnapshotResponse, SnapshotStreamEvent, UsageResponse, + UsageStaleResponse, } from "./types"; import { credentialBlockResponseSchema, @@ -32,6 +33,7 @@ import { snapshotResponseSchema, snapshotStreamEventSchema, usageResponseSchema, + usageStaleResponseSchema, } from "./wire-schemas"; export interface AuthBrokerClientOptions { @@ -242,6 +244,13 @@ export class AuthBrokerClient { return this.#request("GET", "/v1/usage", { schema: usageResponseSchema, signal }); } + notifyUsageStale(signal?: AbortSignal): Promise { + return this.#request("POST", "/v1/usage/stale", { + schema: usageStaleResponseSchema, + signal, + }); + } + async refreshCredential(id: number, signal?: AbortSignal): Promise { return this.#request("POST", `/v1/credential/${id}/refresh`, { schema: credentialRefreshResponseSchema, diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index 4351fe616..0a13a9cfe 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -794,6 +794,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore { } } + async invalidateUsageCache(signal?: AbortSignal): Promise { + this.#invalidateUsageCache(); + await this.#client.notifyUsageStale(signal).catch(err => { + logger.warn("auth-broker notification of stale usage failed", { error: String(err) }); + }); + } + #invalidateUsageCache(): void { this.#usageCache = undefined; this.#usageInflight = undefined; diff --git a/packages/ai/src/auth-broker/server.ts b/packages/ai/src/auth-broker/server.ts index bf5b8afb3..ce8bdf06f 100644 --- a/packages/ai/src/auth-broker/server.ts +++ b/packages/ai/src/auth-broker/server.ts @@ -608,6 +608,17 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer return json(502, { error: message }); } } + if (req.method === "POST" && pathname === "/v1/usage/stale") { + try { + opts.storage.invalidateUsageCache?.(); + logger.info("auth-broker usage cache invalidated", { peer }); + return json(200, { ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn("auth-broker usage cache invalidation failed", { peer, error: message }); + return json(500, { error: message }); + } + } const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null; if (refreshMatch) { const id = Number.parseInt(refreshMatch[1], 10); diff --git a/packages/ai/src/auth-broker/types.ts b/packages/ai/src/auth-broker/types.ts index 6a329d2ca..d571215cf 100644 --- a/packages/ai/src/auth-broker/types.ts +++ b/packages/ai/src/auth-broker/types.ts @@ -75,6 +75,11 @@ export interface CredentialBlocksDeleteResponse { ok: boolean; } +/** POST /v1/usage/stale response body. */ +export interface UsageStaleResponse { + ok: boolean; +} + /** * POST /v1/credential request body. The OAuth `refresh` must be the *real* * refresh token (not the sentinel) — the broker is the canonical writer. diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts index 35180ebf1..bece9ce55 100644 --- a/packages/ai/src/auth-broker/wire-schemas.ts +++ b/packages/ai/src/auth-broker/wire-schemas.ts @@ -258,6 +258,11 @@ export const credentialBlocksDeleteResponseSchema = type({ ok: "boolean", }); +export const usageStaleResponseSchema = type({ + "+": "reject", + ok: "boolean", +}); + // ─── Upload ──────────────────────────────────────────────────────────────── export const credentialUploadRequestSchema = type({ diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 382e053e2..245c118c6 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -315,6 +315,8 @@ export interface CredentialRefreshLeaseFence { export interface AuthCredentialStore { close(): void; + /** Optional hook to notify the underlying store that usage report cache is stale. */ + invalidateUsageCache?(signal?: AbortSignal): Promise; listAuthCredentials(provider?: string): StoredAuthCredential[]; updateAuthCredential(id: number, credential: AuthCredential): void; deleteAuthCredential(id: number, disabledCause: string): void; @@ -3739,8 +3741,17 @@ export class AuthStorage { /** * Resolves an OAuth credential, trying credentials in priority order. - * Skips blocked credentials and checks usage limits for providers with usage data. - * Falls back to earliest-unblocking credential if all are blocked. + * + * Resolution ladder — a request in hand always beats "no API key": + * 1. strict: unblocked credentials only, usage limits respected, plan + * filter enforced (when any account is confirmed eligible); + * 2. plan-fitting last resort: same plan filter, but blocked/exhausted + * accounts are allowed (blocked candidates rank earliest-unblocking + * first) so the caller gets real usage-limit semantics from the wire + * instead of a missing key; + * 3. unfiltered last resort: the plan filter matched nothing usable — + * skip it and try every account once; the server is the final arbiter + * of model access. * * Returns both the API key bytes for outbound requests AND the refreshed * {@link OAuthCredential} so callers needing identity metadata (account id, @@ -3890,42 +3901,34 @@ export class AuthStorage { hasPlanRequirement && candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true); - const fallback = candidates[0]; + const passes: Array<{ allowBlocked: boolean; enforcePlanRequirement: boolean }> = [ + { allowBlocked: false, enforcePlanRequirement }, + { allowBlocked: true, enforcePlanRequirement }, + ]; + if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false }); - for (const candidate of candidates) { - const resolved = await this.#tryOAuthCredential( - provider, - candidate.selection, - providerKey, - sessionId, - options, - { - checkUsage, - allowBlocked: false, - prefetchedUsage: candidate.usage, - usagePrechecked: candidate.usageChecked, - planRequirement, - enforcePlanRequirement, - strategy, - rankingContext, - blockScope, - }, - ); - if (resolved) return resolved; - } - - if (fallback && this.#isCredentialBlocked(provider, providerKey, fallback.selection.index, blockScope)) { - return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, { - checkUsage, - allowBlocked: true, - prefetchedUsage: fallback.usage, - usagePrechecked: fallback.usageChecked, - planRequirement, - enforcePlanRequirement, - strategy, - rankingContext, - blockScope, - }); + for (const pass of passes) { + for (const candidate of candidates) { + const resolved = await this.#tryOAuthCredential( + provider, + candidate.selection, + providerKey, + sessionId, + options, + { + checkUsage, + allowBlocked: pass.allowBlocked, + prefetchedUsage: candidate.usage, + usagePrechecked: candidate.usageChecked, + planRequirement, + enforcePlanRequirement: pass.enforcePlanRequirement, + strategy, + rankingContext, + blockScope, + }, + ); + if (resolved) return resolved; + } } return undefined; @@ -4666,6 +4669,11 @@ export class AuthStorage { }); if (result.ok) { this.#invalidateUsageReportCache(provider, baseUrl); + if (this.#store.invalidateUsageCache) { + await this.#store.invalidateUsageCache(options.signal).catch(err => { + logger.debug("Failed to notify store of stale usage", { err }); + }); + } // The window this credential was blocked on (by markUsageLimitReached) // is now reset, so lift its temporary block — otherwise selection // keeps skipping/under-ranking the freshly-reset account. @@ -4691,6 +4699,40 @@ export class AuthStorage { } } + /** + * Force-invalidate cached usage reports so the next fetch retrieves fresh + * values from upstream providers. If `provider` is specified, only that + * provider's credentials are invalidated; otherwise, all credentials in the + * store are invalidated. + */ + async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise { + if (provider) { + this.#invalidateUsageReportCache(provider); + } else { + this.#usageCacheEpoch += 1; + const expired = Date.now() - 1; + try { + const credentials = this.#store.listAuthCredentials(); + for (const entry of credentials) { + if (entry.credential.type !== "oauth") continue; + const cacheKey = this.#buildUsageReportCacheKey( + this.#buildUsageRequestForOauth(entry.provider, entry.credential), + ); + const existing = this.#usageCache.getStale(cacheKey); + this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired }); + } + } catch (err) { + logger.debug("Failed to list auth credentials for complete usage cache invalidation", { err }); + } + } + + if (this.#store.invalidateUsageCache) { + await this.#store.invalidateUsageCache(signal).catch(err => { + logger.debug("Failed to notify store of stale usage", { err }); + }); + } + } + #invalidateUsageReportCacheForProviderKey(providerKey: string): void { const oauthSuffix = ":oauth"; if (!providerKey.endsWith(oauthSuffix)) return; diff --git a/packages/ai/src/error/provider.ts b/packages/ai/src/error/provider.ts index 36d83ee48..12c15e0d9 100644 --- a/packages/ai/src/error/provider.ts +++ b/packages/ai/src/error/provider.ts @@ -39,10 +39,11 @@ export class ProviderResponseError extends Error { // A safety filter block is a terminal provider finish, not a transient fault. if (this.kind === "content-blocked") attach(this, create(Flag.ProviderFinishError)); // An incomplete stream (connection dropped / truncated before any terminal - // event) never produced a finish reason — the request didn't complete, so it - // is safe to retry. The retry layer's replay-unsafe guard still blocks a - // retry when partial tool output was already emitted. - else if (this.kind === "incomplete-stream") attach(this, create(Flag.Transient)); + // event) or an empty body never produced any content — the request didn't + // complete, so it is safe to retry and eligible for model fallback. The + // retry layer's replay-unsafe guard still blocks a retry when partial tool + // output was already emitted. + else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient)); } } diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index 9131cde81..d21e816b4 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -425,7 +425,7 @@ export async function transformRequestBody( body.text = { ...body.text, - verbosity: options.textVerbosity || "high", + verbosity: options.textVerbosity || "medium", }; const include = Array.isArray(options.include) ? [...options.include] : []; diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 50d859786..316de7262 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -1566,6 +1566,84 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-go"); }); + test("yields an exhausted paid account over an idle free account for a paid-gated model", async () => { + if (!authStorage) throw new Error("test setup failed"); + + // Regression: with every plan-eligible account usage-blocked and an + // unblocked free account present, resolution used to return NO + // credential at all ("No API key found") because the old last-resort + // fallback only fired when the top-ranked candidate happened to be + // blocked — and the idle free account ranked first. The plan-fitting + // last-resort pass must yield the exhausted paid account instead, so + // the caller gets real usage-limit semantics from the wire. + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-paid", "paid@example.com") }, + ]); + + usageByAccount.set( + "acct-free", + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 0.05, resetInMs: 30 * 60 * 1000 }, + secondary: { usedFraction: 0.05, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com" }, + }), + ); + usageByAccount.set( + "acct-paid", + createCodexUsageReport({ + accountId: "acct-paid", + primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "plus", email: "paid@example.com", limitReached: true }, + }), + ); + + const apiKey = await authStorage.getApiKey("openai-codex", "session-paid-gated-all-blocked", { + modelId: "gpt-5.6-sol", + }); + expect(apiKey).toBe("api-acct-paid"); + }); + + test("attempts every exhausted account for a paid-gated model until one passes the plan gate", async () => { + if (!authStorage) throw new Error("test setup failed"); + + // Production shape of the same regression: EVERY seat is usage-blocked + // (the free seat resets soonest, so it leads the blocked ordering) and + // only a later-resetting paid seat can serve gpt-5.6-sol. The blocked + // pass must keep iterating past the plan-ineligible free seat instead + // of giving up after the first blocked candidate. + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-paid", "paid@example.com") }, + ]); + + usageByAccount.set( + "acct-free", + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 }, + secondary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com", limitReached: true }, + }), + ); + usageByAccount.set( + "acct-paid", + createCodexUsageReport({ + accountId: "acct-paid", + primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "plus", email: "paid@example.com", limitReached: true }, + }), + ); + + const apiKey = await authStorage.getApiKey("openai-codex", "session-sol-all-exhausted", { + modelId: "gpt-5.6-sol", + }); + expect(apiKey).toBe("api-acct-paid"); + }); + test("prefers Pro accounts for codex spark models over Plus accounts", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/ai/test/error-aierr.test.ts b/packages/ai/test/error-aierr.test.ts index 57617b60e..a9458255c 100644 --- a/packages/ai/test/error-aierr.test.ts +++ b/packages/ai/test/error-aierr.test.ts @@ -69,6 +69,19 @@ describe("AIError.classify — structural provider errors", () => { expect(AIError.retriable(id)).toBe(true); }); + it("classifies an empty provider response as transient + retryable", () => { + // Regression: "Cloud Code Assist API returned an empty response" matched no + // text pattern and empty-body carried no flag, so retry/model-fallback + // chains never engaged and the turn hard-failed. + const err = new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", { + provider: "google-antigravity", + kind: "empty-body", + }); + const id = AIError.classify(err); + expect(AIError.is(id, AIError.Flag.Transient)).toBe(true); + expect(AIError.retriable(id)).toBe(true); + }); + it("does not mark a terminal output provider error as transient", () => { const err = new AIError.ProviderResponseError("upstream error", { provider: "google", kind: "output" }); expect(AIError.retriable(AIError.classify(err))).toBe(false); diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index e2c81b38f..0167f7569 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -731,4 +731,23 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => { expect(clientStorage.get("kagi")).toBeUndefined(); clientStorage.close(); }); + + test("client AuthStorage invalidateUsageCache notifies broker to invalidate server-side cache", async () => { + const brokerClient = new AuthBrokerClient({ url: handle!.url, token }); + const initialResult = await brokerClient.fetchSnapshot(); + if (initialResult.status !== 200) throw new Error("expected snapshot"); + const remoteStore = new RemoteAuthCredentialStore({ + client: brokerClient, + initialSnapshot: initialResult.snapshot, + }); + const clientStorage = new AuthStorage(remoteStore); + await clientStorage.reload(); + + const serverInvalidateSpy = vi.spyOn(serverStorage!, "invalidateUsageCache"); + + await remoteStore.invalidateUsageCache(); + + expect(serverInvalidateSpy).toHaveBeenCalled(); + clientStorage.close(); + }); }); diff --git a/packages/catalog/package.json b/packages/catalog/package.json index f2da06966..98580554d 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-catalog", - "version": "16.4.5", + "version": "16.4.8", "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 29a2aae36..b5669ce7e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,122 @@ ## [Unreleased] +### Breaking Changes + +- Replaced the `--reasoning-slide-*` flag family (`--reasoning-slide-model`, `--reasoning-slide-turns`, `--reasoning-slide-on-action`, `--reasoning-slide-plan`, `--reasoning-slide-plan-at`, `--reasoning-slide-checklist`) with a single downshift mechanism: `--downshift` switches from the starting model to a fast/cheap target at the first completed turn that starts execution — the todo-list init the plan nudge asks for, or any edit/write tool — always with the hidden plan nudge before the switch and the verify-before-finishing checklist after it (the configuration that won benchmark testing). `--downshift-into ` overrides the default "smol"-role target and implies `--downshift`; `--no-downshift` force-disables. The fixed-turn trigger and per-piece plan/checklist toggles are gone. + +### Added + +- Added `--downshift` / `--downshift-into ` / `--no-downshift`: start on a strong model, then hand off to a fast/cheap one (default the `smol` role) at the first edit/write tool call *after* the todo list has been initialized. The starting model handles all planning and todo initialization, and begins implementation, before handing off; the fast model includes a verify checklist before finishing. Enable per-user with the `downshift.enabled` setting; force it mid-session with the new `/downshift` slash command, which arms the switch. +- Added display setting to toggle between collapsing or keeping compacted history inline, now applied to live session displays +- Added a compact session-only model picker (Alt+P) for quick model switching without changing roles +- Added `@` search to the Alt+P / `/switch` picker: it lists configured Ctrl+P quick roles in matching segment colors and applies the selected role's model and thinking for the current session. +- Redesigned Agent Hub entries as two-line cards: identity (status glyph, name, agent type, parent when nested) on the left, active model + reasoning level and age right-aligned, with the task description on its own line; dropped the redundant `sub · of Main` noise +- Added a project-scoped `launch` tool for shared long-running services and debuggers, with readiness probes, bounded logs, PTY input, restart policies, and automatic teardown after the last omp instance exits. Gated behind the `launch.enabled` setting (default on); when disabled the tool is withdrawn and the bash prompt drops its "use launch" guidance. +- Added `detached` `launch` starts for standalone services that survive every omp instance and broker shutdown, then reconnect to the next broker for logs and explicit stop. + +### Changed + +- Refined the downshift planning instructions to require a super-detailed todo list with one item per concrete step, each specifying its target and verification method +- Updated tangential agent forks to ignore parent session history and focus exclusively on the new request +- Hardened `/tan` fork isolation: the clone's inherited todo list is cleared at fork (parent todo reminders no longer drag the tan back onto the parent's task), the fork notice warns that the parent is concurrently editing the same working directory, and the notice is re-injected after each compaction so the fork boundary survives summarization +- Added visual markers in the transcript for elided tool calls that have no corresponding result +- Updated status event log to prioritize the most recent entries in the display window +- Updated the snapcompact shape preview transcript to use the compact scope format shown to models during compaction. + +### Removed + +- Removed the `--downshift-boomerang` feature and its associated configuration setting +- Removed the unreliable Bing and Yahoo HTML-scraping web search providers + +### Fixed + +- Fixed `/tan` and `/fork` clones cold-missing the provider prompt cache: the per-turn supersede/useless-result prune rewrote the live context without persisting it, so file-based forks and resume rebuilt a divergent (un-pruned) prefix and re-wrote the entire cache +- Fixed `/tan` pinning the clone's prompt-cache key to the parent's session id instead of the parent's effective cache key, dropping shard affinity when the parent was itself a fork or tan +- Fixed inconsistent history rendering when toggling the display setting for compacted items +- Fixed configured `retry.fallbackChains` never engaging on non-retryable provider errors (e.g. "Cloud Code Assist API returned an empty response"): a hard error on a model covered by a fallback chain now switches to the next candidate instead of failing the turn, while still never backoff-retrying the failing model itself +- Fixed transcript rebuilds (compaction, `/compact`, and toggling history display) repainting content below stale scrollback when collapsing history; rebuilds now correctly clear the scrollback buffer when history is collapsed +- Improved auto-compaction to automatically drop images and elide content when context is tight, and added persistent warning badges to the compaction divider when manual intervention is required +- Fixed backgrounded Bash blocks continuing to repaint with live and final job output; they now freeze with a compact job notice while completion is delivered separately +- Fixed the downshift plan nudge silently ending the run with no code written when the model answered with a text-only reply (no tool call): the agent loop treats a tool-call-free turn as a natural stop and never prompts again, which the nudge's own "write the plan in your next reply" instruction makes common. The nudge now explicitly tells the model this is a checkpoint, not a final answer, and the session forces one more turn whenever a post-nudge reply lands with zero tool calls +- Fixed launch tool rendering stacking a stale pending header over a bare `✓ Launch` line and raw text: the tool now uses a merged registry renderer with one per-op status header (op, target, `state · pid · uptime` meta), stripped log cursor suffixes, capped collapsed log/list previews, and a launch tool glyph +- Fixed confusing launch start/wait results when readiness timed out with the log pattern already matched (readiness needs log AND port): the result printed a contradictory `Ready: ` next to `Readiness timed out` without naming the failing condition. Daemon snapshots now carry the unmet conditions (`readyPending`), and start/wait results state exactly what never happened (e.g. `port 3100 on 127.0.0.1 never accepted connections`); the TUI shows a `waiting on port` badge on starting daemons +- Fixed the in-process `stat` builtin mangling BSD-style invocations like `stat -f "%Sm %N" file` (macOS muscle memory): GNU `-f` means `--file-system`, so the format string was treated as a file operand — printing filesystem info for the real operands and erroring with `cannot read file system information for '%Sm %N'`. A `-f` whose format value contains `%` is now detected as BSD syntax and translated to the GNU equivalent (`%Sm`→`%y`, `%N`→`%n`, `%z`→`%s`, epoch/`S`-form times, owner/group/permission and `H`/`L` sub-field directives, `-L`/`-n`/`-q`/`-F` flag clusters, with `%n`/`%t` as literal newline/tab); directives with no GNU counterpart fail with a clear `unsupported BSD format directive` error +- Fixed the remaining GNU-flavored shell builtins that broke under macOS/BSD muscle memory, using the same unambiguous-detection approach as the `stat` fix (only invocations that are invalid or nonsensical under GNU semantics are reinterpreted; unsupported BSD forms fail loudly instead of producing wrong output): `date -r ` formats the epoch when no such file exists (GNU `-r FILE` mtime preserved), signed `date -v±N` adjustments translate to `-d` relative dates and `-j` is accepted (`-j -f` strptime parse mode and field-set `-v` error clearly); `sed -i '' 's/…/…/' file` drops the BSD empty backup-suffix token instead of treating it as the script; `mktemp -t prefix` without X's creates `$TMPDIR/prefix.XXXXXXXXXX` (the GNU `too few X's` error path); `tail -r` reverses input by delegating to `tac` (with `-n`/`-c`/`-f` combinations erroring clearly); `find -E` maps to `-regextype posix-extended` ahead of the expression; `base64 -D` decodes as an alias of `-d`; and `ln -sfh` works via a `-h` alias of `--no-dereference` (clap's `-h` help short is dropped to match real GNU/BSD ln; `--help` unchanged) + +## [16.4.8] - 2026-07-12 +### Added + +- Added a predicate form to the browser run's `wait()` helper: `wait(fn, { timeout?, interval? })` polls the function (sync or async) until truthy and resolves with that value, failing with a named timeout error (deadline clamped under the cell budget so it always beats the opaque whole-cell timeout) instead of Bun's `sleep expects a number` or a whole-cell stall from in-page polling Promises; both `wait` forms now register in the stall diagnosis of cell timeouts +- Added `--reasoning-slide-model` and `--reasoning-slide-turns` to switch a running agent from its initial model after a fixed number of completed assistant turns +- Added `--reasoning-slide-plan` (with `--reasoning-slide-plan-at`) to steer a hidden deep-planning nudge into the run before the reasoning slide; the switch is held until a substantial plan turn actually lands (bounded by a grace window) and the nudge is scrubbed from the LLM context at the switch so the fast model inherits only the produced plan +- Added `--reasoning-slide-on-action` to trigger the reasoning slide at the first completed turn that ran an edit/write tool instead of a fixed turn count (bash is excluded — it doubles as exploration) + +### Changed + +- Replaced the Alt+P / `/switch` temporary model selector's fullscreen /models hub with a compact full-width floating overlay anchored above the editor (~40% of the terminal height): just the searchable model list — no provider sidebar or role management — with the session's active model highlighted and preselected +- Improved tab recovery after timeouts by automatically clearing pending navigation and JS dialogs +- Made `tab.goto` navigation failures catchable with a named error instead of triggering a whole-cell timeout +- Made `tab.evaluate` run in the page's main JavaScript world so page-defined globals are available without a directive +- Enhanced cell timeout messages to include identification of stalled operations and blocking JS dialogs +- Browser `run` on a tab the supervisor force-killed now reports the kill reason instead of a bare "not alive" +- Refined agent workflow to prioritize smoke testing and reduce mandatory upfront test generation + +### Fixed + +- Fixed the eval tool's status-event tree truncating from the bottom: the newest `log()` progress lines were hidden behind an `… N more` marker while the oldest stayed visible; the tree now shows a tail window behind an `… N earlier` marker, and the expanded view widens to the viewport instead of a fixed 10 events +- Fixed the `//!world=main` directive being silently ignored for string expressions passed to raw Puppeteer evaluation APIs +- Fixed tab reuse issues where hung navigation or unhandled modals would cause initialization to stall and trigger a force-kill +- Improved search reliability for Perplexity provider by forcing retrieval for all queries +- Fixed JS eval cells losing top-level `function` and `var` declarations across cells when the defining cell contained top-level `await` — the async wrapper scoped them to the cell's IIFE instead of publishing them to the worker global + +## [16.4.7] - 2026-07-12 + +### Added + +- Enabled Home and End keyboard navigation in the model browser +- Added a `c` hotkey in the plan-review overlay that copies the current reviewed plan markdown to the system clipboard, including in-overlay edits. + +### Changed + +- Streamlined list view styling by removing inline model role chips from row entries +- Reworked /models hub selection visuals: the background highlight band is reserved for mouse hover, the keyboard position is a cursor glyph drawn only in the pane that owns the arrow keys, and the sidebar's active scope renders as a bold accent label +- Removed the redundant "login" label from inactive (locked) provider entries in the Model Hub sidebar + +### Fixed + +- Fixed PageUp/PageDown in the model browser wrapping past the list edges instead of clamping +- Fixed the hover highlight sticking to the last hovered model row when the pointer moved into the provider sidebar + +## [16.4.6] - 2026-07-12 + +### Added + +- Added `invalidate` action to the usage command to clear cached usage reports +- Added model-oriented keys and wildcard entries to `retry.fallbackChains`: a `provider/model-id` key attaches a fallback chain to that exact model, a `provider/*` key covers every current or future model of a provider, and a `provider/*` chain entry keeps the failing model's id while swapping the provider (`google-antigravity/x` → `google/x`) — so fallbacks survive role and model reassignments without config edits. Keys resolve by specificity: exact model, then provider wildcard, then role, then `default`. +- Added fallback-chain editing to the /models Roles view: each role's `retry.fallbackChains` entries render as indented rows beneath it, `f` picks a fallback model to append, Enter on an entry replaces it, `x`/backspace removes it, and `[`/`]` (or shift+↑/↓) reorder the chain. +- Added model-keyed fallback management to the /models Roles view: model and `provider/*` chains render as a separate section below the roles (divider + "+ New fallback…" row for creating one by picking the protected model, then keying it by model or provider), with the same replace/remove/reorder editing as role chains; the model strip gains `fallbacks:` and `fallbacks:/*` chips as shortcuts. +- Added `/queue ` plus `->` / `=>` composer shorthand for follow-up messages that wait until the agent yields. The shorthand opens a dim `Queueing` header and splits sequential numeric, Roman-numeral, or alphabetic lists into separately highlighted queue entries. +- Added per-model TPS/TTFT tracking: every completed assistant turn folds its timing into recency-weighted aggregates in `~/.omp/agent.db`, and the /models browser shows measured speed — a right-aligned `118t/s` column on wide terminals (plus TTFT, e.g. `0.9s 118t/s`, when wider) and `~118t/s · 0.9s ttft` facts in the selection detail line — with no dependency on the `omp stats` session scan. + +### Changed + +- Retain completed and abandoned tasks in session history for improved context on resume +- Changed the Model Hub `retry-fallback` strip chip to append the model to the default fallback chain instead of prepending it, matching the chain-building order of the Roles view (already-registered models are a no-op). +- Changed per-model perf recording (`recordModelPerf`) to be deferred like prompt history: samples are batched and written to `agent.db` in one transaction ~100ms later, keeping SQLite writes off the turn-completion hot path. + +### Fixed + +- Fixed failure to trigger model fallback when the retry budget is exhausted by credential rotation +- Fixed uncontrollable mouse-wheel scrolling in the /models hub: the wheel moved the selection (one step per wheel event, so a single trackpad flick skipped many rows) and wrapped from the bottom back to the top. Wheel scrolling now pans the list viewport only, clamps at the ends, and leaves the selection where it is; keyboard navigation still scrolls the selection into view. Likewise, the wheel over the provider sidebar no longer switches the active scope (or triggers provider refreshes) — it just scrolls the sidebar. +- Fixed TPS being inflated several-fold when a provider hides reasoning tokens until late in the stream (e.g. `google/gemini-3.5` vs `google-vertex/gemini-3.5` reporting 648 vs 186 TPS for identical durations): `omp bench`, the per-turn usage row, and the /models perf aggregates now measure tokens/sec over the total request duration instead of the post-TTFT decode window, matching `omp stats`. Stored perf aggregates are purged and re-backfilled from stats history with the corrected math on first launch. +- Fixed the model-perf stats.db backfill freezing the TUI (~30s on multi-million-row stats databases) when /models triggered it: the import is now fire-and-forget, walks the newest rows in small chunks with event-loop yields between them, and is bounded to 90 days / 256 newest samples per model — beyond either bound the recency decay would erase the contribution anyway. +- Fixed compiled release binaries bundling `fastembed` and baking the build-machine `@anush008/tokenizers` path; native runtime dependencies now stay external for every compiled build path so Mnemopi resolves its on-demand install instead. ([#5195](https://github.com/can1357/oh-my-pi/issues/5195)) +- Fixed `/btw` side-channel turns on Codex models such as `gpt-5.6-luna` by preserving the session websocket preference instead of forcing SSE, and made Esc dismiss the active `/btw` panel before interrupting loop/maintenance work. ([#5213](https://github.com/can1357/oh-my-pi/issues/5213)) +- Fixed the Model Hub role-assignment strip hiding the selected chip once the row overflowed; the strip now scrolls horizontally, truncating passed chips behind a leading ellipsis so the selection (plus one chip of lookahead) stays visible. +- Fixed mouse hover and clicks in the /models Roles view landing one row above the pointer (the row mapping subtracted the status row twice). +- Fixed model search keeping the most-recently-used model on top of the results: match quality now ranks first (an exact `gpt-5.5` beats the active `gpt-5.6-sol`), with MRU order only breaking ties between equally good matches. + ## [16.4.5] - 2026-07-11 ### Breaking Changes diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 562d498a0..76b3c5bd5 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -19,9 +19,7 @@ Run from `packages/coding-agent/` (or add `--cwd=packages/coding-agent`): | Types only | `bun run check:types` | | Lint only | `bun run lint` | | Tests | `bun run test` | -| Autofix: lint + format prompts + docs index | `bun run fix` | -| Reformat prompt `.md` assets | `bun run format-prompts` | -| Regenerate the `docs://` index | `bun run gen:docs` | +| Autofix: lint + format prompts | `bun run fix` | | Build the `dist/omp` binary | `bun run build` | Never invoke `tsc`/`npx tsc` directly — `bun run check` is the typecheck gate. After diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 247d4a6ca..57a83ecfa 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "16.4.5", + "version": "16.4.8", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://omp.sh", "author": "Can Boluk", diff --git a/packages/coding-agent/scripts/build-binary.ts b/packages/coding-agent/scripts/build-binary.ts index 221ade91c..1fb51fcfa 100644 --- a/packages/coding-agent/scripts/build-binary.ts +++ b/packages/coding-agent/scripts/build-binary.ts @@ -99,7 +99,6 @@ async function main(): Promise { outfile: outputPath, transformersVersion, target: crossBuild?.target, - external: ["fastembed", "onnxruntime-node"], skipBuiltinCodesign: shouldAdhocSignDarwinBinary(crossBuild), }); diff --git a/packages/coding-agent/scripts/compile-binary.ts b/packages/coding-agent/scripts/compile-binary.ts index 5e7d27710..495612041 100644 --- a/packages/coding-agent/scripts/compile-binary.ts +++ b/packages/coding-agent/scripts/compile-binary.ts @@ -1,6 +1,9 @@ import { buildDocsIndexPayload } from "./generate-docs-index"; import { createLegacyPiVirtualModulePlugin } from "./legacy-pi-virtual-module"; +/** Native runtime dependencies always resolved from the on-demand install instead of embedded into compiled binaries. */ +export const COMPILED_EXTERNAL_DEPENDENCIES: readonly string[] = Object.freeze(["fastembed", "onnxruntime-node"]); + /** Inputs shared by local and release coding-agent binary builds. */ export interface CodingAgentCompileOptions { /** Absolute repository root used for package resolution. */ @@ -13,8 +16,6 @@ export interface CodingAgentCompileOptions { readonly transformersVersion: string; /** Optional cross-compilation runtime target. */ readonly target?: Bun.Build.CompileTarget; - /** Dependencies intentionally resolved from the runtime filesystem. */ - readonly external?: readonly string[]; /** Match release builds that minify identifiers while retaining names. */ readonly minifyIdentifiers?: boolean; /** Disable Bun's built-in Darwin signing before the caller re-signs. */ @@ -34,7 +35,7 @@ export async function compileCodingAgent(options: CodingAgentCompileOptions): Pr const output = await Bun.build({ entrypoints: [options.entrypoint], root: options.repoRoot, - external: options.external ? [...options.external] : undefined, + external: [...COMPILED_EXTERNAL_DEPENDENCIES], define: { "process.env.PI_COMPILED": JSON.stringify("true"), "process.env.PI_TINY_TRANSFORMERS_VERSION": JSON.stringify(options.transformersVersion), diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 0de859a90..20ad9038c 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -27,6 +27,7 @@ import { import { declareWorkerHostEntry, installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias"; import { extractProfileFlags } from "./cli/profile-bootstrap"; +import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol"; if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) { process.stderr.write( @@ -78,6 +79,8 @@ async function runSmokeTest(): Promise { const { smokeTestTtsWorker } = await import("./tts/tts-client"); const { smokeTestMnemopiEmbedWorker } = await import("./mnemopi/embed-client"); const { smokeTestJsEvalWorker } = await import("./eval/js/context-manager"); + // Smoke dependencies stay lazy so normal CLI startup does not load worker clients. + const { smokeTestDaemonBroker } = await import("./launch/client"); await smokeTestSyncWorker(); const statsServer = await startServer(0); @@ -97,6 +100,7 @@ async function runSmokeTest(): Promise { await smokeTestJsEvalWorker(); await smokeTestTtsWorker(); await smokeTestMnemopiEmbedWorker(); + await smokeTestDaemonBroker(); process.stdout.write("smoke-test: ok\n"); } @@ -167,6 +171,12 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise { await runIpcSubprocessWorker(startMnemopiEmbedWorker); return true; } + if (arg === DAEMON_BROKER_WORKER_ARG) { + // Worker selectors must dispatch before the normal command graph loads. + const { startDaemonBrokerFromEnvironment } = await import("./launch/broker"); + await startDaemonBrokerFromEnvironment(); + return true; + } return false; } diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d5a0b18d4..d60d3a9d3 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -27,6 +27,11 @@ export interface Args { smol?: string; slow?: string; plan?: string; + downshift?: boolean; + noDownshift?: boolean; + downshiftInto?: string; + planYolo?: boolean; + planYoloInto?: string; maxTime?: number; apiKey?: string; systemPrompt?: string; @@ -231,6 +236,12 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map 0 && deltaChunkCount >= 2 ? decodeMs : durationMs; - return windowMs > 0 ? (outputTokens * 1000) / windowMs : 0; -} - interface BenchRequestOptions { apiKey: ApiKeyResolver; sessionId: string; @@ -247,17 +230,10 @@ async function runBenchRequest( headers: model.provider === "openrouter" ? { "X-OpenRouter-Cache": "false" } : undefined, }); let message: AssistantMessage | undefined; - let deltaChunkCount = 0; for await (const event of stream) { if (firstTokenAt === undefined && isFirstTokenEvent(event)) { firstTokenAt = now(); } - if ( - (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") && - event.delta.length > 0 - ) { - deltaChunkCount++; - } if (event.type === "error") { return { ok: false, error: event.error.errorMessage ?? "request failed" }; } @@ -291,7 +267,12 @@ async function runBenchRequest( ttftMs, durationMs, outputTokens, - tokensPerSecond: computeTokensPerSecond(outputTokens, durationMs, ttftMs, deltaChunkCount), + // TPS over the TOTAL request duration, deliberately not the post-TTFT + // decode window: reasoning models can spend seconds generating hidden + // thinking tokens (counted in usage.output) before the first visible + // byte, so "duration - TTFT" inflates TPS several-fold on providers + // that buffer or hide reasoning (e.g. google vs google-vertex). + tokensPerSecond: durationMs > 0 ? (outputTokens * 1000) / durationMs : 0, }; } catch (error) { return { ok: false, error: getErrorMessage(error) }; diff --git a/packages/coding-agent/src/cli/flag-tables.ts b/packages/coding-agent/src/cli/flag-tables.ts index e8c8c9570..6d26ce7d4 100644 --- a/packages/coding-agent/src/cli/flag-tables.ts +++ b/packages/coding-agent/src/cli/flag-tables.ts @@ -121,6 +121,12 @@ export const STRING_SETTERS: Record = { "--plan": (result, value) => { result.plan = value; }, + "--downshift-into": (result, value) => { + result.downshiftInto = value; + }, + "--plan-yolo-into": (result, value) => { + result.planYoloInto = value; + }, "--max-time": (result, value, deps) => { const seconds = Number(value); if (Number.isFinite(seconds) && seconds > 0) { @@ -275,6 +281,9 @@ export const VALUELESS_FLAGS: ReadonlySet = new Set([ "--no-pty", "--hide-thinking", "--advisor", + "--downshift", + "--no-downshift", + "--plan-yolo", "--print", "--print-thoughts", "--no-extensions", diff --git a/packages/coding-agent/src/cli/gallery-fixtures/shell.ts b/packages/coding-agent/src/cli/gallery-fixtures/shell.ts index 2dc810bef..d734a7da3 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/shell.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/shell.ts @@ -1,4 +1,4 @@ -/** Gallery fixtures for the shell tools (bash, eval). */ +/** Gallery fixtures for the shell tools (bash, eval, launch). */ import type { GalleryFixture } from "./types"; export const shellFixtures: Record = { @@ -56,6 +56,78 @@ export const shellFixtures: Record = { }, }, + launch: { + label: "Launch", + streamingArgs: { op: "start", name: "web" }, + args: { + op: "start", + name: "web", + application: "bun", + args: ["run", "dev"], + ready: { log: "Local:.*http", port: 5173, timeout: 30 }, + }, + result: { + content: [ + { + type: "text", + text: "Started web: ready pid=51234 uptime=1.2s restarts=0\nReady: Local: http://localhost:5173", + }, + ], + details: { + op: "start", + daemon: { + name: "web", + id: "d-1", + state: "ready", + pid: 51234, + createdAt: 0, + startedAt: Date.now() - 1_200, + readyAt: Date.now(), + restartCount: 0, + outputBytes: 2048, + readyMatch: "Local: http://localhost:5173/", + persist: false, + detached: false, + }, + timedOut: false, + }, + }, + errorResult: { + content: [{ type: "text", text: "start requires application" }], + isError: true, + details: { op: "start" }, + }, + }, + + launch_logs: { + label: "Launch", + renderer: "launch", + args: { op: "logs", name: "web", lines: 100, follow: true, cursor: 1842, timeout: 30 }, + result: { + content: [ + { + type: "text", + text: [ + "$ bun run dev", + " VITE v6.0.3 ready in 312 ms", + "", + " ➜ Local: http://localhost:5173/", + " ➜ Network: use --host to expose", + "12:04:11 [vite] hmr update /src/App.tsx", + "12:04:15 [vite] hmr update /src/components/Chart.tsx", + "[web: running; cursor=2210]", + ].join("\n"), + }, + ], + details: { op: "logs", cursor: 2210, timedOut: false, state: "running" }, + }, + errorResult: { + content: [{ type: "text", text: "No daemon named web" }], + isError: true, + details: { op: "logs" }, + }, + }, + eval: { label: "Eval", streamingArgs: { diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 8dfe9ca90..c32ab65ec 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -23,6 +23,7 @@ import { discoverAuthStorage } from "../sdk"; const BAR_WIDTH = 28; export interface UsageCommandArgs { + action?: string; json?: boolean; provider?: string; redact?: boolean; @@ -755,6 +756,16 @@ function redactReportForJson( export async function runUsageCommand(cmd: UsageCommandArgs): Promise { const authStorage = await discoverAuthStorage(); try { + if (cmd.action === "invalidate") { + const provider = cmd.provider?.toLowerCase(); + await authStorage.invalidateUsageCache(provider); + if (provider) { + process.stdout.write(`Invalidated cached usage reports for provider "${provider}".\n`); + } else { + process.stdout.write("Invalidated cached usage reports for all providers.\n"); + } + return; + } if (cmd.history) { const days = cmd.days !== undefined && Number.isFinite(cmd.days) && cmd.days > 0 ? cmd.days : 7; const nowMs = Date.now(); diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 5c559d6ea..915d56971 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -34,6 +34,23 @@ export default class Index extends Command { plan: Flags.string({ description: "Plan model for architectural planning (or PI_PLAN_MODEL env)", }), + downshift: Flags.boolean({ + description: + "Switch from the active model to a fast/cheap model at the first edit/write after the plan's todo list exists (default off; see downshift.enabled)", + }), + "no-downshift": Flags.boolean({ + description: "Disable downshift even if downshift.enabled is set", + }), + "downshift-into": Flags.string({ + description: 'Target model for downshift (default the "smol" role)', + }), + "plan-yolo": Flags.boolean({ + description: + "Force read-only plan mode at start, auto-approve the plan on the model's first resolve call, then switch to --plan-yolo-into to implement it", + }), + "plan-yolo-into": Flags.string({ + description: 'Target model for plan-yolo execution (default the "smol" role)', + }), provider: Flags.string({ description: "Provider to use (legacy; prefer --model)", }), diff --git a/packages/coding-agent/src/commands/usage.ts b/packages/coding-agent/src/commands/usage.ts index 06d856a4b..bfaf3a3f3 100644 --- a/packages/coding-agent/src/commands/usage.ts +++ b/packages/coding-agent/src/commands/usage.ts @@ -1,12 +1,20 @@ /** * Show provider usage limits for every authenticated account. */ -import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runUsageCommand } from "../cli/usage-cli"; export default class Usage extends Command { static description = "Show provider usage limits for every authenticated account"; + static args = { + action: Args.string({ + description: "Optional subcommand to execute", + required: false, + options: ["invalidate"], + }), + }; + static flags = { json: Flags.boolean({ char: "j", description: "Output usage reports as JSON", default: false }), provider: Flags.string({ char: "p", description: "Only show usage for this provider id (e.g. anthropic)" }), @@ -28,11 +36,14 @@ export default class Usage extends Command { "# Redact account identifiers for screenshots\n omp usage --redact", "# Machine-readable output\n omp usage --json", "# Usage-limit trend over the last 30 days\n omp usage --history --days 30", + "# Invalidate cached usage reports for all providers\n omp usage invalidate", + "# Invalidate cached usage reports for a specific provider\n omp usage invalidate --provider anthropic", ]; async run(): Promise { - const { flags } = await this.parse(Usage); + const { args, flags } = await this.parse(Usage); await runUsageCommand({ + action: args.action, json: flags.json, provider: flags.provider, redact: flags.redact, diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c314ef56a..5888b7ce0 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -117,7 +117,7 @@ export const TAB_METADATA: Record = { appearance: ["Theme", "Status Line", "Display", "Images"], - model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor", "Vision"], + model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor", "Downshift", "Vision"], interaction: [ "Input", "Approvals", @@ -331,6 +331,25 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ tool: "write", message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.", }, + { + pattern: "^\\s*nohup\\s+|(? var { a, b } = obj; * class Foo extends Bar {} -> var Foo = class extends Bar {}; * - * When the source must run inside the async wrapper, demoted `var`s would normally become - * function-scoped. In that mode we publish each top-level binding back to the wrapper's - * lexical `this`, which is the worker global object. + * When the source must run inside the async wrapper (top-level `await`), demoted `var`s — + * and the user's own top-level `var` and `function` declarations — would be scoped to the + * wrapper function and die with the cell. In that mode we publish every top-level binding + * back to the wrapper's lexical `this`, which is the worker global object. * - * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're + * Nested declarations (inside functions, blocks, classes) are left alone — they're * scoped to their enclosing function/block regardless of `var` vs `let`/`const`. */ async function demoteTopLevelLexicals(code: string, options: { publishGlobals?: boolean } = {}): Promise { - if (!/\b(?:const|let|class)\b/.test(code)) return code; + const publishGlobals = options.publishGlobals === true; + const fastPath = publishGlobals ? /\b(?:const|let|class|var|function)\b/ : /\b(?:const|let|class)\b/; + if (!fastPath.test(code)) return code; const ast = await parseProgram(code); if (!ast) { return code; } - const targets: BabelLexicalDecl[] = []; + const targets: Array<{ node: BabelPublishableDecl; demote: boolean }> = []; for (const node of ast.program.body) { if (node.type === "VariableDeclaration") { const decl = node as unknown as BabelVariableDeclaration; - if (decl.kind === "const" || decl.kind === "let") targets.push(decl); + if (decl.kind === "const" || decl.kind === "let") targets.push({ node: decl, demote: true }); + else if (publishGlobals) targets.push({ node: decl, demote: false }); } else if (node.type === "ClassDeclaration") { const decl = node as unknown as BabelClassDeclaration; - if (decl.id) targets.push(decl); + if (decl.id) targets.push({ node: decl, demote: true }); + } else if (publishGlobals && node.type === "FunctionDeclaration") { + const decl = node as unknown as BabelFunctionDeclaration; + if (decl.id) targets.push({ node: decl, demote: false }); } } if (targets.length === 0) return code; - targets.sort((a, b) => b.start - a.start); + targets.sort((a, b) => b.node.start - a.node.start); let result = code; - for (const node of targets) { + for (const { node, demote } of targets) { const segment = result.slice(node.start, node.end); - const bindingNames = options.publishGlobals ? getLexicalBindingNames(node) : []; + const bindingNames = publishGlobals ? getLexicalBindingNames(node) : []; let replacement: string; - if (node.type === "VariableDeclaration") { + if (!demote) { + replacement = segment; + } else if (node.type === "VariableDeclaration") { replacement = `var${segment.slice(node.kind.length)}`; } else { const id = node.id; diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index 27b885c40..2ab01d757 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -457,9 +457,6 @@ export class TtsrManager { strictness: AstMatchStrictness.Smart, limit: 1, }); - if (result.parseErrors && result.parseErrors.length > 0) { - logger.debug("TTSR ast match reported parse errors", { parseErrors: result.parseErrors }); - } return result.totalMatches > 0; } catch (error) { logger.warn("TTSR ast match failed, treating as no match", { diff --git a/packages/coding-agent/src/internal-urls/docs-index.ts b/packages/coding-agent/src/internal-urls/docs-index.ts index 6674ded3c..6fcca6fc0 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.ts @@ -1,8 +1,8 @@ /** * Harness documentation index for the `omp://` protocol. * - * Compiled binaries and the prepacked npm bundle inline a compressed index from - * `docs-index.generated.txt` (populated by `gen:docs` at build time). The format is two lines: + * Compiled binaries and the prepacked npm bundle inline a compressed index of the + * docs (injected via `process.env.PI_DOCS_EMBED` at build time). The format is two lines: * 1. a plain JSON array of the sorted doc file names, and * 2. a base64 gzip blob of the index-aligned doc bodies (`string[]`). * Listing/completion (`getDocFilenames`) parses only the small first line and @@ -83,8 +83,8 @@ function getIndex(): DocsIndex { const decoded = decodeDocsIndex(docsEmbed); if (decoded === null) { throw new Error( - "Malformed embedded docs index (docs-index.generated.txt): non-empty payload without a newline separator. " + - "Rebuild with `bun run gen:docs`.", + "Malformed embedded docs index: non-empty payload without a newline separator. " + + "Rebuild the binary or bundle.", ); } index = decoded; diff --git a/packages/coding-agent/src/launch/broker.ts b/packages/coding-agent/src/launch/broker.ts new file mode 100644 index 000000000..c2bc6ef5c --- /dev/null +++ b/packages/coding-agent/src/launch/broker.ts @@ -0,0 +1,1000 @@ +import * as fs from "node:fs/promises"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Process, type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives"; +import { isEexist, isEnoent, logger, postmortem, sanitizeText } from "@oh-my-pi/pi-utils"; +import { truncateHead, truncateTail } from "../session/streaming-output"; +import { workerEnvFromParent } from "../subprocess/worker-client"; +import { daemonBrokerEndpoint } from "./paths"; +import { hasLiveDaemonProjectPresence } from "./presence"; +import { + DAEMON_IDLE_GRACE_ENV, + DAEMON_PROJECT_DIR_ENV, + DAEMON_RUNTIME_DIR_ENV, + type DaemonOperation, + type DaemonReadySpec, + type DaemonRpcResult, + type DaemonSignal, + type DaemonSnapshot, + type DaemonSpec, + parseDaemonSnapshot, + parseDaemonSpec, + parseDaemonWireRequest, +} from "./protocol"; + +const DEFAULT_IDLE_GRACE_MS = 3_000; +const MAX_REQUEST_BYTES = 1024 * 1024; +const MAX_LOG_BYTES = 25 * 1024 * 1024; +const LOG_READ_BYTES = 2 * 1024 * 1024; +const READINESS_BUFFER_CHARS = 64 * 1024; +const RESTART_MAX_DELAY_MS = 30_000; +const TOKEN_FILE = "broker.token"; +const PID_FILE = "broker.pid"; +const META_FILE = "meta.json"; +const LOG_FILE = "output.log"; +const PREVIOUS_LOG_FILE = "output.previous.log"; + +const SIGNAL_NUMBER: Record = { + SIGINT: os.constants.signals.SIGINT, + SIGTERM: os.constants.signals.SIGTERM, + SIGHUP: os.constants.signals.SIGHUP, + SIGQUIT: os.constants.signals.SIGQUIT, + SIGKILL: os.constants.signals.SIGKILL, +}; + +interface ManagedProcess { + pid: number; + exited: Promise; + unref(): void; +} + +interface ManagedDaemon { + spec: DaemonSpec; + snapshot: DaemonSnapshot; + dir: string; + log?: DaemonLog; + process?: ManagedProcess; + input?: Bun.FileSink; + pty?: PtySession; + generation: number; + stopRequested: boolean; + logReady: boolean; + portReady: boolean; + readinessBuffer: string; + outputOffset: number; + readyPattern?: RegExp; + restartTimer?: NodeJS.Timeout; + consecutiveFailures: number; + persistQueue: Promise; +} + +interface BrokerLease { + path: string; + instanceId: string; +} + +function quoteShellArg(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} +function quoteCmdArg(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +function terminalState(state: DaemonSnapshot["state"]): boolean { + return state === "exited" || state === "failed"; +} + +/** Mirror per-condition readiness progress into the snapshot so clients can see which condition is unmet. */ +function syncReadyPending(record: ManagedDaemon): void { + if (record.snapshot.state !== "starting") { + record.snapshot.readyPending = undefined; + return; + } + const pending: ("log" | "port")[] = []; + if (!record.logReady) pending.push("log"); + if (!record.portReady) pending.push("port"); + record.snapshot.readyPending = pending.length > 0 ? pending : undefined; +} + +async function fileTextSlice(filePath: string, head: boolean): Promise { + try { + const stat = await fs.stat(filePath); + const file = Bun.file(filePath); + if (stat.size <= LOG_READ_BYTES) return await file.text(); + return head + ? await file.slice(0, LOG_READ_BYTES).text() + : await file.slice(Math.max(0, stat.size - LOG_READ_BYTES)).text(); + } catch (error) { + if (isEnoent(error)) return ""; + throw error; + } +} + +class DaemonLog { + readonly #path: string; + readonly #previousPath: string; + readonly #file: Bun.BunFile; + #writer: Bun.FileSink; + #currentBytes = 0; + #queue: Promise = Promise.resolve(); + #closed = false; + + constructor(logPath: string, previousPath: string, file: Bun.BunFile, writer: Bun.FileSink) { + this.#path = logPath; + this.#previousPath = previousPath; + this.#file = file; + this.#writer = writer; + } + + static async open(dir: string): Promise { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const logPath = path.join(dir, LOG_FILE); + const previousPath = path.join(dir, PREVIOUS_LOG_FILE); + await fs.rm(previousPath, { force: true }); + try { + await fs.rename(logPath, previousPath); + } catch (error) { + if (!isEnoent(error)) throw error; + } + const file = Bun.file(logPath); + return new DaemonLog(logPath, previousPath, file, file.writer()); + } + + append(raw: string): string { + const text = sanitizeText(raw); + if (text.length === 0 || this.#closed) return text; + const bytes = Buffer.byteLength(text, "utf8"); + this.#queue = this.#queue.then(async () => { + if (this.#currentBytes > 0 && this.#currentBytes + bytes > MAX_LOG_BYTES) await this.#rotate(); + this.#writer.write(text); + this.#currentBytes += bytes; + await this.#writer.flush(); + }); + return text; + } + + async read(head: boolean, lines: number, grep?: string): Promise { + await this.#queue; + await this.#writer.flush(); + return DaemonLog.readFiles(this.#path, this.#previousPath, head, lines, grep); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#queue; + await this.#writer.end(); + } + + static async readDir(dir: string, head: boolean, lines: number, grep?: string): Promise { + return DaemonLog.readFiles(path.join(dir, LOG_FILE), path.join(dir, PREVIOUS_LOG_FILE), head, lines, grep); + } + + static async readFiles( + logPath: string, + previousPath: string, + head: boolean, + lines: number, + grep?: string, + ): Promise { + const [previous, current] = await Promise.all([fileTextSlice(previousPath, head), fileTextSlice(logPath, head)]); + let text = sanitizeText(`${previous}${previous && current && !previous.endsWith("\n") ? "\n" : ""}${current}`); + if (grep) { + let pattern: RegExp; + try { + pattern = new RegExp(grep, "u"); + } catch (error) { + throw new Error(`Invalid log regex: ${error instanceof Error ? error.message : String(error)}`); + } + text = text + .split("\n") + .filter(line => pattern.test(line)) + .join("\n"); + } + const options = { maxLines: lines, maxBytes: 256 * 1024 }; + return head ? truncateHead(text, options).content : truncateTail(text, options).content; + } + + async #rotate(): Promise { + await this.#writer.end(); + await fs.rm(this.#previousPath, { force: true }); + await fs.rename(this.#path, this.#previousPath); + this.#writer = this.#file.writer(); + this.#currentBytes = 0; + } +} + +async function acquireBrokerLease(runtimeDir: string): Promise { + const pidPath = path.join(runtimeDir, PID_FILE); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const handle = await fs.open(pidPath, "wx", 0o600); + const instanceId = crypto.randomUUID(); + try { + await handle.writeFile(JSON.stringify({ pid: process.pid, instanceId }), "utf8"); + } finally { + await handle.close(); + } + return { path: pidPath, instanceId }; + } catch (error) { + if (!isEexist(error)) throw error; + try { + const raw: unknown = await Bun.file(pidPath).json(); + if (typeof raw === "object" && raw !== null && "pid" in raw && typeof raw.pid === "number") { + try { + process.kill(raw.pid, 0); + return null; + } catch { + // Stale PID file; the next loop iteration claims it. + } + } + } catch { + // Malformed or partially-written PID files are stale. + } + await fs.rm(pidPath, { force: true }); + } + } + return null; +} + +async function releaseBrokerLease(lease: BrokerLease): Promise { + try { + const raw: unknown = await Bun.file(lease.path).json(); + if (typeof raw === "object" && raw !== null && "instanceId" in raw && raw.instanceId === lease.instanceId) { + await fs.rm(lease.path, { force: true }); + } + } catch (error) { + if (!isEnoent(error)) throw error; + } +} + +function connectPort(host: string, port: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + const socket = net.createConnection({ host, port }); + let settled = false; + const finish = (connected: boolean): void => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(connected); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(250, () => finish(false)); + return promise; +} + +class DaemonBroker { + readonly #projectDir: string; + readonly #runtimeDir: string; + readonly #endpoint: string; + readonly #token: string; + readonly #idleGraceMs: number; + readonly #records = new Map(); + readonly #clients = new Set(); + readonly #finished = Promise.withResolvers(); + readonly #sockets = new Set(); + #server: net.Server | undefined; + #idleTimer: NodeJS.Timeout | undefined; + #shuttingDown = false; + + constructor(projectDir: string, runtimeDir: string, token: string, idleGraceMs: number) { + this.#projectDir = projectDir; + this.#runtimeDir = runtimeDir; + this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir); + this.#token = token; + this.#idleGraceMs = idleGraceMs; + } + + async run(): Promise { + await this.#recoverRecords(); + if (process.platform !== "win32") await fs.rm(this.#endpoint, { force: true }); + const server = net.createServer(socket => this.#accept(socket)); + this.#server = server; + const { promise: listening, resolve, reject } = Promise.withResolvers(); + server.once("listening", resolve); + server.once("error", reject); + server.listen(this.#endpoint); + await listening; + if (process.platform !== "win32") await fs.chmod(this.#endpoint, 0o600); + this.#scheduleIdleShutdown(); + await this.#finished.promise; + } + + async shutdown(): Promise { + if (this.#shuttingDown) return this.#finished.promise; + this.#shuttingDown = true; + clearTimeout(this.#idleTimer); + this.#idleTimer = undefined; + for (const record of this.#records.values()) { + const detached = record.spec.detached && !record.stopRequested && record.snapshot.pid !== undefined; + if (!detached && !terminalState(record.snapshot.state)) await this.#stopRecord(record, 2_000); + clearTimeout(record.restartTimer); + await record.log?.close(); + await record.persistQueue; + } + for (const socket of this.#sockets) socket.destroy(); + this.#sockets.clear(); + this.#clients.clear(); + if (this.#server) { + const { promise, resolve } = Promise.withResolvers(); + this.#server.close(() => resolve()); + await promise; + } + if (process.platform !== "win32") await fs.rm(this.#endpoint, { force: true }); + this.#finished.resolve(); + } + + #accept(socket: net.Socket): void { + this.#sockets.add(socket); + let authenticated = false; + let buffer = ""; + socket.setEncoding("utf8"); + socket.on("data", chunk => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { + socket.destroy(new Error("Daemon broker request exceeds size limit")); + return; + } + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line) continue; + void this.#handleLine(socket, line, () => { + if (authenticated) return; + authenticated = true; + this.#clients.add(socket); + clearTimeout(this.#idleTimer); + this.#idleTimer = undefined; + }); + } + }); + socket.on("error", () => { + // Socket closure performs client accounting. + }); + socket.on("close", () => { + this.#sockets.delete(socket); + if (!authenticated) return; + this.#clients.delete(socket); + this.#scheduleIdleShutdown(); + }); + } + + async #handleLine(socket: net.Socket, line: string, onAuthenticated: () => void): Promise { + let id = "unknown"; + try { + const decoded: unknown = JSON.parse(line); + const request = parseDaemonWireRequest(decoded); + id = request.id; + if (request.token !== this.#token) throw new Error("Daemon broker authentication failed"); + onAuthenticated(); + const result = await this.#dispatch(request.operation); + socket.write(`${JSON.stringify({ id, ok: true, result })}\n`); + if (request.operation.op === "shutdown") setTimeout(() => void this.shutdown(), 10); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + socket.write(`${JSON.stringify({ id, ok: false, error: message })}\n`); + } + } + + async #dispatch(operation: DaemonOperation): Promise { + switch (operation.op) { + case "ping": + return { op: "ping", projectDir: this.#projectDir }; + case "start": + return this.#start(operation.spec, operation.owner); + case "list": { + await Promise.all([...this.#records.values()].map(record => this.#refreshDetached(record))); + return { + op: "list", + daemons: [...this.#records.values()] + .sort((left, right) => left.snapshot.createdAt - right.snapshot.createdAt) + .map(record => record.snapshot), + }; + } + case "logs": + return this.#logs(operation); + case "wait": + return this.#wait(operation); + case "send": + return this.#send(operation); + case "stop": { + const record = this.#record(operation.name); + await this.#stopRecord(record, operation.timeoutMs); + return { op: "stop", daemon: record.snapshot }; + } + case "restart": + return this.#restart(operation.name); + case "describe": { + const record = this.#record(operation.name); + await this.#refreshDetached(record); + return { op: "describe", daemon: record.snapshot, spec: record.spec }; + } + case "shutdown": + return { op: "shutdown" }; + } + } + + async #start(spec: DaemonSpec, owner?: string): Promise { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/.test(spec.name)) { + throw new Error("Daemon name must be 1-48 letters, numbers, dots, underscores, or hyphens"); + } + if (spec.detached && spec.pty) { + throw new Error("A detached daemon cannot allocate a PTY"); + } + const existing = this.#records.get(spec.name); + if (existing) await this.#refreshDetached(existing); + if (existing && !terminalState(existing.snapshot.state)) { + throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`); + } + if (spec.ready?.log) { + try { + new RegExp(spec.ready.log, "u"); + } catch (error) { + throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`); + } + } + const stat = await fs.stat(spec.cwd); + if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`); + const dir = path.join(this.#runtimeDir, "daemons", spec.name); + const now = Date.now(); + const record: ManagedDaemon = { + spec, + snapshot: { + name: spec.name, + id: crypto.randomUUID(), + state: "starting", + createdAt: now, + startedAt: now, + restartCount: 0, + outputBytes: 0, + owner, + persist: spec.persist, + detached: spec.detached, + }, + dir, + log: await DaemonLog.open(dir), + generation: 0, + stopRequested: false, + logReady: !spec.ready?.log, + portReady: spec.ready?.port === undefined, + readinessBuffer: "", + outputOffset: 0, + readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined, + consecutiveFailures: 0, + persistQueue: Promise.resolve(), + }; + syncReadyPending(record); + this.#records.set(spec.name, record); + await this.#launch(record); + let readyTimedOut = false; + if (spec.ready && !terminalState(record.snapshot.state)) { + const ready = await this.#waitUntil(record, () => record.snapshot.state === "ready", spec.ready.timeoutMs); + readyTimedOut = !ready && !terminalState(record.snapshot.state); + } + await record.persistQueue; + return { op: "start", daemon: record.snapshot, readyTimedOut }; + } + + async #launch(record: ManagedDaemon): Promise { + record.generation++; + const generation = record.generation; + record.stopRequested = false; + record.snapshot.state = record.spec.ready ? "starting" : "running"; + record.snapshot.startedAt = Date.now(); + record.snapshot.readyAt = undefined; + record.snapshot.exitedAt = undefined; + record.snapshot.exitCode = undefined; + record.snapshot.exitReason = undefined; + record.snapshot.pid = undefined; + record.snapshot.readyMatch = undefined; + record.logReady = !record.spec.ready?.log; + record.portReady = record.spec.ready?.port === undefined; + syncReadyPending(record); + record.readinessBuffer = ""; + record.outputOffset = 0; + this.#persist(record); + try { + if (record.spec.detached) await this.#launchDetached(record, generation); + else if (record.spec.pty) await this.#launchPty(record, generation); + else this.#launchPipe(record, generation); + if (record.spec.ready?.port !== undefined) void this.#pollPort(record, generation, record.spec.ready); + this.#markReady(record); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + record.log?.append(`Daemon launch failed: ${message}\n`); + await this.#settle(record, generation, undefined, message); + } + } + + async #launchPty(record: ManagedDaemon, generation: number): Promise { + const pidPath = path.join(record.dir, "process.pid"); + await fs.rm(pidPath, { force: true }); + const argv = [record.spec.application, ...record.spec.args]; + const command = + process.platform === "win32" + ? argv.map(quoteCmdArg).join(" ") + : [`printf '%s' "$$" > ${quoteShellArg(pidPath)}`, `exec ${argv.map(quoteShellArg).join(" ")}`].join("; "); + const session = new PtySession(); + record.pty = session; + const shell = process.platform === "win32" ? process.env.COMSPEC : process.env.SHELL; + void session + .start( + { + command, + cwd: record.spec.cwd, + env: workerEnvFromParent({ TERM: "xterm-256color", ...record.spec.env }), + cols: 120, + rows: 40, + shell, + }, + (error, chunk) => { + if (generation !== record.generation) return; + if (error) record.log?.append(`PTY output error: ${error.message}\n`); + if (chunk) this.#onOutput(record, generation, chunk); + }, + ) + .then(result => this.#onPtyExit(record, generation, result)) + .catch(error => + this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)), + ); + + if (process.platform === "win32") return; + const deadline = Date.now() + 5_000; + const pidFile = Bun.file(pidPath); + while (Date.now() < deadline && generation === record.generation) { + try { + const pid = Number.parseInt((await pidFile.text()).trim(), 10); + if (Number.isSafeInteger(pid) && pid > 0) { + record.snapshot.pid = pid; + this.#persist(record); + return; + } + } catch (error) { + if (!isEnoent(error)) throw error; + } + if (terminalState(record.snapshot.state)) return; + await Bun.sleep(20); + } + } + + #launchPipe(record: ManagedDaemon, generation: number): void { + const process = Bun.spawn([record.spec.application, ...record.spec.args], { + cwd: record.spec.cwd, + env: workerEnvFromParent(record.spec.env), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + record.process = process; + record.input = process.stdin; + record.snapshot.pid = process.pid; + this.#persist(record); + const stdout = this.#drain(record, generation, process.stdout); + const stderr = this.#drain(record, generation, process.stderr); + void Promise.all([stdout, stderr, process.exited]) + .then(([, , exitCode]) => this.#settle(record, generation, exitCode)) + .catch(error => + this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)), + ); + } + + async #launchDetached(record: ManagedDaemon, generation: number): Promise { + const logPath = path.join(record.dir, LOG_FILE); + const output = await fs.open(logPath, "a", 0o600); + try { + const process = Bun.spawn([record.spec.application, ...record.spec.args], { + cwd: record.spec.cwd, + env: workerEnvFromParent(record.spec.env), + stdio: ["ignore", output.fd, output.fd], + detached: true, + }); + record.process = process; + record.snapshot.pid = process.pid; + this.#persist(record); + process.unref(); + void process.exited + .then(exitCode => this.#settle(record, generation, exitCode)) + .catch(error => + this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)), + ); + } finally { + await output.close(); + } + } + + async #drain(record: ManagedDaemon, generation: number, stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (generation === record.generation) + this.#onOutput(record, generation, decoder.decode(value, { stream: true })); + } + const tail = decoder.decode(); + if (tail && generation === record.generation) this.#onOutput(record, generation, tail); + } finally { + reader.releaseLock(); + } + } + + #onOutput(record: ManagedDaemon, generation: number, raw: string): void { + if (generation !== record.generation) return; + const text = record.log?.append(raw) ?? sanitizeText(raw); + record.snapshot.outputBytes += Buffer.byteLength(text, "utf8"); + this.#trackOutput(record, generation, text); + } + + async #readDetachedOutput(record: ManagedDaemon, generation: number): Promise { + if (!record.spec.detached || generation !== record.generation) return; + const logPath = path.join(record.dir, LOG_FILE); + let size: number; + try { + size = (await fs.stat(logPath)).size; + } catch (error) { + if (isEnoent(error)) return; + throw error; + } + if (size < record.outputOffset) record.outputOffset = 0; + if (size === record.outputOffset) return; + const file = Bun.file(logPath); + const raw = await file.slice(record.outputOffset, size).text(); + if (generation !== record.generation) return; + record.outputOffset = size; + record.snapshot.outputBytes = size; + this.#trackOutput(record, generation, sanitizeText(raw)); + } + + #trackOutput(record: ManagedDaemon, generation: number, text: string): void { + if (generation !== record.generation) return; + record.readinessBuffer = (record.readinessBuffer + text).slice(-READINESS_BUFFER_CHARS); + if (!record.logReady && record.readyPattern) { + const match = record.readyPattern.exec(record.readinessBuffer); + if (match) { + record.logReady = true; + record.snapshot.readyMatch = match[0].slice(0, 500); + syncReadyPending(record); + } + } + this.#markReady(record); + } + + async #refreshDetached(record: ManagedDaemon): Promise { + if (!record.spec.detached || terminalState(record.snapshot.state)) return; + const generation = record.generation; + await this.#readDetachedOutput(record, generation); + if (generation !== record.generation || record.process) return; + const processRef = record.snapshot.pid === undefined ? null : Process.fromPid(record.snapshot.pid); + if (processRef?.status() === "running") return; + await this.#settle(record, generation); + } + + async #pollPort(record: ManagedDaemon, generation: number, ready: DaemonReadySpec): Promise { + const host = ready.host ?? "127.0.0.1"; + const port = ready.port; + if (port === undefined) return; + while (generation === record.generation && !terminalState(record.snapshot.state)) { + if (await connectPort(host, port)) { + record.portReady = true; + syncReadyPending(record); + this.#markReady(record); + return; + } + await Bun.sleep(100); + } + } + + #markReady(record: ManagedDaemon): void { + if (!record.spec.ready || record.snapshot.state !== "starting") return; + if (!record.logReady || !record.portReady) return; + record.snapshot.state = "ready"; + record.snapshot.readyAt = Date.now(); + this.#persist(record); + } + + async #onPtyExit(record: ManagedDaemon, generation: number, result: PtyRunResult): Promise { + return this.#settle(record, generation, result.exitCode, result.timedOut ? "timed out" : undefined); + } + + async #settle(record: ManagedDaemon, generation: number, exitCode?: number, error?: string): Promise { + if (generation !== record.generation || terminalState(record.snapshot.state)) return; + await this.#readDetachedOutput(record, generation); + record.process = undefined; + record.input = undefined; + record.pty = undefined; + record.snapshot.pid = undefined; + record.snapshot.exitedAt = Date.now(); + record.snapshot.exitCode = exitCode; + record.snapshot.exitReason = error; + record.snapshot.readyPending = undefined; + const failed = error !== undefined || (exitCode !== undefined && exitCode !== 0); + const shouldRestart = + !record.stopRequested && + (record.spec.restart === "always" || (record.spec.restart === "on-failure" && failed)); + if (shouldRestart && !this.#shuttingDown) { + const uptime = Date.now() - record.snapshot.startedAt; + record.consecutiveFailures = uptime >= 30_000 ? 0 : record.consecutiveFailures + 1; + record.snapshot.restartCount++; + record.snapshot.state = "restarting"; + const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS); + record.log?.append( + `\n[daemon exited${exitCode === undefined ? "" : ` with code ${exitCode}`}; restarting in ${delay}ms]\n`, + ); + this.#persist(record); + record.restartTimer = setTimeout(() => { + record.restartTimer = undefined; + void this.#launch(record); + }, delay); + return; + } + record.snapshot.state = failed && !record.stopRequested ? "failed" : "exited"; + this.#persist(record); + await record.log?.close(); + record.log = undefined; + } + + async #logs(operation: Extract): Promise { + const record = this.#record(operation.name); + await this.#refreshDetached(record); + const cursor = operation.cursor ?? record.snapshot.outputBytes; + let timedOut = false; + if (operation.follow && record.snapshot.outputBytes <= cursor && !terminalState(record.snapshot.state)) { + const changed = await this.#waitUntil( + record, + () => record.snapshot.outputBytes > cursor || terminalState(record.snapshot.state), + operation.timeoutMs, + ); + timedOut = !changed; + } + const lines = Math.max(1, Math.min(1_000, Math.floor(operation.lines))); + const text = record.log + ? await record.log.read(operation.head, lines, operation.grep) + : await DaemonLog.readDir(record.dir, operation.head, lines, operation.grep); + return { + op: "logs", + name: record.snapshot.name, + text, + cursor: record.snapshot.outputBytes, + timedOut, + state: record.snapshot.state, + }; + } + + async #wait(operation: Extract): Promise { + const record = this.#record(operation.name); + await this.#refreshDetached(record); + let matched: string | undefined; + let pattern: RegExp | undefined; + if (operation.pattern) { + try { + pattern = new RegExp(operation.pattern, "u"); + } catch (error) { + throw new Error(`Invalid wait regex: ${error instanceof Error ? error.message : String(error)}`); + } + } + const condition = (): boolean => { + if (pattern) { + const match = pattern.exec(record.readinessBuffer); + if (!match) return false; + matched = match[0].slice(0, 500); + return true; + } + if (operation.for === "exit") return terminalState(record.snapshot.state); + return record.snapshot.state === "ready" || (record.snapshot.state === "running" && !record.spec.ready); + }; + const reached = condition() || (await this.#waitUntil(record, condition, operation.timeoutMs)); + return { op: "wait", daemon: record.snapshot, matched, timedOut: !reached }; + } + + async #send(operation: Extract): Promise { + const record = this.#record(operation.name); + await this.#refreshDetached(record); + if (terminalState(record.snapshot.state) || record.snapshot.state === "stopping") { + throw new Error(`Daemon ${operation.name} is ${record.snapshot.state}`); + } + if (operation.data === undefined && operation.signal === undefined) { + throw new Error("send requires data or signal"); + } + if (operation.data !== undefined) { + if (record.pty) record.pty.write(operation.data); + else if (record.input) { + record.input.write(operation.data); + await record.input.flush(); + } else throw new Error(`Daemon ${operation.name} stdin is unavailable`); + } + if (operation.signal) { + if (process.platform === "win32" && record.pty) { + if (operation.signal === "SIGINT") record.pty.write("\u0003"); + else record.pty.kill(); + } else { + const processRef = record.snapshot.pid === undefined ? null : Process.fromPid(record.snapshot.pid); + if (!processRef) throw new Error(`Daemon ${operation.name} process is unavailable`); + processRef.killTree(SIGNAL_NUMBER[operation.signal]); + } + } + return { op: "send", daemon: record.snapshot }; + } + + async #stopRecord(record: ManagedDaemon, timeoutMs: number): Promise { + await this.#refreshDetached(record); + if (terminalState(record.snapshot.state)) return; + record.stopRequested = true; + if (record.restartTimer) { + clearTimeout(record.restartTimer); + record.restartTimer = undefined; + record.snapshot.state = "exited"; + record.snapshot.exitedAt = Date.now(); + this.#persist(record); + await record.log?.close(); + record.log = undefined; + return; + } + record.snapshot.state = "stopping"; + this.#persist(record); + const processRef = record.snapshot.pid === undefined ? null : Process.fromPid(record.snapshot.pid); + if (processRef) await processRef.terminate({ group: true, gracefulMs: timeoutMs, timeoutMs: timeoutMs + 1_000 }); + else record.pty?.kill(); + const settled = await this.#waitUntil(record, () => terminalState(record.snapshot.state), timeoutMs + 1_000); + if (!settled && record.pty) record.pty.kill(); + } + + async #restart(name: string): Promise { + const record = this.#record(name); + await this.#stopRecord(record, 2_000); + await record.log?.close(); + record.log = await DaemonLog.open(record.dir); + record.stopRequested = false; + await this.#launch(record); + await record.persistQueue; + return { op: "restart", daemon: record.snapshot }; + } + + async #waitUntil(record: ManagedDaemon, condition: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + Math.max(0, timeoutMs); + while (Date.now() < deadline) { + await this.#refreshDetached(record); + if (condition()) return true; + if (this.#shuttingDown && terminalState(record.snapshot.state)) return condition(); + await Bun.sleep(50); + } + await this.#refreshDetached(record); + return condition(); + } + + #record(name: string): ManagedDaemon { + const record = this.#records.get(name); + if (record) return record; + const names = [...this.#records.keys()]; + throw new Error(`Unknown daemon ${name}${names.length ? `. Available: ${names.join(", ")}` : ""}`); + } + + #persist(record: ManagedDaemon): void { + const metaPath = path.join(record.dir, META_FILE); + const tempPath = `${metaPath}.${process.pid}.tmp`; + record.persistQueue = record.persistQueue + .then(async () => { + await Bun.write(tempPath, JSON.stringify({ daemon: record.snapshot, spec: record.spec })); + await fs.rename(tempPath, metaPath); + }) + .catch(error => { + logger.warn("Failed to persist daemon metadata", { + name: record.snapshot.name, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + async #recoverRecords(): Promise { + const root = path.join(this.#runtimeDir, "daemons"); + const entries = await fs.readdir(root, { withFileTypes: true }).catch(error => { + if (isEnoent(error)) return []; + throw error; + }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dir = path.join(root, entry.name); + try { + const decoded: unknown = await Bun.file(path.join(dir, META_FILE)).json(); + if (typeof decoded !== "object" || decoded === null || !("daemon" in decoded) || !("spec" in decoded)) { + continue; + } + const snapshot = parseDaemonSnapshot(decoded.daemon); + const spec = parseDaemonSpec(decoded.spec); + const processRef = snapshot.pid === undefined ? null : Process.fromPid(snapshot.pid); + const detached = + spec.detached && + !terminalState(snapshot.state) && + snapshot.state !== "stopping" && + processRef?.status() === "running"; + if (!detached) { + if (processRef) await processRef.terminate({ group: true, gracefulMs: 500, timeoutMs: 2_000 }); + snapshot.pid = undefined; + snapshot.state = "exited"; + snapshot.exitedAt = Date.now(); + snapshot.exitReason = "previous broker exited"; + } else if (snapshot.state === "restarting") { + snapshot.state = spec.ready ? "starting" : "running"; + } + snapshot.persist = spec.persist; + snapshot.detached = spec.detached; + const record: ManagedDaemon = { + spec, + snapshot, + dir, + generation: 0, + stopRequested: !detached || snapshot.state === "stopping", + logReady: detached && (!spec.ready?.log || snapshot.state === "ready"), + portReady: detached && (spec.ready?.port === undefined || snapshot.state === "ready"), + readinessBuffer: "", + outputOffset: detached ? snapshot.outputBytes : 0, + readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined, + consecutiveFailures: 0, + persistQueue: Promise.resolve(), + }; + syncReadyPending(record); + this.#records.set(snapshot.name, record); + if (detached && spec.ready?.port !== undefined && snapshot.state !== "ready") { + void this.#pollPort(record, record.generation, spec.ready); + } + this.#persist(record); + } catch (error) { + logger.warn("Failed to recover daemon record", { + name: entry.name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + #scheduleIdleShutdown(): void { + if (this.#shuttingDown || this.#clients.size > 0) return; + clearTimeout(this.#idleTimer); + this.#idleTimer = setTimeout(() => { + this.#idleTimer = undefined; + void (async () => { + const livePersistent = [...this.#records.values()].some( + record => record.spec.persist && !terminalState(record.snapshot.state), + ); + if (this.#clients.size > 0 || livePersistent) return; + if (await hasLiveDaemonProjectPresence(this.#runtimeDir)) { + this.#scheduleIdleShutdown(); + return; + } + if (this.#clients.size === 0) await this.shutdown(); + })(); + }, this.#idleGraceMs); + } +} + +/** Start the detached per-project daemon broker selected by the CLI worker host. */ +export async function startDaemonBrokerFromEnvironment(): Promise { + const projectDir = process.env[DAEMON_PROJECT_DIR_ENV]; + const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV]; + if (!projectDir || !runtimeDir) throw new Error("Daemon broker environment is incomplete"); + delete process.env[DAEMON_PROJECT_DIR_ENV]; + delete process.env[DAEMON_RUNTIME_DIR_ENV]; + const rawGrace = process.env[DAEMON_IDLE_GRACE_ENV]; + delete process.env[DAEMON_IDLE_GRACE_ENV]; + const parsedGrace = rawGrace === undefined ? DEFAULT_IDLE_GRACE_MS : Number.parseInt(rawGrace, 10); + const idleGraceMs = Number.isFinite(parsedGrace) && parsedGrace >= 0 ? parsedGrace : DEFAULT_IDLE_GRACE_MS; + await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); + const lease = await acquireBrokerLease(runtimeDir); + if (!lease) return; + process.title = "omp daemon broker"; + const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim(); + if (!token) throw new Error("Daemon broker token is empty"); + const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs); + const cancelCleanup = postmortem.register("daemon-broker", () => broker.shutdown()); + try { + await broker.run(); + } finally { + cancelCleanup(); + await releaseBrokerLease(lease); + } +} diff --git a/packages/coding-agent/src/launch/client.ts b/packages/coding-agent/src/launch/client.ts new file mode 100644 index 000000000..0e06bf06c --- /dev/null +++ b/packages/coding-agent/src/launch/client.ts @@ -0,0 +1,344 @@ +import * as fs from "node:fs/promises"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { isEexist, isEnoent, postmortem } from "@oh-my-pi/pi-utils"; +import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client"; +import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths"; +import { + DAEMON_BROKER_WORKER_ARG, + DAEMON_IDLE_GRACE_ENV, + DAEMON_PROJECT_DIR_ENV, + DAEMON_RUNTIME_DIR_ENV, + type DaemonOperation, + type DaemonRpcResult, + type DaemonWireResponse, + parseDaemonRpcResult, + parseDaemonWireResponse, +} from "./protocol"; + +const CONNECT_TIMEOUT_MS = 10_000; +const CONNECT_RETRY_MS = 50; +const TOKEN_FILE = "broker.token"; + +interface PendingRequest { + operation: DaemonOperation; + resolve: (result: DaemonRpcResult) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + removeAbort?: () => void; +} + +/** Broker location and lifecycle overrides used by smoke tests and isolated consumers. */ +export interface DaemonBrokerClientOptions { + /** Runtime directory override; defaults to the project-scoped config path. */ + runtimeDir?: string; + /** Last-client shutdown grace override in milliseconds. */ + idleGraceMs?: number; +} + +/** Persistent per-process connection to one project's daemon broker. */ +export interface DaemonBrokerClient { + readonly projectDir: string; + request(operation: DaemonOperation, signal?: AbortSignal): Promise; + close(): void; +} + +async function canonicalProjectDir(projectDir: string): Promise { + const resolved = path.resolve(projectDir); + try { + return await fs.realpath(resolved); + } catch (error) { + if (isEnoent(error)) return resolved; + throw error; + } +} + +async function readOrCreateToken(runtimeDir: string): Promise { + await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); + const tokenPath = path.join(runtimeDir, TOKEN_FILE); + const tokenFile = Bun.file(tokenPath); + for (let attempt = 0; attempt < 100; attempt++) { + try { + const token = (await tokenFile.text()).trim(); + if (token.length > 0) return token; + } catch (error) { + if (!isEnoent(error)) throw error; + } + + try { + const handle = await fs.open(tokenPath, "wx", 0o600); + try { + const token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); + await handle.writeFile(token, "utf8"); + return token; + } finally { + await handle.close(); + } + } catch (error) { + if (!isEexist(error)) throw error; + } + await Bun.sleep(10); + } + throw new Error(`Timed out initializing daemon broker token in ${runtimeDir}`); +} + +function requestTimeoutMs(operation: DaemonOperation): number { + switch (operation.op) { + case "start": + return (operation.spec.ready?.timeoutMs ?? CONNECT_TIMEOUT_MS) + 5_000; + case "wait": + case "logs": + case "stop": + return operation.timeoutMs + 5_000; + default: + return 30_000; + } +} + +function openSocket(endpoint: string, timeoutMs: number): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = net.createConnection({ path: endpoint }); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`Timed out connecting to daemon broker at ${endpoint}`)); + }, timeoutMs); + const cleanup = (): void => { + clearTimeout(timer); + socket.off("connect", onConnect); + socket.off("error", onError); + }; + const onConnect = (): void => { + cleanup(); + resolve(socket); + }; + const onError = (error: Error): void => { + cleanup(); + socket.destroy(); + reject(error); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + return promise; +} + +class SocketDaemonClient implements DaemonBrokerClient { + readonly projectDir: string; + readonly #runtimeDir: string; + readonly #endpoint: string; + readonly #token: string; + readonly #idleGraceMs: number | undefined; + readonly #pending = new Map(); + #socket: net.Socket | undefined; + #connectPromise: Promise | undefined; + #buffer = ""; + #closed = false; + + constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) { + this.projectDir = projectDir; + this.#runtimeDir = runtimeDir; + this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir); + this.#token = token; + this.#idleGraceMs = options.idleGraceMs; + } + + async request(operation: DaemonOperation, signal?: AbortSignal): Promise { + if (this.#closed) throw new Error("Daemon broker client is closed"); + if (signal?.aborted) throw new Error("Daemon broker request aborted"); + await this.#connect(); + const socket = this.#socket; + if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable"); + + const id = crypto.randomUUID(); + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + const pending = this.#pending.get(id); + if (!pending) return; + this.#pending.delete(id); + pending.removeAbort?.(); + reject(new Error(`Daemon ${operation.op} request timed out`)); + }, requestTimeoutMs(operation)); + const pending: PendingRequest = { operation, resolve, reject, timer }; + if (signal) { + const abort = (): void => { + if (!this.#pending.delete(id)) return; + clearTimeout(timer); + reject(new Error("Daemon broker request aborted")); + }; + signal.addEventListener("abort", abort, { once: true }); + pending.removeAbort = () => signal.removeEventListener("abort", abort); + } + this.#pending.set(id, pending); + socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`); + return promise; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#socket?.destroy(); + this.#socket = undefined; + this.#rejectPending(new Error("Daemon broker client closed")); + } + + async #connect(): Promise { + if (this.#socket && !this.#socket.destroyed) return; + if (this.#connectPromise) return this.#connectPromise; + this.#connectPromise = this.#connectOnce(); + try { + await this.#connectPromise; + } finally { + this.#connectPromise = undefined; + } + } + + async #connectOnce(): Promise { + try { + this.#bindSocket(await openSocket(this.#endpoint, 250)); + return; + } catch { + // No live broker. Multiple clients may race to spawn; the broker's PID + // lease selects one winner before any candidate touches the socket. + } + this.#spawnBroker(); + const deadline = Date.now() + CONNECT_TIMEOUT_MS; + let lastError: Error | undefined; + while (Date.now() < deadline) { + try { + this.#bindSocket(await openSocket(this.#endpoint, 250)); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await Bun.sleep(CONNECT_RETRY_MS); + } + } + throw new Error(`Failed to start daemon broker: ${lastError?.message ?? "socket unavailable"}`); + } + + #spawnBroker(): void { + const spawn = resolveWorkerSpawnCmd(DAEMON_BROKER_WORKER_ARG); + const overlay: Record = { + [DAEMON_PROJECT_DIR_ENV]: this.projectDir, + [DAEMON_RUNTIME_DIR_ENV]: this.#runtimeDir, + }; + if (this.#idleGraceMs !== undefined) overlay[DAEMON_IDLE_GRACE_ENV] = String(this.#idleGraceMs); + const child = Bun.spawn(spawn.cmd, { + cwd: spawn.cwd, + env: workerEnvFromParent(overlay), + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + detached: true, + }); + child.unref(); + } + + #bindSocket(socket: net.Socket): void { + this.#socket = socket; + this.#buffer = ""; + socket.setEncoding("utf8"); + socket.on("data", chunk => this.#onData(chunk)); + socket.on("error", () => { + // The close handler rejects pending requests with one stable error. + }); + socket.on("close", () => { + if (this.#socket === socket) this.#socket = undefined; + this.#rejectPending(new Error("Daemon broker connection closed")); + }); + } + + #onData(chunk: string | Buffer): void { + this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + for (;;) { + const newline = this.#buffer.indexOf("\n"); + if (newline < 0) return; + const line = this.#buffer.slice(0, newline); + this.#buffer = this.#buffer.slice(newline + 1); + if (line.length === 0) continue; + let response: DaemonWireResponse; + try { + const decoded: unknown = JSON.parse(line); + response = parseDaemonWireResponse(decoded); + } catch (error) { + this.#rejectPending(error instanceof Error ? error : new Error(String(error))); + continue; + } + const pending = this.#pending.get(response.id); + if (!pending) continue; + this.#pending.delete(response.id); + clearTimeout(pending.timer); + pending.removeAbort?.(); + if (!response.ok) { + pending.reject(new Error(response.error)); + continue; + } + try { + pending.resolve(parseDaemonRpcResult(pending.operation, response.result)); + } catch (error) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } + } + } + + #rejectPending(error: Error): void { + for (const pending of this.#pending.values()) { + clearTimeout(pending.timer); + pending.removeAbort?.(); + pending.reject(error); + } + this.#pending.clear(); + } +} + +const sharedClients = new Map>(); +let cancelExitCleanup: (() => void) | undefined; + +/** Create an independent socket connection to one project's shared daemon broker. */ +export async function createDaemonBrokerClient( + projectDir: string, + options: DaemonBrokerClientOptions = {}, +): Promise { + const canonical = await canonicalProjectDir(projectDir); + const runtimeDir = options.runtimeDir ?? daemonRuntimeDir(canonical); + const token = await readOrCreateToken(runtimeDir); + return new SocketDaemonClient(canonical, runtimeDir, token, options); +} + +/** Get the process-shared daemon broker client for one canonical project directory. */ +export async function daemonClientForProject(projectDir: string): Promise { + const canonical = await canonicalProjectDir(projectDir); + let pending = sharedClients.get(canonical); + if (!pending) { + pending = createDaemonBrokerClient(canonical); + sharedClients.set(canonical, pending); + if (!cancelExitCleanup) { + cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients()); + } + } + return pending; +} + +/** Close every project broker connection held by this omp process. */ +export async function closeDaemonClients(): Promise { + const pending = [...sharedClients.values()]; + sharedClients.clear(); + for (const client of await Promise.all(pending)) client.close(); + cancelExitCleanup?.(); + cancelExitCleanup = undefined; +} + +/** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */ +export async function smokeTestDaemonBroker(): Promise { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-")); + const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-")); + const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + try { + const ping = await client.request({ op: "ping" }); + if (ping.op !== "ping" || ping.projectDir !== client.projectDir) throw new Error("daemon broker ping mismatch"); + await client.request({ op: "shutdown" }); + } finally { + client.close(); + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.rm(runtimeDir, { recursive: true, force: true }); + } +} diff --git a/packages/coding-agent/src/launch/paths.ts b/packages/coding-agent/src/launch/paths.ts new file mode 100644 index 000000000..2426da642 --- /dev/null +++ b/packages/coding-agent/src/launch/paths.ts @@ -0,0 +1,17 @@ +import * as path from "node:path"; +import { getConfigRootDir } from "@oh-my-pi/pi-utils"; + +/** Resolve the private runtime directory shared by omp processes in one project directory. */ +export function daemonRuntimeDir(projectDir: string, configRoot: string = getConfigRootDir()): string { + const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0"); + return path.join(configRoot, "run", "daemons", key); +} + +/** Resolve the Unix socket or Windows named pipe used by one project broker. */ +export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string { + if (process.platform === "win32") { + const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0"); + return `\\\\.\\pipe\\omp-daemon-${key}`; + } + return path.join(runtimeDir, "broker.sock"); +} diff --git a/packages/coding-agent/src/launch/presence.ts b/packages/coding-agent/src/launch/presence.ts new file mode 100644 index 000000000..afce0d88c --- /dev/null +++ b/packages/coding-agent/src/launch/presence.ts @@ -0,0 +1,82 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { isEnoent, postmortem } from "@oh-my-pi/pi-utils"; +import { daemonRuntimeDir } from "./paths"; + +const CLIENTS_DIR = "clients"; + +/** Handle keeping one omp process registered in a project daemon scope. */ +export interface DaemonProjectPresence { + close(): Promise; +} + +async function canonicalProjectDir(projectDir: string): Promise { + const resolved = path.resolve(projectDir); + try { + return await fs.realpath(resolved); + } catch (error) { + if (isEnoent(error)) return resolved; + throw error; + } +} + +/** Register this omp process so project daemons survive while it remains alive. */ +export async function registerDaemonProjectPresence( + projectDir: string, + runtimeOverride?: string, +): Promise { + const canonical = await canonicalProjectDir(projectDir); + const runtimeDir = runtimeOverride ?? daemonRuntimeDir(canonical); + const clientsDir = path.join(runtimeDir, CLIENTS_DIR); + await fs.mkdir(clientsDir, { recursive: true, mode: 0o700 }); + const id = `${process.pid}-${crypto.randomUUID()}`; + const presencePath = path.join(clientsDir, `${id}.json`); + await Bun.write(presencePath, JSON.stringify({ pid: process.pid, id, projectDir: canonical })); + await fs.chmod(presencePath, 0o600); + let closed = false; + const close = async (): Promise => { + if (closed) return; + closed = true; + cancelCleanup(); + await fs.rm(presencePath, { force: true }); + }; + const cancelCleanup = postmortem.register(`daemon-presence:${id}`, () => close()); + return { close }; +} + +/** Return whether a registered omp process in this runtime directory is still alive. */ +export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise { + const clientsDir = path.join(runtimeDir, CLIENTS_DIR); + let entries: string[]; + try { + entries = await fs.readdir(clientsDir); + } catch (error) { + if (isEnoent(error)) return false; + throw error; + } + let live = false; + for (const entry of entries) { + const presencePath = path.join(clientsDir, entry); + try { + const decoded: unknown = await Bun.file(presencePath).json(); + if ( + typeof decoded !== "object" || + decoded === null || + !("pid" in decoded) || + typeof decoded.pid !== "number" + ) { + await fs.rm(presencePath, { force: true }); + continue; + } + try { + process.kill(decoded.pid, 0); + live = true; + } catch { + await fs.rm(presencePath, { force: true }); + } + } catch (error) { + if (!isEnoent(error)) await fs.rm(presencePath, { force: true }); + } + } + return live; +} diff --git a/packages/coding-agent/src/launch/protocol.ts b/packages/coding-agent/src/launch/protocol.ts new file mode 100644 index 000000000..97bf0631c --- /dev/null +++ b/packages/coding-agent/src/launch/protocol.ts @@ -0,0 +1,378 @@ +/** + * Cross-process daemon broker protocol shared by the tool, client, and broker. + */ +/** Hidden CLI selector used to re-enter the daemon broker worker. */ +export const DAEMON_BROKER_WORKER_ARG = "__omp_worker_daemon_broker"; + +/** Environment key carrying the broker's canonical project directory. */ +export const DAEMON_PROJECT_DIR_ENV = "OMP_DAEMON_PROJECT_DIR"; + +/** Environment key carrying the broker's private runtime directory. */ +export const DAEMON_RUNTIME_DIR_ENV = "OMP_DAEMON_RUNTIME_DIR"; + +/** Optional environment key overriding last-client shutdown grace. */ +export const DAEMON_IDLE_GRACE_ENV = "OMP_DAEMON_IDLE_GRACE_MS"; + +/** Stable lifecycle states exposed by the launch tool. */ +export type DaemonState = "starting" | "running" | "ready" | "restarting" | "stopping" | "exited" | "failed"; + +/** Restart behavior applied after an unexpected daemon exit. */ +export type DaemonRestartPolicy = "no" | "on-failure" | "always"; + +/** Readiness conditions; every configured condition must pass. */ +export interface DaemonReadySpec { + log?: string; + port?: number; + host?: string; + timeoutMs: number; +} + +/** Immutable launch specification retained for restart and inspection. */ +export interface DaemonSpec { + name: string; + application: string; + args: string[]; + env: Record; + cwd: string; + pty: boolean; + ready?: DaemonReadySpec; + restart: DaemonRestartPolicy; + persist: boolean; + detached: boolean; +} + +/** Serializable daemon state visible to every client in one project directory. */ +export interface DaemonSnapshot { + name: string; + id: string; + state: DaemonState; + pid?: number; + createdAt: number; + startedAt: number; + readyAt?: number; + exitedAt?: number; + exitCode?: number; + exitReason?: string; + restartCount: number; + outputBytes: number; + owner?: string; + readyMatch?: string; + /** Readiness conditions still unmet while `state` is `starting`; absent once ready or without a ready spec. */ + readyPending?: ("log" | "port")[]; + persist: boolean; + detached: boolean; +} + +/** Signals accepted by daemon input operations. */ +export type DaemonSignal = "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGQUIT" | "SIGKILL"; + +/** Typed broker operation sent over the authenticated socket. */ +export type DaemonOperation = + | { op: "ping" } + | { op: "start"; spec: DaemonSpec; owner?: string } + | { op: "list" } + | { + op: "logs"; + name: string; + lines: number; + head: boolean; + grep?: string; + follow: boolean; + cursor?: number; + timeoutMs: number; + } + | { op: "wait"; name: string; for: "ready" | "exit"; pattern?: string; timeoutMs: number } + | { op: "send"; name: string; data?: string; signal?: DaemonSignal } + | { op: "stop"; name: string; timeoutMs: number } + | { op: "restart"; name: string } + | { op: "describe"; name: string } + | { op: "shutdown" }; + +/** Typed broker result decoded before it reaches tool code. */ +export type DaemonRpcResult = + | { op: "ping"; projectDir: string } + | { op: "start"; daemon: DaemonSnapshot; readyTimedOut: boolean } + | { op: "list"; daemons: DaemonSnapshot[] } + | { + op: "logs"; + name: string; + text: string; + cursor: number; + timedOut: boolean; + state: DaemonState; + } + | { op: "wait"; daemon: DaemonSnapshot; matched?: string; timedOut: boolean } + | { op: "send"; daemon: DaemonSnapshot } + | { op: "stop"; daemon: DaemonSnapshot } + | { op: "restart"; daemon: DaemonSnapshot } + | { op: "describe"; daemon: DaemonSnapshot; spec: DaemonSpec } + | { op: "shutdown" }; + +/** Authenticated request envelope used by socket clients. */ +export interface DaemonWireRequest { + id: string; + token: string; + operation: DaemonOperation; +} + +/** Response envelope kept raw until matched with its pending operation. */ +export type DaemonWireResponse = { id: string; ok: true; result: unknown } | { id: string; ok: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function record(value: unknown, label: string): Record { + if (!isRecord(value)) throw new Error(`${label} must be an object`); + return value; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); + return value; +} +function rawString(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + return value; +} + +function optionalString(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined; + return stringValue(value, label); +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a finite number`); + return value; +} + +function optionalNumber(value: unknown, label: string): number | undefined { + if (value === undefined) return undefined; + return numberValue(value, label); +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array of strings`); + const result: string[] = []; + for (const item of value) result.push(rawString(item, `${label} item`)); + return result; +} + +function stringRecord(value: unknown, label: string): Record { + const source = record(value, label); + const result: Record = {}; + for (const key in source) result[key] = rawString(source[key], `${label}.${key}`); + return result; +} + +function daemonState(value: unknown): DaemonState { + const state = stringValue(value, "daemon state"); + if (state === "starting" || state === "running" || state === "ready" || state === "restarting") return state; + if (state === "stopping" || state === "exited" || state === "failed") return state; + throw new Error(`Unknown daemon state: ${state}`); +} + +function restartPolicy(value: unknown): DaemonRestartPolicy { + const policy = stringValue(value, "restart policy"); + if (policy === "no" || policy === "on-failure" || policy === "always") return policy; + throw new Error(`Unknown restart policy: ${policy}`); +} + +function daemonSignal(value: unknown): DaemonSignal { + const signal = stringValue(value, "signal"); + if (signal === "SIGINT" || signal === "SIGTERM" || signal === "SIGHUP") return signal; + if (signal === "SIGQUIT" || signal === "SIGKILL") return signal; + throw new Error(`Unknown daemon signal: ${signal}`); +} + +function readyPendingList(value: unknown): ("log" | "port")[] { + if (!Array.isArray(value)) throw new Error("daemon.readyPending must be an array"); + const result: ("log" | "port")[] = []; + for (const item of value) { + if (item !== "log" && item !== "port") throw new Error(`Unknown readiness condition: ${String(item)}`); + result.push(item); + } + return result; +} + +function readySpec(value: unknown): DaemonReadySpec { + const source = record(value, "ready"); + const log = optionalString(source.log, "ready.log"); + const port = optionalNumber(source.port, "ready.port"); + const host = optionalString(source.host, "ready.host"); + const timeoutMs = numberValue(source.timeoutMs, "ready.timeoutMs"); + if (!log && port === undefined) throw new Error("ready requires log or port"); + return { log, port, host, timeoutMs }; +} + +/** Decode and validate a daemon launch specification. */ +export function parseDaemonSpec(value: unknown): DaemonSpec { + const source = record(value, "daemon spec"); + const detached = source.detached === undefined ? false : booleanValue(source.detached, "spec.detached"); + return { + name: stringValue(source.name, "spec.name"), + application: stringValue(source.application, "spec.application"), + args: stringArray(source.args, "spec.args"), + env: stringRecord(source.env, "spec.env"), + cwd: stringValue(source.cwd, "spec.cwd"), + pty: booleanValue(source.pty, "spec.pty"), + ready: source.ready === undefined ? undefined : readySpec(source.ready), + restart: restartPolicy(source.restart), + persist: booleanValue(source.persist, "spec.persist") || detached, + detached, + }; +} + +/** Decode and validate one daemon snapshot. */ +export function parseDaemonSnapshot(value: unknown): DaemonSnapshot { + const source = record(value, "daemon snapshot"); + return { + name: stringValue(source.name, "daemon.name"), + id: stringValue(source.id, "daemon.id"), + state: daemonState(source.state), + pid: optionalNumber(source.pid, "daemon.pid"), + createdAt: numberValue(source.createdAt, "daemon.createdAt"), + startedAt: numberValue(source.startedAt, "daemon.startedAt"), + readyAt: optionalNumber(source.readyAt, "daemon.readyAt"), + exitedAt: optionalNumber(source.exitedAt, "daemon.exitedAt"), + exitCode: optionalNumber(source.exitCode, "daemon.exitCode"), + exitReason: optionalString(source.exitReason, "daemon.exitReason"), + restartCount: numberValue(source.restartCount, "daemon.restartCount"), + outputBytes: numberValue(source.outputBytes, "daemon.outputBytes"), + owner: optionalString(source.owner, "daemon.owner"), + readyMatch: optionalString(source.readyMatch, "daemon.readyMatch"), + readyPending: source.readyPending === undefined ? undefined : readyPendingList(source.readyPending), + persist: booleanValue(source.persist, "daemon.persist"), + detached: source.detached === undefined ? false : booleanValue(source.detached, "daemon.detached"), + }; +} + +/** Decode a socket request before the broker acts on it. */ +export function parseDaemonWireRequest(value: unknown): DaemonWireRequest { + const source = record(value, "daemon request"); + return { + id: stringValue(source.id, "request.id"), + token: stringValue(source.token, "request.token"), + operation: parseDaemonOperation(source.operation), + }; +} + +/** Decode a socket response envelope before resolving a pending call. */ +export function parseDaemonWireResponse(value: unknown): DaemonWireResponse { + const source = record(value, "daemon response"); + const id = stringValue(source.id, "response.id"); + if (source.ok === true) return { id, ok: true, result: source.result }; + if (source.ok === false) return { id, ok: false, error: stringValue(source.error, "response.error") }; + throw new Error("response.ok must be a boolean"); +} + +function parseDaemonOperation(value: unknown): DaemonOperation { + const source = record(value, "daemon operation"); + const op = stringValue(source.op, "operation.op"); + switch (op) { + case "ping": + case "list": + case "shutdown": + return { op }; + case "start": + return { + op, + spec: parseDaemonSpec(source.spec), + owner: optionalString(source.owner, "operation.owner"), + }; + case "logs": + return { + op, + name: stringValue(source.name, "operation.name"), + lines: numberValue(source.lines, "operation.lines"), + head: booleanValue(source.head, "operation.head"), + grep: optionalString(source.grep, "operation.grep"), + follow: booleanValue(source.follow, "operation.follow"), + cursor: optionalNumber(source.cursor, "operation.cursor"), + timeoutMs: numberValue(source.timeoutMs, "operation.timeoutMs"), + }; + case "wait": { + const target = stringValue(source.for, "operation.for"); + if (target !== "ready" && target !== "exit") throw new Error("operation.for must be ready or exit"); + return { + op, + name: stringValue(source.name, "operation.name"), + for: target, + pattern: optionalString(source.pattern, "operation.pattern"), + timeoutMs: numberValue(source.timeoutMs, "operation.timeoutMs"), + }; + } + case "send": + return { + op, + name: stringValue(source.name, "operation.name"), + data: optionalString(source.data, "operation.data"), + signal: source.signal === undefined ? undefined : daemonSignal(source.signal), + }; + case "stop": + return { + op, + name: stringValue(source.name, "operation.name"), + timeoutMs: numberValue(source.timeoutMs, "operation.timeoutMs"), + }; + case "restart": + case "describe": + return { op, name: stringValue(source.name, "operation.name") }; + default: + throw new Error(`Unknown daemon operation: ${op}`); + } +} + +/** Decode a broker result using its pending operation as the discriminator. */ +export function parseDaemonRpcResult(operation: DaemonOperation, value: unknown): DaemonRpcResult { + const source = record(value, `${operation.op} result`); + switch (operation.op) { + case "ping": + return { op: "ping", projectDir: stringValue(source.projectDir, "result.projectDir") }; + case "start": + return { + op: "start", + daemon: parseDaemonSnapshot(source.daemon), + readyTimedOut: booleanValue(source.readyTimedOut, "result.readyTimedOut"), + }; + case "list": { + if (!Array.isArray(source.daemons)) throw new Error("result.daemons must be an array"); + return { op: "list", daemons: source.daemons.map(parseDaemonSnapshot) }; + } + case "logs": + return { + op: "logs", + name: stringValue(source.name, "result.name"), + text: typeof source.text === "string" ? source.text : "", + cursor: numberValue(source.cursor, "result.cursor"), + timedOut: booleanValue(source.timedOut, "result.timedOut"), + state: daemonState(source.state), + }; + case "wait": + return { + op: "wait", + daemon: parseDaemonSnapshot(source.daemon), + matched: optionalString(source.matched, "result.matched"), + timedOut: booleanValue(source.timedOut, "result.timedOut"), + }; + case "send": + return { op: "send", daemon: parseDaemonSnapshot(source.daemon) }; + case "stop": + return { op: "stop", daemon: parseDaemonSnapshot(source.daemon) }; + case "restart": + return { op: "restart", daemon: parseDaemonSnapshot(source.daemon) }; + case "describe": + return { + op: "describe", + daemon: parseDaemonSnapshot(source.daemon), + spec: parseDaemonSpec(source.spec), + }; + case "shutdown": + return { op: "shutdown" }; + } +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0b2b119d9..f91f1e31c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -31,6 +31,7 @@ import { applyStartupCwd } from "./cli/startup-cwd"; import { findConfigFile } from "./config"; import { ModelRegistry } from "./config/model-registry"; import { + expandRoleAlias, getModelMatchPreferences, resolveCliModel, resolveModelRoleValue, @@ -50,6 +51,7 @@ import { injectOmpExtensionCliRoots } from "./discovery/omp-extension-roots"; import { ExtensionRunner } from "./extensibility/extensions/runner"; import type { ExtensionUIContext } from "./extensibility/extensions/types"; import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update"; +import { registerDaemonProjectPresence } from "./launch/presence"; import type { MCPManager } from "./mcp"; import { InteractiveMode } from "./modes/interactive-mode"; import type { PrintModeOptions } from "./modes/print-mode"; @@ -903,6 +905,47 @@ export async function buildSessionOptions( if (!options.model) options.model = scopedModels[0].model; } + if (parsed.noDownshift && (parsed.downshift || parsed.downshiftInto !== undefined)) { + throw new Error("--no-downshift cannot be combined with --downshift or --downshift-into"); + } + const downshiftEnabled = parsed.noDownshift + ? false + : parsed.downshift === true || parsed.downshiftInto !== undefined + ? true + : activeSettings.get("downshift.enabled"); + if (downshiftEnabled) { + const rolePattern = expandRoleAlias(parsed.downshiftInto ?? "pi/smol", activeSettings); + const resolved = resolveCliModel({ cliModel: rolePattern, modelRegistry, preferences: modelMatchPreferences }); + if (resolved.warning) { + process.stderr.write(`${chalk.yellow(`Warning: ${resolved.warning}`)}\n`); + } + if (resolved.error || !resolved.model) { + throw new Error(resolved.error ?? `Model "${parsed.downshiftInto ?? "pi/smol"}" not found`); + } + if (!modelRegistry.hasConfiguredAuth(resolved.model)) { + throw new Error(`No API key for ${resolved.model.provider}/${resolved.model.id}`); + } + options.downshift = { target: resolved.model, thinkingLevel: resolved.thinkingLevel }; + } + + if (parsed.planYoloInto !== undefined && !parsed.planYolo) { + throw new Error("--plan-yolo-into requires --plan-yolo"); + } + if (parsed.planYolo) { + const rolePattern = expandRoleAlias(parsed.planYoloInto ?? "pi/smol", activeSettings); + const resolved = resolveCliModel({ cliModel: rolePattern, modelRegistry, preferences: modelMatchPreferences }); + if (resolved.warning) { + process.stderr.write(`${chalk.yellow(`Warning: ${resolved.warning}`)}\n`); + } + if (resolved.error || !resolved.model) { + throw new Error(resolved.error ?? `Model "${parsed.planYoloInto ?? "pi/smol"}" not found`); + } + if (!modelRegistry.hasConfiguredAuth(resolved.model)) { + throw new Error(`No API key for ${resolved.model.provider}/${resolved.model.id}`); + } + options.planYolo = { target: resolved.model, thinkingLevel: resolved.thinkingLevel }; + } + // Thinking level if (parsed.thinking) { options.thinkingLevel = parsed.thinking; @@ -989,11 +1032,12 @@ interface RunRootCommandDependencies { settings?: Settings; forceSetupWizard?: boolean; } +const DEFAULT_RUN_ROOT_DEPENDENCIES: RunRootCommandDependencies = {}; export async function runRootCommand( parsed: Args, rawArgs: string[], - deps: RunRootCommandDependencies = {}, + deps: RunRootCommandDependencies = DEFAULT_RUN_ROOT_DEPENDENCIES, ): Promise { logger.startTiming(); startStartupWatchdog(); @@ -1250,6 +1294,9 @@ export async function runRootCommand( } await pluginPreloadPromise; + if (deps === DEFAULT_RUN_ROOT_DEPENDENCIES) { + await logger.time("registerDaemonProjectPresence", registerDaemonProjectPresence, cwd); + } scheduleMarketplaceAutoUpdate({ autoUpdate: settingsInstance.get("marketplace.autoUpdate"), diff --git a/packages/coding-agent/src/modes/components/advisor-config.ts b/packages/coding-agent/src/modes/components/advisor-config.ts index 3764d7ff5..79e43d051 100644 --- a/packages/coding-agent/src/modes/components/advisor-config.ts +++ b/packages/coding-agent/src/modes/components/advisor-config.ts @@ -522,7 +522,8 @@ export class AdvisorConfigOverlayComponent implements Component { } #showModelPicker(index: number): void { - const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; + const storage = this.#settings.getStorage(); + const mruOrder = storage?.getModelUsageOrder() ?? []; let models: ReadonlyArray; if (this.#scopedModels.length > 0) { models = this.#scopedModels.map(scoped => scoped.model); @@ -538,6 +539,7 @@ export class AdvisorConfigOverlayComponent implements Component { const picker = new ModelBrowser(this.#settings, {}); picker.setMruOrder(mruOrder); + picker.setPerfStats(storage?.getModelPerf() ?? new Map()); picker.setItems(items); picker.onActivate = item => { const efforts = getSupportedEfforts(item.model); diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 1c8413605..5b09acf76 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -15,8 +15,8 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui"; +import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils"; import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../../advisor"; import type { KeyId } from "../../config/keybindings"; @@ -25,6 +25,7 @@ import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import { USER_INTERRUPT_LABEL } from "../../session/messages"; +import { parseThinkingLevel } from "../../thinking"; import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; import { theme } from "../theme/theme"; @@ -55,20 +56,50 @@ function clampHubLine(line: string, width: number): string { const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; -/** Glyph + status word, colored per theme status conventions. */ -function statusBadge(status: AgentStatus): string { +/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */ +function statusGlyph(status: AgentStatus): string { switch (status) { case "running": - return theme.fg("accent", `${theme.status.running} running`); + return theme.fg("accent", theme.status.running); case "idle": - return theme.fg("success", `${theme.status.enabled} idle`); + return theme.fg("success", theme.status.enabled); case "parked": - return theme.fg("muted", `${theme.status.shadowed} parked`); + return theme.fg("muted", theme.status.shadowed); case "aborted": - return theme.fg("error", `${theme.status.aborted} aborted`); + return theme.fg("error", theme.status.aborted); } } +/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */ +function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string { + const model = theme.fg("muted", replaceTabs(modelId)); + if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model; + const display = theme.thinking[level as keyof typeof theme.thinking] ?? level; + return `${model} ${theme.getThinkingBorderColor(level)(display)}`; +} + +/** + * Active model + reasoning level for a hub row: live session state when the + * agent is attached, else the executor-reported `resolvedModel` selector + * (`provider/id`, optionally `:`). Undefined when neither is known + * (e.g. a parked historical agent restored from disk). + */ +function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined { + const model = ref.session?.model; + if (model) { + const level = model.thinking ? ref.session?.thinkingLevel : undefined; + return formatModelBadge(model.id, level); + } + const resolved = observed?.progress?.resolvedModel; + if (!resolved) return undefined; + // Model ids may themselves contain colons (`qwen3:14b`), so only treat the + // suffix as a thinking level when it parses as one. + const colon = resolved.lastIndexOf(":"); + const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined; + const selector = level !== undefined ? resolved.slice(0, colon) : resolved; + return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level); +} + async function registerPersistedSubagents( registry: AgentRegistry, sessionFile: string | null | undefined, @@ -427,17 +458,31 @@ export class AgentHubOverlayComponent extends Container { } else { const termHeight = process.stdout.rows || 40; // Chrome: 2 borders + title + notice? + blank + hints + border - const maxVisible = Math.max(3, termHeight - 7 - (this.#notice ? 1 : 0)); - let start = 0; - if (this.#rows.length > maxVisible) { - start = Math.min( - Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)), - this.#rows.length - maxVisible, - ); + const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0)); + const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width)); + // Entries are 1-2 lines tall; grow a window around the selection until + // the line budget is spent, so the selected entry stays centered. + let start = this.#selectedRow; + let end = this.#selectedRow + 1; + let used = entries[start]?.length ?? 0; + for (let grew = true; grew; ) { + grew = false; + if (end < entries.length && used + entries[end].length <= budget) { + used += entries[end].length; + end++; + grew = true; + } + if (start > 0 && used + entries[start - 1].length <= budget) { + start--; + used += entries[start].length; + grew = true; + } + } + if (start > 0) { + lines.push(` ${theme.fg("dim", `… ${start} more`)}`); } - const end = Math.min(start + maxVisible, this.#rows.length); for (let i = start; i < end; i++) { - lines.push(this.#renderRow(this.#rows[i], i === this.#selectedRow, width)); + lines.push(...entries[i]); } if (end < this.#rows.length) { lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`); @@ -466,26 +511,51 @@ export class AgentHubOverlayComponent extends Container { return parts.join(theme.sep.dot); } - #renderRow(ref: AgentRef, selected: boolean, width: number): string { + /** + * One agent entry, 1-2 lines: + * `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity + * left, metadata right-aligned (inlined when the terminal is too narrow) — + * plus an indented dim task line when the agent's work is known. + */ + #renderEntry(ref: AgentRef, selected: boolean, width: number): string[] { + const max = Math.max(1, width - 2); const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))]; - parts.push(theme.fg("dim", replaceTabs(ref.displayName))); - parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind)); - if (ref.kind === "advisor") { - parts.push(theme.fg("warning", "read-only")); + const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`]; + if (ref.displayName && ref.displayName !== ref.id) { + fields.push(theme.fg("dim", replaceTabs(ref.displayName))); } - const observed = this.#observableFor(ref.id); - const task = observed?.description ?? observed?.progress?.task; - if (task) { - parts.push(theme.fg("muted", sanitizeLine(task, TRUNCATE_LENGTHS.TITLE))); + if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) { + fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`)); + } + if (ref.kind === "advisor") { + fields.push(theme.fg("warning", "read-only")); } const unread = this.#irc.unreadCount(ref.id); if (unread > 0) { - parts.push(theme.fg("warning", `⧉ ${unread}`)); + fields.push(theme.fg("warning", `⧉ ${unread}`)); } - parts.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000))))); - const rawLine = ` ${cursor} ${parts.join(theme.sep.dot)}`; - return truncateToWidth(rawLine.replace(/[\r\n]+/g, " "), Math.max(1, width - 1)); + const left = ` ${fields.join(" ")}`; + + const observed = this.#observableFor(ref.id); + const meta: string[] = []; + const badge = modelBadge(ref, observed); + if (badge) meta.push(badge); + meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000))))); + const right = meta.join(theme.sep.dot); + + const leftWidth = visibleWidth(left); + const rightWidth = visibleWidth(right); + const line = + leftWidth + 2 + rightWidth <= max + ? left + padding(max - leftWidth - rightWidth) + right + : truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max); + const entry = [line]; + + const task = observed?.description ?? observed?.progress?.task ?? ref.activity; + if (task) { + entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`); + } + return entry; } #handleTableInput(keyData: string): void { diff --git a/packages/coding-agent/src/modes/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/components/compaction-summary-message.ts index 04aeb9ece..f5cb45590 100644 --- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts @@ -87,7 +87,12 @@ export class CompactionSummaryMessageComponent implements Component { constructor(private readonly message: CompactionSummaryMessage) { this.#divider = new SummaryDividerComponent({ - label: () => `${theme.icon.camera} compacted`, + // A dead-end warning stamped by the progress guard badges the bar; + // the full text lives in the ctrl+o detail block below. + label: () => + this.message.warning + ? `${theme.icon.camera} compacted ${theme.fg("warning", theme.icon.warning)}` + : `${theme.icon.camera} compacted`, detailMarkdown: () => this.#detailMarkdown(), }); } @@ -109,7 +114,8 @@ export class CompactionSummaryMessageComponent implements Component { const frameCount = this.message.images?.length ?? 0; const frameNote = frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : ""; - return `**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`; + const warningNote = this.message.warning ? `\n\n${theme.icon.warning} **Warning:** ${this.message.warning}` : ""; + return `**Compacted from ${tokenStr} tokens**${warningNote}\n\n${this.message.summary}${frameNote}`; } } diff --git a/packages/coding-agent/src/modes/components/custom-editor.test.ts b/packages/coding-agent/src/modes/components/custom-editor.test.ts index 18a5fd681..24c67cefb 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.test.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { $ } from "bun"; import { getDefaultPasteImageKeys } from "../../config/keybindings"; -import { getEditorTheme, initTheme } from "../theme/theme"; +import { getEditorTheme, initTheme, theme } from "../theme/theme"; import { CustomEditor, extractBracketedImagePastePaths, @@ -78,6 +79,62 @@ describe("CustomEditor placeholder decoration", () => { }); }); +describe("CustomEditor queue shorthand decoration", () => { + beforeAll(async () => { + await initTheme(); + }); + + it("reserves the first line as soon as either queue prefix is completed", () => { + for (const prefix of ["->", "=>"]) { + const editor = new CustomEditor(getEditorTheme()); + editor.handleInput(prefix[0] ?? ""); + expect(editor.getText()).toBe(prefix[0]); + + editor.handleInput(prefix[1] ?? ""); + expect(editor.getText()).toBe(`${prefix}\n`); + expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); + + editor.handleInput("\x7f"); + expect(editor.getText()).toBe(`${prefix}\n`); + expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); + } + }); + + it("renders the reserved line as a dim Queueing header", () => { + for (const prefix of ["->", "=>"]) { + const editor = new CustomEditor(getEditorTheme()); + editor.setText(`${prefix}\nqueue this`); + + expect(editor.decorateText(prefix)).toBe(theme.fg("dim", `Queueing ${theme.nav.selected}`)); + editor.focused = true; + const rendered = editor.render(40).map(line => Bun.stripANSI(line.replace(CURSOR_MARKER, ""))); + expect(rendered.some(line => line.includes(`Queueing ${theme.nav.selected}`))).toBe(true); + expect(rendered.every(line => Bun.stringWidth(line) === 40)).toBe(true); + expect(rendered.some(line => line.includes("queue this"))).toBe(true); + } + }); + + it("highlights dot and parenthesis markers only for detected queue lists", () => { + for (const [input, marker] of [ + ["=>\n1. first\n2. second", "1."], + ["=>\n1) first\n2) second", "1)"], + ]) { + const editor = new CustomEditor(getEditorTheme()); + editor.setText(input); + expect(editor.decorateText(`${marker} first`).startsWith(theme.fg("accent", marker))).toBe(true); + } + + const unfinished = new CustomEditor(getEditorTheme()); + unfinished.setText("=>\n1. first\n2. second\n3. third\n4."); + expect(unfinished.decorateText("1. first").startsWith(theme.fg("accent", "1."))).toBe(true); + expect(unfinished.decorateText("4.").startsWith(theme.fg("accent", "4."))).toBe(true); + + const editor = new CustomEditor(getEditorTheme()); + editor.setText("=>\n1. first\n3. third"); + expect(editor.decorateText("1. first")).toBe("1. first"); + }); +}); + describe("CustomEditor bracketed path paste", () => { it("leaves a pasted bare .png filename on the normal text path", () => { expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined(); diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index bf144ff11..98394db89 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -6,7 +6,8 @@ import type { AppKeybinding } from "../../config/keybindings"; import { isSettingsInitialized, settings } from "../../config/settings"; import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references"; import { hasMagicKeyword, highlightMagicKeywords } from "../magic-keywords"; -import { fgOrPlain } from "../theme/theme"; +import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "../queue-input"; +import { fgOrPlain, theme } from "../theme/theme"; type ConfigurableEditorAction = Extract< AppKeybinding, @@ -326,21 +327,41 @@ export class CustomEditor extends Editor { * timer to request the next animation frame. Undefined when nobody is * listening (tests, headless callers); the timer chain still self-cleans. */ #requestShimmerRepaint: (() => void) | undefined; + #queueDecorationText: string | undefined; + #queueShorthandActive = false; + #queueListActive = false; - /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types - * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make - * pasted image placeholders visually distinct and hyperlink them once their blob file exists. - * When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled` - * is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render - * schedules the next frame, so losing focus, deleting the keyword, or flipping the setting - * stops the animation on its own. The static glow itself runs even when shimmering is gated - * off, matching existing behavior for the editor and sent bubbles. */ + /** Decorate magic keywords, attachments, and the queue-composer header/list markers. + * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential + * item markers use the accent color so separate follow-ups remain visible while composing. */ decorateText = (text: string): string => { - const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(this.getText()); + const editorText = this.getText(); + const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText); const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0; if (animated) this.#scheduleShimmerFrame(); + if (this.#queueDecorationText !== editorText) { + this.#queueDecorationText = editorText; + const queueBody = parseQueueShorthand(editorText); + this.#queueShorthandActive = queueBody !== undefined; + this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody); + } return renderPlaceholders(text, { - renderText: value => highlightMagicKeywords(value, undefined, phase), + renderText: value => { + const highlighted = highlightMagicKeywords(value, undefined, phase); + if (this.#queueShorthandActive && (value.startsWith("->") || value.startsWith("=>"))) { + const icon = typeof theme === "undefined" ? "➤" : theme.nav.selected; + return `${fgOrPlain("dim", `Queueing ${icon}`)}${highlighted.slice(2)}`; + } + if (this.#queueListActive) { + const markerMatch = QUEUE_LIST_MARKER_RE.exec(value); + if (markerMatch) { + const indent = markerMatch[1] ?? ""; + const markerEnd = markerMatch[0].length; + return `${indent}${fgOrPlain("accent", value.slice(indent.length, markerEnd))}${highlighted.slice(markerEnd)}`; + } + } + return highlighted; + }, renderReference: (value, kind, index) => kind === "image" ? imageReferenceHyperlink(value, index, this.imageLinks, label => @@ -628,6 +649,7 @@ export class CustomEditor extends Editor { this.#pendingInput.push(data); return; } + const hadBareQueuePrefix = this.getText() === "->" || this.getText() === "=>"; const kittyParsed = parseKittySequence(data); if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) { // Caps Lock is modifier bit 64 @@ -831,5 +853,14 @@ export class CustomEditor extends Editor { // Pass to parent for normal handling super.handleInput(data); + const cursor = this.getCursor(); + if ( + !hadBareQueuePrefix && + (this.getText() === "->" || this.getText() === "=>") && + cursor.line === 0 && + cursor.col === 2 + ) { + this.insertText("\n"); + } } } diff --git a/packages/coding-agent/src/modes/components/index.ts b/packages/coding-agent/src/modes/components/index.ts index 769bb9ddd..e1fe04035 100644 --- a/packages/coding-agent/src/modes/components/index.ts +++ b/packages/coding-agent/src/modes/components/index.ts @@ -20,6 +20,7 @@ export * from "./login-dialog"; export * from "./logout-account-selector"; export * from "./model-browser"; export * from "./model-hub"; +export * from "./model-picker"; export * from "./oauth-selector"; export * from "./queue-mode-selector"; export * from "./read-tool-group"; diff --git a/packages/coding-agent/src/modes/components/model-browser.ts b/packages/coding-agent/src/modes/components/model-browser.ts index 2634a4752..a08fc3eba 100644 --- a/packages/coding-agent/src/modes/components/model-browser.ts +++ b/packages/coding-agent/src/modes/components/model-browser.ts @@ -13,7 +13,7 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { type Component, - fuzzyFilter, + fuzzyRank, Input, matchesKey, ScrollView, @@ -22,10 +22,12 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import { formatNumber } from "@oh-my-pi/pi-utils"; -import { getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles"; +import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver"; +import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles"; import type { Settings } from "../../config/settings"; -import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; -import { theme } from "../theme/theme"; +import type { ModelPerfStats } from "../../session/agent-storage"; +import { AUTO_THINKING, type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../../thinking"; +import { type ThemeColor, theme } from "../theme/theme"; import { matchesSelectCancel, matchesSelectDown, @@ -34,12 +36,14 @@ import { matchesSelectUp, } from "../utils/keybinding-matchers"; -/** One selectable model row. `selector` is the canonical `provider/id` key. */ +/** One selectable row. `selector` is a canonical model key or host-specific virtual key. */ export interface ModelBrowserItem { provider: string; id: string; model: Model; selector: string; + /** Optional foreground color for the row label. */ + labelColor?: ThemeColor; } /** Resolved role assignment as displayed by the browser and the hub. */ @@ -53,6 +57,67 @@ export interface RoleAssignment { /** Map of role id to its resolved assignment (absent roles are unresolved). */ export type RoleAssignments = Record; +/** + * Resolve every known role to its display assignment: configured role values + * resolve against `allModels`; unconfigured roles fall back to auto-selection + * over `autoCandidates` (skipped when empty). Shared by the /models hub and + * the alt+p session picker. + */ +export function resolveRoleAssignments( + settings: Settings, + allModels: ReadonlyArray, + autoCandidates: ReadonlyArray, +): RoleAssignments { + const resolvedThinkingLevel = ( + role: string, + resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel }, + ): ConfiguredThinkingLevel => { + if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) { + return resolved.thinkingLevel; + } + if (role === "default") { + return parseConfiguredThinkingLevel(settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit; + } + return ThinkingLevel.Inherit; + }; + + const roles: RoleAssignments = {}; + const matchPreferences = getModelMatchPreferences(settings); + const knownRoles = getKnownRoleIds(settings); + const configuredRoles = new Set(); + const catalog = [...allModels]; + + for (const role of knownRoles) { + const roleValue = settings.getModelRole(role); + if (!roleValue) continue; + configuredRoles.add(role); + const resolved = resolveModelRoleValue(roleValue, catalog, { settings, matchPreferences }); + if (resolved.model) { + roles[role] = { + model: resolved.model, + thinkingLevel: resolvedThinkingLevel(role, resolved), + autoSelected: false, + }; + } + } + + if (autoCandidates.length > 0) { + const candidates = [...autoCandidates]; + for (const role of knownRoles) { + if (configuredRoles.has(role)) continue; + const resolved = resolveModelRoleValue(`pi/${role}`, candidates, { settings, matchPreferences }); + if (!resolved.model) continue; + roles[role] = { + model: resolved.model, + thinkingLevel: resolvedThinkingLevel(role, resolved), + autoSelected: true, + }; + } + } + + return roles; +} + /** Wrap raw models into browser items. */ export function buildBrowserItems(models: ReadonlyArray): ModelBrowserItem[] { return models.map(model => ({ @@ -227,6 +292,18 @@ function formatContext(model: Model): string { return `${formatNumber(ctx).toLowerCase()} ${theme.icon.context.replace(/:$/, "")}`; } +/** `118t/s` average output speed; one decimal below 10 t/s. */ +function formatTps(tps: number): string { + const value = tps >= 10 ? String(Math.round(tps)) : tps.toFixed(1); + return `${value}t/s`; +} + +/** `0.9s` average time-to-first-token; whole seconds from 10s up. */ +function formatTtft(ms: number): string { + const seconds = ms / 1000; + return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`; +} + /** Pad `text` on the left to `width` terminal columns (ANSI/emoji aware). */ function padLeftVisible(text: string, width: number): string { const missing = width - visibleWidth(text); @@ -243,13 +320,18 @@ export interface ModelBrowserOptions { disableOverContext?: boolean; /** Host-provided empty-state text (e.g. provider discovery status). */ emptyText?: () => string | undefined; - initialQuery?: string; } /** Rendered rows before the list window: search row + blank. */ const LIST_ROW_START = 2; /** Rendered rows after the list window: blank + two detail rows. */ const DETAIL_ROWS = 3; +/** Row width from which the measured-perf column appears (TPS only). */ +const PERF_TPS_MIN_WIDTH = 76; +/** Row width from which the perf column also includes TTFT. */ +const PERF_FULL_MIN_WIDTH = 96; +/** What the per-row perf column shows at the current width. */ +type PerfMode = "off" | "tps" | "full"; /** * The reusable browser component. Renders a fixed-height block @@ -263,6 +345,7 @@ export class ModelBrowser implements Component { #visibleItems: ModelBrowserItem[] = []; #roles: RoleAssignments = {}; #mruOrder: ReadonlyArray = []; + #perf: ReadonlyMap = new Map(); #selectedIndex = 0; #hoveredIndex: number | null = null; #maxVisible = 10; @@ -270,8 +353,15 @@ export class ModelBrowser implements Component { #currentContextTokens: number; #disableOverContext: boolean; #emptyText?: () => string | undefined; + /** Keep role-like virtual rows in their host-defined order during search. */ + #preserveQueryOrder = false; + /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */ #windowStart = 0; #windowCount = 0; + /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */ + #focused = true; + /** `provider/id` of the session's active model; marked in rows and detail. */ + #currentSelector: string | undefined; /** Enter or click-on-selected. */ onActivate?: (item: ModelBrowserItem) => void; @@ -287,9 +377,11 @@ export class ModelBrowser implements Component { this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0; this.#disableOverContext = options.disableOverContext ?? false; this.#emptyText = options.emptyText; - if (options.initialQuery) { - this.#searchInput.setValue(options.initialQuery); - } + } + + /** Mark `selector` as the session's active model (undefined clears the mark). */ + setCurrentSelector(selector: string | undefined): void { + this.#currentSelector = selector; } /** Replace the scope's base items; the live query re-applies and selection is pinned by selector. */ @@ -310,13 +402,32 @@ export class ModelBrowser implements Component { this.#mruOrder = order; } + /** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */ + setPerfStats(perf: ReadonlyMap): void { + this.#perf = perf; + } + setMaxVisible(rows: number): void { + // No selection snap here: hosts call this on every render, and it must + // not undo wheel panning. render() re-clamps the window. this.#maxVisible = Math.max(1, rows); } setShowProvider(show: boolean): void { this.#showProvider = show; } + /** Keep the source order after fuzzy filtering instead of applying model-specific ranking. */ + setPreserveQueryOrder(preserve: boolean): void { + this.#preserveQueryOrder = preserve; + } + /** Allow hosts to toggle context-window eligibility between browser modes. */ + setDisableOverContext(disable: boolean): void { + this.#disableOverContext = disable; + } + /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */ + setFocused(focused: boolean): void { + this.#focused = focused; + } /** Total rendered height for the current `maxVisible` (host layout budgeting). */ get renderedRows(): number { @@ -345,6 +456,7 @@ export class ModelBrowser implements Component { const index = this.#visibleItems.findIndex(item => item.selector === selector); if (index < 0) return false; this.#selectedIndex = this.#coerceSelectedIndex(index); + this.#ensureSelectedVisible(); return true; } @@ -372,23 +484,48 @@ export class ModelBrowser implements Component { return clamped; } - moveSelection(delta: number): void { + /** Clamp a window start into `[0, total - maxVisible]`. */ + #clampWindowStart(start: number): number { + return Math.max(0, Math.min(start, this.#visibleItems.length - this.#maxVisible)); + } + + /** Scroll just enough to keep the selected row inside the window. */ + #ensureSelectedVisible(): void { + if (this.#selectedIndex < this.#windowStart) { + this.#windowStart = this.#selectedIndex; + } else if (this.#selectedIndex >= this.#windowStart + this.#maxVisible) { + this.#windowStart = this.#selectedIndex - this.#maxVisible + 1; + } + this.#windowStart = this.#clampWindowStart(this.#windowStart); + } + + /** + * Move the selection by `delta` rows, skipping disabled rows. Single steps + * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead. + */ + moveSelection(delta: number, options: { wrap?: boolean } = {}): void { const count = this.#visibleItems.length; if (count === 0) return; - let index = this.#selectedIndex; - for (let step = 0; step < count; step++) { - index = (index + delta + count) % count; - const item = this.#visibleItems[index]; - if (item && !this.#isDisabled(item)) { - this.#setSelectedIndex(index); - return; + if (options.wrap ?? true) { + let index = this.#selectedIndex; + for (let step = 0; step < count; step++) { + index = (index + delta + count) % count; + const item = this.#visibleItems[index]; + if (item && !this.#isDisabled(item)) { + this.#setSelectedIndex(index); + return; + } } + return; } + const target = Math.max(0, Math.min(this.#selectedIndex + delta, count - 1)); + this.#setSelectedIndex(this.#coerceSelectedIndex(target)); } #setSelectedIndex(index: number): void { if (index === this.#selectedIndex) return; this.#selectedIndex = index; + this.#ensureSelectedVisible(); this.onSelectionChange?.(this.getSelected()); } @@ -432,16 +569,30 @@ export class ModelBrowser implements Component { if (query.trim()) { // Match against the displayed "provider/id" string so the user can // type what they see: bare names, provider prefixes, or scoped - // queries all flow through the same fuzzy matcher. Skip role rank - // so a weakly matching default doesn't trump a stronger match. - const matches = fuzzyFilter(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`); - sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true }); - items = matches; + // queries all flow through the same fuzzy matcher. + const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`); + const matches = ranked.map(result => result.item); + if (this.#preserveQueryOrder) { + items = matches; + } else { + // Match quality is the primary key while searching: an exact + // "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so + // role rank is skipped and MRU only breaks ties. Scores are + // bucketed so sub-point position noise (provider-name length) + // can't split equally good matches; within a bucket the stable + // sort keeps sortModelItems' MRU/version order. + sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true }); + const buckets = new Map(); + for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10)); + matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0)); + items = matches; + } } else { items = this.#baseItems; } this.#visibleItems = this.#insertSeparator(items); this.#selectedIndex = this.#coerceSelectedIndex(Math.min(this.#selectedIndex, this.#visibleItems.length - 1)); + this.#ensureSelectedVisible(); this.onSelectionChange?.(this.getSelected()); } @@ -459,11 +610,19 @@ export class ModelBrowser implements Component { return; } if (matchesSelectPageUp(data)) { - this.moveSelection(-this.#maxVisible); + this.moveSelection(-this.#maxVisible, { wrap: false }); return; } if (matchesSelectPageDown(data)) { - this.moveSelection(this.#maxVisible); + this.moveSelection(this.#maxVisible, { wrap: false }); + return; + } + if (matchesKey(data, "home")) { + this.moveSelection(-this.#visibleItems.length, { wrap: false }); + return; + } + if (matchesKey(data, "end")) { + this.moveSelection(this.#visibleItems.length, { wrap: false }); return; } if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { @@ -499,50 +658,49 @@ export class ModelBrowser implements Component { */ routeMouse(event: SgrMouseEvent, line: number): void { if (event.wheel !== null) { - this.moveSelection(event.wheel); - return; - } - const listLine = line - LIST_ROW_START; - if (listLine < 0 || listLine >= this.#windowCount) { - if (event.motion && this.#hoveredIndex !== null) { - this.#hoveredIndex = null; - } - return; - } - const index = this.#windowStart + listLine; - const item = this.#visibleItems[index]; - if (!item || this.#isDisabled(item)) { - this.#hoveredIndex = null; + // Wheel pans the window; it never moves the selection and never wraps. + this.#windowStart = this.#clampWindowStart(this.#windowStart + event.wheel); + this.#hoveredIndex = this.#hoverIndexAt(line); return; } if (event.motion) { - this.#hoveredIndex = index; + this.#hoveredIndex = this.#hoverIndexAt(line); return; } - if (event.leftClick) { - // Settings idiom: click selects, click-again activates. - if (index === this.#selectedIndex) { - this.onActivate?.(item); - } else { - this.#setSelectedIndex(index); - } + if (!event.leftClick) return; + const index = this.#hoverIndexAt(line); + const item = index !== null ? this.#visibleItems[index] : undefined; + if (index === null || !item) return; + // Settings idiom: click selects, click-again activates. + if (index === this.#selectedIndex) { + this.onActivate?.(item); + } else { + this.#setSelectedIndex(index); } } + /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */ + clearHover(): void { + this.#hoveredIndex = null; + } - #chipsFor(model: Model): string { - const parts: string[] = []; - const seen = new Set(); - const pushChip = (role: string) => { - if (seen.has(role)) return; - seen.add(role); - const assignment = this.#roles[role]; - if (!assignment || !modelsAreEqual(assignment.model, model)) return; - if (getRoleInfo(role, this.#settings).hidden) return; - parts.push(formatRoleChip(role, assignment, this.#settings)); - }; - for (const role of MODEL_ROLE_IDS) pushChip(role); - for (const role in this.#roles) pushChip(role); - return parts.length > 0 ? ` ${parts.join(" ")}` : ""; + /** List index under a frame-local row, or null when off-list or on a disabled row. */ + #hoverIndexAt(line: number): number | null { + const listLine = line - LIST_ROW_START; + if (listLine < 0 || listLine >= this.#windowCount) return null; + const index = this.#windowStart + listLine; + const item = this.#visibleItems[index]; + if (!item || this.#isDisabled(item)) return null; + return index; + } + + /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */ + #perfCell(item: ModelBrowserItem, mode: PerfMode): string { + if (mode === "off") return ""; + const perf = this.#perf.get(item.selector); + if (!perf) return ""; + const tps = formatTps(perf.tps); + if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`; + return tps; } #renderRow( @@ -552,6 +710,8 @@ export class ModelBrowser implements Component { hovered: boolean, ctxWidth: number, costWidth: number, + perfWidth: number, + perfMode: PerfMode, ): string { if (item.id === "separator") { const dashCount = Math.max(0, width - 4); @@ -559,16 +719,25 @@ export class ModelBrowser implements Component { return ` ${line} `; } const disabled = this.#isDisabled(item); - const prefix = selected ? `${theme.fg("accent", theme.nav.cursor)} ` : " "; + const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " "; const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : ""; - const name = selected ? theme.fg("accent", item.id) : item.id; + const name = item.labelColor + ? theme.fg(item.labelColor, item.id) + : selected + ? theme.fg("accent", item.id) + : item.id; + const currentMark = + item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : ""; const overLimit = disabled ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}` : ""; - let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`; + let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`; - const meta = `${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`; - const metaWidth = ctxWidth + costWidth + 2; + // Perf column collapses entirely when no visible row has measurements. + const perfCol = + perfWidth > 0 ? `${theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth))} ` : ""; + const meta = `${perfCol}${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`; + const metaWidth = ctxWidth + costWidth + 2 + (perfWidth > 0 ? perfWidth + 2 : 0); const available = Math.max(1, width - metaWidth - 1); left = truncateToWidth(left, available); const gap = Math.max(0, available - visibleWidth(left)); @@ -577,7 +746,9 @@ export class ModelBrowser implements Component { if (disabled) { line = theme.fg("dim", Bun.stripANSI(line)); } - if (hovered && !selected && !disabled) { + // The bg band is reserved for the mouse: it marks hover, nothing else. + // Keyboard selection is the cursor glyph + accent name. + if (hovered && !disabled) { line = theme.bg("selectedBg", line); } return line; @@ -594,6 +765,11 @@ export class ModelBrowser implements Component { facts.push(`${formatCostPair(model)} per M`); if (model.reasoning) facts.push("reasoning"); if (model.input.includes("image")) facts.push("vision"); + const perf = this.#perf.get(selected.selector); + if (perf) { + facts.push(`~${formatTps(perf.tps)}`); + if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`); + } const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width); if (this.#isDisabled(selected)) { @@ -602,6 +778,9 @@ export class ModelBrowser implements Component { } const chips: string[] = []; + if (selected.selector === this.#currentSelector) { + chips.push(theme.fg("success", `${theme.status.enabled} current`)); + } const seen = new Set(); const pushRole = (role: string) => { if (seen.has(role)) return; @@ -626,12 +805,12 @@ export class ModelBrowser implements Component { lines.push(""); const total = this.#visibleItems.length; - const startIndex = Math.max( - 0, - Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible), - ); + // The window is persistent state: wheel scrolling panned it, keyboard + // navigation snapped it to the selection. Re-clamp here because items + // or maxVisible may have changed since. + this.#windowStart = this.#clampWindowStart(this.#windowStart); + const startIndex = this.#windowStart; const endIndex = Math.min(startIndex + this.#maxVisible, total); - this.#windowStart = startIndex; this.#windowCount = Math.max(0, endIndex - startIndex); if (total === 0) { @@ -644,11 +823,14 @@ export class ModelBrowser implements Component { // scanning the entire catalog on every render. let ctxWidth = 0; let costWidth = 0; + const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off"; + let perfWidth = 0; for (let i = startIndex; i < endIndex; i++) { const item = this.#visibleItems[i]; if (!item) continue; ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model))); costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model))); + perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode))); } const rows: string[] = []; @@ -663,6 +845,8 @@ export class ModelBrowser implements Component { i === this.#hoveredIndex, ctxWidth, costWidth, + perfWidth, + perfMode, ), ); } diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 752174779..d662f8ed8 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -6,7 +6,8 @@ * {@link ModelBrowser} body. The Roles view manages assignments directly: * pick a role, pick a model, adjust thinking in an inline strip, or clear the * role back to auto-selection. Locked providers forward to the /login flow. - * Fully mouse-navigable (hover, wheel, click). + * Fully mouse-navigable (hover, wheel, click). Session-only switching lives + * in the compact alt+p picker ({@link ./model-picker}). */ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; @@ -27,15 +28,9 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import type { ModelRegistry } from "../../config/model-registry"; -import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver"; import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles"; import type { Settings } from "../../config/settings"; -import { - AUTO_THINKING, - type ConfiguredThinkingLevel, - getConfiguredThinkingLevelMetadata, - parseConfiguredThinkingLevel, -} from "../../thinking"; +import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking"; import { theme } from "../theme/theme"; import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; import { @@ -43,16 +38,36 @@ import { ModelBrowser, type ModelBrowserItem, type RoleAssignments, + resolveRoleAssignments, sortModelItems, thinkingLevelGlyph, } from "./model-browser"; import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box"; import { renderSegmentTrack } from "./segment-track"; -/** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */ -export type ModelHubMode = "roles" | "pick"; +/** + * A row of the Roles view: a role, a model/wildcard chain-key header, one of a + * chain's fallback entries, or the trailing "+ New role…". Fallback rows under + * a chain-key header carry the key in `role` — `retry.fallbackChains` treats + * roles, `provider/model-id`, and `provider/*` keys uniformly. + */ +type RolesRow = + | { kind: "role"; role: string } + | { kind: "chainKey"; role: string } + | { kind: "fallback"; role: string; chainIndex: number; selector: string } + | { kind: "separator" } + | { kind: "newFallback" } + | { kind: "newRole" }; -export type ModelHubAction = "modelRole" | "retryFallback"; +/** + * What the model browser is currently picking for: a role's model, a slot in + * a fallback chain (`role` may be a role name, model selector, or `provider/*` + * key), or the primary model a brand-new fallback chain protects. + */ +type AssignTarget = + | { kind: "role"; role: string } + | { kind: "fallback"; role: string; index: number | null } + | { kind: "fallbackKey" }; /** A `--models` scope entry (mirrors the session's scoped model list). */ export interface ScopedModelItem { @@ -61,18 +76,12 @@ export interface ScopedModelItem { } export interface ModelHubCallbacks { - /** Persist a role assignment (or a retry-fallback registration). */ - onAssign: ( - model: Model, - role: string, - thinkingLevel: ConfiguredThinkingLevel | undefined, - selector: string, - action: ModelHubAction, - ) => void; + /** Persist a role assignment. */ + onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void; /** Clear a configured role back to auto-selection. */ onUnassign: (role: string) => void; - /** Pick-mode activation: session-only switch or embedded pick. */ - onPick?: (model: Model, selector: string) => void; + /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */ + onFallbackChainChange?: (role: string, chain: string[]) => void; /** Locked provider activation: forward to the /login flow. */ onLoginRequest?: (providerId: string) => void; /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */ @@ -81,14 +90,8 @@ export interface ModelHubCallbacks { } export interface ModelHubOptions { - mode?: ModelHubMode; - /** Session token count; in pick mode, models with smaller context windows are disabled. */ - currentContextTokens?: number; /** Preselect this provider's sidebar entry (e.g. when reopening after /login). */ initialProviderId?: string; - /** Status-row hint shown in pick mode. */ - pickerHint?: string; - initialQuery?: string; } interface SidebarEntry { @@ -108,7 +111,7 @@ interface StripChip { /** Pre-styled label body (without selection decoration). */ styled: string; role?: string; - action: "assign" | "unassign" | "fallback" | "thinking"; + action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking"; thinkingLevel?: ConfiguredThinkingLevel; } @@ -140,8 +143,6 @@ const RECENT_LIMIT = 15; const SIDEBAR_MIN_WIDTH = 18; const SIDEBAR_MAX_WIDTH = 26; -const PICK_MODE_HINT = "Session-only switch — role models stay unchanged"; - /** * Providers already auto-refreshed this process. Selecting a provider fetches * its live model list at most once per application lifetime (surviving hub @@ -164,8 +165,6 @@ export class ModelHubComponent implements Component { #registry: ModelRegistry; #scopedModels: ReadonlyArray; #callbacks: ModelHubCallbacks; - #mode: ModelHubMode; - #pickerHint: string; #browser: ModelBrowser; #roles: RoleAssignments = {}; @@ -184,6 +183,8 @@ export class ModelHubComponent implements Component { #searchTotal = 0; #activeEntryId = "all"; #sidebarScroll = 0; + /** Snap the sidebar viewport to the active entry on the next render; wheel panning leaves it free. */ + #sidebarFollowActive = true; #sidebarHover: number | null = null; /** * Arrow-key ownership: `scope` (default) hops the sidebar even while the @@ -192,11 +193,11 @@ export class ModelHubComponent implements Component { */ #focus: "scope" | "list" = "scope"; - #roleIds: string[] = []; + #rolesRows: RolesRow[] = []; #roleIndex = 0; #roleHover: number | null = null; - #assigningRole: string | null = null; + #assigning: AssignTarget | null = null; #strip: StripState | null = null; /** Per-provider fuzzy match counts while a query is active; null when not searching. */ #searchCounts: Map | null = null; @@ -215,7 +216,7 @@ export class ModelHubComponent implements Component { #footerRow = 0; #chipRanges: ChipRange[] = []; #lockedLoginLine: number | null = null; - #rolesRowStart = 2; + #rolesRowStart = 1; constructor( tui: TUI, @@ -230,14 +231,9 @@ export class ModelHubComponent implements Component { this.#registry = registry; this.#scopedModels = scopedModels; this.#callbacks = callbacks; - this.#mode = options.mode ?? "roles"; - this.#pickerHint = options.pickerHint ?? PICK_MODE_HINT; this.#browser = new ModelBrowser(settings, { - currentContextTokens: options.currentContextTokens, - disableOverContext: this.#mode === "pick", emptyText: () => this.#emptyStateMessage(), - initialQuery: options.initialQuery, }); this.#browser.onActivate = item => this.#activateItem(item); this.#browser.onCancel = () => this.#callbacks.onCancel(); @@ -255,10 +251,6 @@ export class ModelHubComponent implements Component { this.#setActiveEntry("all"); } - if (this.#mode === "pick") { - this.#focus = "list"; - } - // Reconcile with cached discovery state in the background. A --models // scope is registry-independent, so the offline reload would only repeat // the synchronous hydration above. @@ -294,62 +286,10 @@ export class ModelHubComponent implements Component { return getKnownRoleIds(this.#settings).filter(role => !getRoleInfo(role, this.#settings).hidden); } - #getResolvedRoleThinkingLevel( - role: string, - resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel }, - ): ConfiguredThinkingLevel { - if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) { - return resolved.thinkingLevel; - } - if (role === "default") { - return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit; - } - return ThinkingLevel.Inherit; - } - /** Resolve every known role: configured values first, auto-selection for the rest. */ #reloadRoles(autoCandidates: ReadonlyArray): void { - const nextRoles: RoleAssignments = {}; - const allModels = this.#scopedModels.length > 0 ? [...autoCandidates] : this.#registry.getAll(); - const matchPreferences = getModelMatchPreferences(this.#settings); - const knownRoles = getKnownRoleIds(this.#settings); - const configuredRoles = new Set(); - - for (const role of knownRoles) { - const roleValue = this.#settings.getModelRole(role); - if (!roleValue) continue; - configuredRoles.add(role); - const resolved = resolveModelRoleValue(roleValue, allModels, { - settings: this.#settings, - matchPreferences, - }); - if (resolved.model) { - nextRoles[role] = { - model: resolved.model, - thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved), - autoSelected: false, - }; - } - } - - if (autoCandidates.length > 0) { - const candidates = [...autoCandidates]; - for (const role of knownRoles) { - if (configuredRoles.has(role)) continue; - const resolved = resolveModelRoleValue(`pi/${role}`, candidates, { - settings: this.#settings, - matchPreferences, - }); - if (!resolved.model) continue; - nextRoles[role] = { - model: resolved.model, - thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved), - autoSelected: true, - }; - } - } - - this.#roles = nextRoles; + const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll(); + this.#roles = resolveRoleAssignments(this.#settings, allModels, autoCandidates); } /** Rebuild items, roles, and the sidebar from the registry's in-memory state. */ @@ -373,12 +313,15 @@ export class ModelHubComponent implements Component { } this.#reloadRoles(availableModels); + this.#buildRolesRows(); - const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; + const storage = this.#settings.getStorage(); + const mruOrder = storage?.getModelUsageOrder() ?? []; this.#availableItems = buildBrowserItems(availableModels); sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder }); this.#browser.setRoles(this.#roles); this.#browser.setMruOrder(mruOrder); + this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map()); const bySelector = new Map(this.#availableItems.map(item => [item.selector, item])); this.#recentItems = []; @@ -438,7 +381,7 @@ export class ModelHubComponent implements Component { label: providerId, providerId, locked: isLocked, - annotation: isLocked ? "login" : String(availableCounts.get(providerId) ?? 0), + annotation: isLocked ? undefined : String(availableCounts.get(providerId) ?? 0), oauth: oauthIds.has(providerId), catalogCount: catalogCounts.get(providerId) ?? 0, }); @@ -452,16 +395,15 @@ export class ModelHubComponent implements Component { // Roles leads the fixed section so downward hops from Recent head into // model scopes instead of being captured by the roles view. - const fixed: SidebarEntry[] = []; - if (this.#mode === "roles") { - fixed.push({ + const fixed: SidebarEntry[] = [ + { id: "roles", kind: "roles", label: "Roles", annotation: `${assignedCount}/${visibleRoles.length}`, - }); - } - fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) }); + }, + { id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) }, + ]; this.#fixedEntries = fixed; this.#unlockedProviderEntries = [...unlocked] @@ -502,6 +444,7 @@ export class ModelHubComponent implements Component { this.#entries = entries; if (!entries.some(entry => entry.id === this.#activeEntryId)) { this.#activeEntryId = "all"; + this.#sidebarFollowActive = true; } } @@ -512,6 +455,7 @@ export class ModelHubComponent implements Component { #setActiveEntry(id: string): void { if (!this.#entries.some(entry => entry.id === id)) return; this.#activeEntryId = id; + this.#sidebarFollowActive = true; this.#applyScope(); const entry = this.#activeEntry(); // Hops must never steal arrow focus: landing on a scope keeps provider @@ -545,7 +489,6 @@ export class ModelHubComponent implements Component { break; } case "roles": - this.#roleIds = this.#visibleRoleIds(); this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1)); break; default: @@ -555,6 +498,58 @@ export class ModelHubComponent implements Component { } } + /** + * The configured `retry.fallbackChains` record with malformed keys/entries + * dropped: non-array chains and non-string selectors never reach the rows + * or chain editors, so an edit through the hub replaces them wholesale. + */ + #fallbackChains(): Record { + try { + const chains = this.#settings.get("retry.fallbackChains"); + if (!chains || typeof chains !== "object" || Array.isArray(chains)) return {}; + const sanitized: Record = {}; + for (const key in chains) { + const chain = (chains as Record)[key]; + if (!Array.isArray(chain)) continue; + sanitized[key] = chain.filter((entry): entry is string => typeof entry === "string"); + } + return sanitized; + } catch { + return {}; + } + } + + /** + * Rebuild the Roles view rows: each visible role followed by its + * fallback-chain entries, then model-oriented chains (`provider/model-id` + * and `provider/*` keys) as headed groups. + */ + #buildRolesRows(): void { + const rows: RolesRow[] = []; + const chains = this.#fallbackChains(); + for (const role of this.#visibleRoleIds()) { + rows.push({ kind: "role", role }); + const chain = chains[role] ?? []; + for (let i = 0; i < chain.length; i++) { + rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] }); + } + } + rows.push({ kind: "newRole" }); + rows.push({ kind: "separator" }); + const modelKeys = Object.keys(chains) + .filter(key => key.includes("/")) + .sort(); + for (const key of modelKeys) { + const chain = chains[key] ?? []; + rows.push({ kind: "chainKey", role: key }); + for (let i = 0; i < chain.length; i++) { + rows.push({ kind: "fallback", role: key, chainIndex: i, selector: chain[i] }); + } + } + rows.push({ kind: "newFallback" }); + this.#rolesRows = rows; + } + /** Refresh roles + dependent state after a settings mutation (assign/unassign). */ #refreshAfterMutation(): void { this.#syncFromRegistryState(); @@ -588,7 +583,7 @@ export class ModelHubComponent implements Component { this.#composeEntries(); const entry = this.#activeEntry(); if ( - this.#assigningRole === null && + this.#assigning === null && entry.kind === "provider" && (entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0) ) { @@ -735,14 +730,16 @@ export class ModelHubComponent implements Component { // ═══════════════════════════════════════════════════════════════════════ #activateItem(item: ModelBrowserItem): void { - if (this.#mode === "pick") { - this.#callbacks.onPick?.(item.model, item.selector); - return; - } - if (this.#assigningRole) { - const role = this.#assigningRole; - this.#assigningRole = null; - this.#assignRole(item, role, true); + if (this.#assigning) { + const target = this.#assigning; + this.#assigning = null; + if (target.kind === "role") { + this.#assignRole(item, target.role, true); + } else if (target.kind === "fallbackKey") { + this.#openFallbackKeyStrip(item); + } else { + this.#commitFallback(item, target); + } return; } this.#openRoleStrip(item); @@ -756,7 +753,7 @@ export class ModelHubComponent implements Component { const supported = this.#thinkingOptionsFor(item.model); level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit; } - this.#callbacks.onAssign(item.model, role, level, item.selector, "modelRole"); + this.#callbacks.onAssign(item.model, role, level, item.selector); this.#refreshAfterMutation(); this.#openThinkingStrip(item, role, returnToRoles); } @@ -793,6 +790,16 @@ export class ModelHubComponent implements Component { action: assignedHere ? "unassign" : "assign", }); } + chips.push({ + label: `fallbacks:${item.model.id}`, + styled: theme.fg("muted", `fallbacks:${item.model.id}`), + action: "fallbackModel", + }); + chips.push({ + label: `fallbacks:${item.model.provider}/*`, + styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`), + action: "fallbackProvider", + }); chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" }); this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false }; } @@ -825,7 +832,7 @@ export class ModelHubComponent implements Component { const strip = this.#strip; this.#strip = null; this.#chipRanges = []; - if (strip?.kind === "thinking" && strip.returnToRoles && this.#mode === "roles") { + if (strip?.kind === "thinking" && strip.returnToRoles) { this.#setActiveEntry("roles"); this.#focus = "list"; } @@ -851,18 +858,20 @@ export class ModelHubComponent implements Component { this.#closeStrip(); return; case "fallback": - this.#callbacks.onAssign(strip.item.model, "default", undefined, strip.item.selector, "retryFallback"); + this.#appendFallback(strip.item, "default"); this.#closeStrip(); return; + case "fallbackModel": + this.#closeStrip(); + this.#startAssignFallback(strip.item.selector, null); + return; + case "fallbackProvider": + this.#closeStrip(); + this.#startAssignFallback(`${strip.item.model.provider}/*`, null); + return; case "thinking": if (strip.role && chip.thinkingLevel !== undefined) { - this.#callbacks.onAssign( - strip.item.model, - strip.role, - chip.thinkingLevel, - strip.item.selector, - "modelRole", - ); + this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector); this.#refreshAfterMutation(); } this.#closeStrip(); @@ -872,7 +881,7 @@ export class ModelHubComponent implements Component { /** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */ #startAssign(role: string): void { - this.#assigningRole = role; + this.#assigning = { kind: "role", role }; this.#focus = "scope"; this.#browser.setShowProvider(true); this.#browser.setItems([...this.#availableItems]); @@ -883,15 +892,107 @@ export class ModelHubComponent implements Component { } } - #cancelAssign(): void { - this.#assigningRole = null; + /** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */ + #startAssignFallback(role: string, index: number | null): void { + this.#assigning = { kind: "fallback", role, index }; + this.#focus = "scope"; + this.#browser.setShowProvider(true); + this.#browser.setItems([...this.#availableItems]); this.#browser.setQuery(""); - if (this.#mode === "roles") { - this.#setActiveEntry("roles"); - this.#focus = "list"; + if (index !== null) { + const selector = this.#fallbackChains()[role]?.[index]; + if (selector) this.#browser.selectSelector(selector); } } + /** Browse the catalog for the primary model a brand-new fallback chain protects. */ + #startAssignFallbackKey(): void { + this.#assigning = { kind: "fallbackKey" }; + this.#focus = "scope"; + this.#browser.setShowProvider(true); + this.#browser.setItems([...this.#availableItems]); + this.#browser.setQuery(""); + } + + /** Second step of "+ New fallback…": key the chain by the picked model or its whole provider. */ + #openFallbackKeyStrip(item: ModelBrowserItem): void { + const chips: StripChip[] = [ + { + label: `for ${item.selector}`, + styled: theme.fg("muted", `for ${item.selector}`), + action: "fallbackModel", + }, + { + label: `for ${item.model.provider}/*`, + styled: theme.fg("muted", `for ${item.model.provider}/*`), + action: "fallbackProvider", + }, + ]; + this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false }; + } + + /** Write the picked model into the target chain slot, dedupe, and land back on its Roles row. */ + #commitFallback(item: ModelBrowserItem, target: { role: string; index: number | null }): void { + const chain = [...(this.#fallbackChains()[target.role] ?? [])]; + const selector = item.selector; + if (target.index !== null && target.index < chain.length) { + chain[target.index] = selector; + for (let i = chain.length - 1; i >= 0; i--) { + if (i !== target.index && chain[i] === selector) chain.splice(i, 1); + } + } else if (!chain.includes(selector)) { + chain.push(selector); + } + this.#setFallbackChain(target.role, chain); + this.#browser.setQuery(""); + this.#setActiveEntry("roles"); + this.#focus = "list"; + const rowIndex = this.#rolesRows.findIndex( + row => row.kind === "fallback" && row.role === target.role && row.selector === selector, + ); + if (rowIndex >= 0) this.#roleIndex = rowIndex; + } + + /** Persist `role`'s chain through the host callback and rebuild dependent state. */ + #setFallbackChain(role: string, chain: string[]): void { + this.#callbacks.onFallbackChainChange?.(role, chain); + this.#refreshAfterMutation(); + } + + /** Append `item` to `role`'s fallback chain (no-op when already present). */ + #appendFallback(item: ModelBrowserItem, role: string): void { + const chain = [...(this.#fallbackChains()[role] ?? [])]; + if (chain.includes(item.selector)) return; + chain.push(item.selector); + this.#setFallbackChain(role, chain); + } + + /** Remove one chain entry; the cursor stays on the nearest surviving row. */ + #removeFallback(row: { role: string; chainIndex: number }): void { + const chain = [...(this.#fallbackChains()[row.role] ?? [])]; + if (row.chainIndex >= chain.length) return; + chain.splice(row.chainIndex, 1); + this.#setFallbackChain(row.role, chain); + this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRows.length - 1)); + } + + /** Move a chain entry one slot earlier/later; the cursor follows the moved entry. */ + #moveFallback(row: { role: string; chainIndex: number }, delta: -1 | 1): void { + const chain = [...(this.#fallbackChains()[row.role] ?? [])]; + const target = row.chainIndex + delta; + if (row.chainIndex >= chain.length || target < 0 || target >= chain.length) return; + [chain[row.chainIndex], chain[target]] = [chain[target], chain[row.chainIndex]]; + this.#setFallbackChain(row.role, chain); + this.#roleIndex += delta; + } + + #cancelAssign(): void { + this.#assigning = null; + this.#browser.setQuery(""); + this.#setActiveEntry("roles"); + this.#focus = "list"; + } + // ═══════════════════════════════════════════════════════════════════════ // Quick-switch cycle (ctrl+p) editing // ═══════════════════════════════════════════════════════════════════════ @@ -961,7 +1062,7 @@ export class ModelHubComponent implements Component { } if (matchesSelectCancel(data)) { - if (this.#assigningRole !== null) { + if (this.#assigning !== null) { this.#cancelAssign(); return; } @@ -975,8 +1076,8 @@ export class ModelHubComponent implements Component { } const entry = this.#activeEntry(); - const rolesView = entry.kind === "roles" && this.#assigningRole === null; - const lockedView = entry.kind === "provider" && entry.locked && this.#assigningRole === null; + const rolesView = entry.kind === "roles" && this.#assigning === null; + const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null; if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { this.#focus = this.#focus === "scope" ? "list" : "scope"; @@ -1030,7 +1131,7 @@ export class ModelHubComponent implements Component { } #isBrowserView(entry: SidebarEntry): boolean { - if (this.#assigningRole !== null) return true; + if (this.#assigning !== null) return true; return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked); } @@ -1074,16 +1175,58 @@ export class ModelHubComponent implements Component { if (entry && !this.#isHopSkipped(entry)) { // Scope changes keep an active assignment (scoping helps find the // model); landing on the Roles view cancels it. - if (entry.kind === "roles") this.#assigningRole = null; + if (entry.kind === "roles") this.#assigning = null; this.#setActiveEntry(entry.id); return; } } } - /** Row count of the roles view: every visible role plus the trailing "+ New role…" row. */ + /** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */ get #rolesRowCount(): number { - return this.#roleIds.length + 1; + return this.#rolesRows.length; + } + + /** Enter/click activation for a Roles-view row. */ + #activateRolesRow(row: RolesRow): void { + switch (row.kind) { + case "role": + this.#startAssign(row.role); + return; + case "chainKey": + this.#startAssignFallback(row.role, null); + return; + case "fallback": + this.#startAssignFallback(row.role, row.chainIndex); + return; + case "newFallback": + this.#startAssignFallbackKey(); + return; + case "newRole": + this.#openRoleNameStrip(); + return; + case "separator": + return; + } + } + + /** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */ + #stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number { + const wrap = options.wrap ?? true; + const count = this.#rolesRows.length; + if (count === 0) return 0; + let index = from; + for (let i = 0; i < count; i++) { + const next = index + delta; + if (next < 0 || next >= count) { + if (!wrap) return from; + index = (next + count) % count; + } else { + index = next; + } + if (this.#rolesRows[index]?.kind !== "separator") return index; + } + return from; } #handleRolesViewInput(data: string): void { @@ -1095,41 +1238,50 @@ export class ModelHubComponent implements Component { } return; } - const rowCount = Math.max(1, this.#rolesRowCount); if (matchesSelectUp(data)) { - this.#roleIndex = (this.#roleIndex - 1 + rowCount) % rowCount; + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1); return; } if (matchesSelectDown(data)) { - this.#roleIndex = (this.#roleIndex + 1) % rowCount; + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1); return; } - const role = this.#roleIds[this.#roleIndex]; + const row = this.#rolesRows[this.#roleIndex]; + const role = row?.kind === "role" ? row.role : undefined; if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - if (role) { - this.#startAssign(role); - } else { - // The virtual "+ New role…" row. - this.#openRoleNameStrip(); - } + if (row) this.#activateRolesRow(row); return; } if (matchesKey(data, "backspace") || matchesKey(data, "delete")) { if (role) this.#unassignRole(role); + else if (row?.kind === "fallback") this.#removeFallback(row); + else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []); return; } - // Cycle reordering: [ / shift+↑ moves the role earlier, ] / shift+↓ later. + // Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later — + // cycle order on a role row, chain order on a fallback row. if (matchesKey(data, "shift+up")) { if (role) this.#moveCycleMembership(role, -1); + else if (row?.kind === "fallback") this.#moveFallback(row, -1); return; } if (matchesKey(data, "shift+down")) { if (role) this.#moveCycleMembership(role, 1); + else if (row?.kind === "fallback") this.#moveFallback(row, 1); return; } const printable = extractPrintableText(data); if (printable === "x") { if (role) this.#unassignRole(role); + else if (row?.kind === "fallback") this.#removeFallback(row); + else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []); + return; + } + if (printable === "f") { + if (row?.kind === "newFallback") this.#startAssignFallbackKey(); + else if (row && row.kind !== "newRole" && row.kind !== "separator") { + this.#startAssignFallback(row.role, null); + } return; } if (printable === "c") { @@ -1138,10 +1290,12 @@ export class ModelHubComponent implements Component { } if (printable === "[") { if (role) this.#moveCycleMembership(role, -1); + else if (row?.kind === "fallback") this.#moveFallback(row, -1); return; } if (printable === "]") { if (role) this.#moveCycleMembership(role, 1); + else if (row?.kind === "fallback") this.#moveFallback(row, 1); return; } if (printable === "n") { @@ -1202,11 +1356,13 @@ export class ModelHubComponent implements Component { if (event.wheel !== null) { if (overSidebar) { - this.#moveSidebar(event.wheel); + // Wheel pans the sidebar viewport; picking a scope is click/keys only. + const maxScroll = Math.max(0, this.#entries.length - this.#contentRowCount); + this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll + event.wheel, maxScroll)); + this.#sidebarHover = this.#sidebarEntryIndexAt(contentLine); } else if (overBody) { - if (entry.kind === "roles" && this.#assigningRole === null) { - const count = Math.max(1, this.#rolesRowCount); - this.#roleIndex = (this.#roleIndex + event.wheel + count) % count; + if (entry.kind === "roles" && this.#assigning === null) { + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false }); } else if (this.#isBrowserView(entry)) { this.#browser.routeMouse(event, bodyLine); } @@ -1216,13 +1372,17 @@ export class ModelHubComponent implements Component { if (event.motion) { this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null; - if (overBody && entry.kind === "roles" && this.#assigningRole === null) { + if (overBody && entry.kind === "roles" && this.#assigning === null) { const roleLine = bodyLine - this.#rolesRowStart; this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null; } else { this.#roleHover = null; if (overBody && this.#isBrowserView(entry)) { this.#browser.routeMouse(event, bodyLine); + } else { + // Pointer left the browser pane: without this, the last + // hovered row keeps its band while the sidebar hovers too. + this.#browser.clearHover(); } } return true; @@ -1235,7 +1395,7 @@ export class ModelHubComponent implements Component { const clicked = index !== null ? this.#entries[index] : undefined; if (clicked && clicked.kind !== "separator") { const already = clicked.id === this.#activeEntryId; - if (clicked.kind === "roles") this.#assigningRole = null; + if (clicked.kind === "roles") this.#assigning = null; this.#setActiveEntry(clicked.id); // A click on Roles is a deliberate dive into the rows. if (clicked.kind === "roles") this.#focus = "list"; @@ -1247,22 +1407,20 @@ export class ModelHubComponent implements Component { } if (overBody) { - if (entry.kind === "roles" && this.#assigningRole === null) { + if (entry.kind === "roles" && this.#assigning === null) { this.#focus = "list"; const roleLine = bodyLine - this.#rolesRowStart; if (roleLine >= 0 && roleLine < this.#rolesRowCount) { - if (roleLine === this.#roleIndex) { - const role = this.#roleIds[roleLine]; - if (role) { - this.#startAssign(role); + const rowDef = this.#rolesRows[roleLine]; + if (rowDef && rowDef.kind !== "separator") { + if (roleLine === this.#roleIndex) { + this.#activateRolesRow(rowDef); } else { - this.#openRoleNameStrip(); + this.#roleIndex = roleLine; } - } else { - this.#roleIndex = roleLine; } } - } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) { + } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) { if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) { this.#requestLogin(entry); } @@ -1294,15 +1452,22 @@ export class ModelHubComponent implements Component { } #renderSidebar(width: number, rows: number): string[] { - const activeIndex = Math.max( - 0, - this.#entries.findIndex(entry => entry.id === this.#activeEntryId), - ); - if (this.#entries.length > rows) { - this.#sidebarScroll = Math.max(0, Math.min(activeIndex - Math.floor(rows / 2), this.#entries.length - rows)); - } else { - this.#sidebarScroll = 0; + // The scroll offset is persistent: the wheel pans it freely. Only an + // activation (keys, click, programmatic) snaps the viewport to the + // active entry, and only far enough to reveal it. + if (this.#sidebarFollowActive) { + const activeIndex = Math.max( + 0, + this.#entries.findIndex(entry => entry.id === this.#activeEntryId), + ); + if (activeIndex < this.#sidebarScroll) { + this.#sidebarScroll = activeIndex; + } else if (activeIndex >= this.#sidebarScroll + rows) { + this.#sidebarScroll = activeIndex - rows + 1; + } + this.#sidebarFollowActive = false; } + this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll, Math.max(0, this.#entries.length - rows))); const lines: string[] = []; for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) { @@ -1328,11 +1493,10 @@ export class ModelHubComponent implements Component { // While searching, entries the hop skips gray out: locked and // zero-match providers, an empty Recent, and the Roles view. const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles"); - const cursor = active - ? this.#focus === "scope" - ? theme.fg("accent", theme.nav.cursor) - : theme.fg("dim", theme.nav.cursor) - : " "; + // The sidebar's active entry is state, not a cursor: accent label + // plus a cursor glyph while the sidebar owns the arrows. The band + // stays in the body pane so the two never look alike. + const cursor = active && this.#focus === "scope" ? theme.fg("accent", theme.nav.cursor) : " "; let icon: string; if (entry.kind === "recent") { @@ -1347,7 +1511,7 @@ export class ModelHubComponent implements Component { const labelStyled = muted ? theme.fg("dim", entry.label) : active - ? theme.fg("accent", entry.label) + ? theme.bold(theme.fg("accent", entry.label)) : entry.label; const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false; @@ -1364,8 +1528,10 @@ export class ModelHubComponent implements Component { line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`; } else { line = truncateToWidth(left, width); + const lineWidth = visibleWidth(line); + if (lineWidth < width) line += " ".repeat(width - lineWidth); } - if (hovered && !active) { + if (hovered) { line = theme.bg("selectedBg", line); } lines.push(line); @@ -1374,9 +1540,22 @@ export class ModelHubComponent implements Component { } #statusRow(width: number): string { - if (this.#assigningRole !== null) { - const info = getRoleInfo(this.#assigningRole, this.#settings); - const label = info.tag ?? info.name ?? this.#assigningRole; + if (this.#assigning !== null) { + if (this.#assigning.kind === "fallbackKey") { + return truncateToWidth( + theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"), + width, + ); + } + const info = getRoleInfo(this.#assigning.role, this.#settings); + const label = info.tag ?? info.name ?? this.#assigning.role; + if (this.#assigning.kind === "fallback") { + const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of"; + return truncateToWidth( + theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`), + width, + ); + } return truncateToWidth( theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`), width, @@ -1387,10 +1566,10 @@ export class ModelHubComponent implements Component { let text: string; switch (entry.kind) { case "recent": - text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`; + text = `Recently used models${scopedSuffix}`; break; case "roles": - text = "Model roles — assignments fall back to auto-selection when cleared"; + text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection"; break; case "provider": if (entry.locked) { @@ -1402,7 +1581,7 @@ export class ModelHubComponent implements Component { } break; default: - text = this.#mode === "pick" ? this.#pickerHint : `All available models${scopedSuffix}`; + text = `All available models${scopedSuffix}`; break; } if (this.#configError && entry.kind !== "provider") { @@ -1412,25 +1591,78 @@ export class ModelHubComponent implements Component { return truncateToWidth(theme.fg("muted", ` ${text}`), width); } + /** Clamp a roles row to `width`; the bg band is reserved for mouse hover. */ + #finishRolesRow(line: string, width: number, hovered: boolean): string { + let out = truncateToWidth(line, width); + if (hovered) { + const w = visibleWidth(out); + if (w < width) out += " ".repeat(width - w); + return theme.bg("selectedBg", out); + } + return out; + } + #renderRolesView(width: number, rows: number): string[] { const lines: string[] = []; lines.push(""); - this.#rolesRowStart = lines.length + 1; // +1 for the status row offset handled by caller + // First row's offset in bodyLine coordinates: the mouse router's + // `bodyLine` has already dropped the status row, so this is just the + // leading blank line — no extra status-row offset here. + this.#rolesRowStart = lines.length; let tagWidth = 0; - for (const role of this.#roleIds) { - const info = getRoleInfo(role, this.#settings); - tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? role)); + for (const rowDef of this.#rolesRows) { + if (rowDef.kind !== "role") continue; + const info = getRoleInfo(rowDef.role, this.#settings); + tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role)); } const cycleOrder = this.#cycleOrder(); - for (let i = 0; i < this.#roleIds.length && lines.length < rows - 3; i++) { - const role = this.#roleIds[i]; - const info = getRoleInfo(role, this.#settings); - const assignment = this.#roles[role]; + const listFocused = this.#focus === "list"; + for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) { + const rowDef = this.#rolesRows[i]; + if (!rowDef) continue; const selected = i === this.#roleIndex; const hovered = i === this.#roleHover; - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; + // The unfocused pane draws no cursor; accent text still marks the row. + const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " "; + + if (rowDef.kind === "separator") { + lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`); + continue; + } + + if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") { + const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…"; + let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`; + line = this.#finishRolesRow(line, width, hovered); + lines.push(line); + continue; + } + + if (rowDef.kind === "chainKey") { + const key = rowDef.role; + const slash = key.lastIndexOf("/"); + const tail = key.slice(slash + 1); + const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail); + let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`; + line = this.#finishRolesRow(line, width, hovered); + lines.push(line); + continue; + } + + if (rowDef.kind === "fallback") { + const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`); + const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector); + let line = ` ${cursor} ${branch} ${selector}`; + line = this.#finishRolesRow(line, width, hovered); + lines.push(line); + continue; + } + + const role = rowDef.role; + const info = getRoleInfo(role, this.#settings); + const assignment = this.#roles[role]; const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth); let dot: string; @@ -1466,26 +1698,8 @@ export class ModelHubComponent implements Component { const lineWidth = visibleWidth(line); if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) { line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`; - } else { - line = truncateToWidth(line, width); - } - if (hovered && !selected) { - line = theme.bg("selectedBg", line); - } - lines.push(line); - } - - // Trailing virtual row: create a custom role. - if (lines.length < rows - 2) { - const newRoleIndex = this.#roleIds.length; - const selected = this.#roleIndex === newRoleIndex; - const hovered = this.#roleHover === newRoleIndex; - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New role…")}`; - line = truncateToWidth(line, width); - if (hovered && !selected) { - line = theme.bg("selectedBg", line); } + line = this.#finishRolesRow(line, width, hovered); lines.push(line); } @@ -1495,7 +1709,10 @@ export class ModelHubComponent implements Component { if (rows >= 2) { const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p"; if (cycleOrder.length > 0) { - const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? ""); + const selectedRow = this.#rolesRows[this.#roleIndex]; + const selectedRole = + selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : ""; + const activeIndex = cycleOrder.indexOf(selectedRole); const track = renderSegmentTrack( cycleOrder.map(role => ({ label: role })), activeIndex, @@ -1557,23 +1774,38 @@ export class ModelHubComponent implements Component { ? "←/→ choose · Enter assign/clear · Esc cancel" : "←/→ thinking level · Enter apply · Esc keep"; } - if (this.#assigningRole !== null) { - return "Enter assign · ↑/↓ providers · type to search · Esc cancel"; + if (this.#assigning !== null) { + switch (this.#assigning.kind) { + case "fallback": + return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel"; + case "fallbackKey": + return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel"; + default: + return "Enter assign · ↑/↓ providers · type to search · Esc cancel"; + } } const entry = this.#activeEntry(); if (entry.kind === "roles") { - return this.#focus === "list" - ? "↑/↓ roles · Enter pick · x clear · t thinking · c cycle · [/] reorder · n new · ← providers" - : "↑/↓ providers · → roles · Esc close"; + if (this.#focus !== "list") { + return "↑/↓ providers · → roles · Esc close"; + } + const row = this.#rolesRows[this.#roleIndex]; + if (row?.kind === "fallback") { + return "↑/↓ rows · Enter replace · f add another · x remove · [/] reorder · ← providers"; + } + if (row?.kind === "chainKey") { + return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers"; + } + if (row?.kind === "newFallback") { + return "↑/↓ rows · Enter new model/provider fallback chain · ← providers"; + } + return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new"; } if (entry.kind === "provider" && entry.locked) { return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close"; } const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers"; const refresh = entry.kind === "provider" ? " · F5 refresh" : ""; - if (this.#mode === "pick") { - return `Enter use for this session · ${arrows} · type to search${refresh} · Esc close`; - } return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`; } @@ -1597,10 +1829,38 @@ export class ModelHubComponent implements Component { ? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} ` : `${theme.fg(getRoleInfo(strip.role ?? "", this.#settings).color ?? "muted", (getRoleInfo(strip.role ?? "", this.#settings).tag ?? strip.role ?? "").toLowerCase())}${theme.fg("dim", ` · ${strip.item.id} →`)} `; + // Horizontal window: once the strip overflows, drop leading chips behind + // a dim ellipsis so the selected chip (plus one chip of lookahead when it + // fits) stays visible while cycling right. + const prefixWidth = visibleWidth(prefix); + const available = Math.max(1, width - prefixWidth); + const chipWidths = strip.chips.map( + (chip, i) => visibleWidth(` ${chip.styled} `) + (i === strip.index ? 2 : 0) + 1, + ); + // Smallest start index whose window [start..target] (with its "… " lead-in + // when start > 0) fits in the available width; `target` itself may still + // overflow when a single chip is wider than the row. + const startFor = (target: number): number => { + let start = 0; + while (start < target) { + let sum = start > 0 ? 2 : 0; + for (let i = start; i <= target; i++) sum += chipWidths[i] ?? 0; + if (sum <= available) break; + start++; + } + return start; + }; + let start = startFor(Math.min(strip.index + 1, strip.chips.length - 1)); + if (start > strip.index) start = startFor(strip.index); + let line = prefix; // Columns are relative to the frame: row() insets content by 2. - let col = 2 + visibleWidth(prefix); - for (let i = 0; i < strip.chips.length; i++) { + let col = 2 + prefixWidth; + if (start > 0) { + line += theme.fg("dim", "… "); + col += 2; + } + for (let i = start; i < strip.chips.length; i++) { const chip = strip.chips[i]; if (!chip) continue; const selected = i === strip.index; @@ -1628,20 +1888,20 @@ export class ModelHubComponent implements Component { const entry = this.#activeEntry(); const bodyLines: string[] = [this.#statusRow(bodyWidth)]; - if (entry.kind === "roles" && this.#assigningRole === null) { + if (entry.kind === "roles" && this.#assigning === null) { bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1)); - } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) { + } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) { bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1)); } else { this.#browser.setMaxVisible(contentRows - 1 - 5); + this.#browser.setFocused(this.#focus === "list"); bodyLines.push(...this.#browser.render(bodyWidth)); } const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows); - const title = this.#mode === "pick" ? "Switch Model" : "Models"; const out: string[] = []; - out.push(topBorderSplit(width, title, sidebarWidth)); + out.push(topBorderSplit(width, "Models", sidebarWidth)); this.#contentRowStart = out.length; for (let i = 0; i < contentRows; i++) { out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth)); diff --git a/packages/coding-agent/src/modes/components/model-picker.ts b/packages/coding-agent/src/modes/components/model-picker.ts new file mode 100644 index 000000000..71301c276 --- /dev/null +++ b/packages/coding-agent/src/modes/components/model-picker.ts @@ -0,0 +1,233 @@ +/** + * Compact session-model picker (alt+p / `/switch`): a bottom-anchored + * floating overlay hosting just a {@link ModelBrowser} — no provider sidebar. + * Model entries switch the current session only; a search beginning with `@` + * exposes the configured ctrl+p quick roles. + */ +import type { Model } from "@oh-my-pi/pi-ai"; +import type { Component, TUI } from "@oh-my-pi/pi-tui"; +import type { ModelRegistry } from "../../config/model-registry"; +import type { Settings } from "../../config/settings"; +import type { ResolvedRoleModel } from "../../session/agent-session"; +import { theme } from "../theme/theme"; +import { + buildBrowserItems, + ModelBrowser, + type ModelBrowserItem, + resolveRoleAssignments, + sortModelItems, +} from "./model-browser"; +import type { ScopedModelItem } from "./model-hub"; +import { bottomBorder, row, topBorder } from "./overlay-box"; +import { resolveSegmentPalette } from "./segment-track"; + +export interface ModelPickerCallbacks { + /** A model was chosen for a session-only switch. `selector` is `provider/id`. */ + onPick: (model: Model, selector: string) => void; + /** A configured ctrl+p quick role was chosen. */ + onPickRole?: (entry: ResolvedRoleModel) => void; + /** The picker was dismissed. */ + onCancel: () => void; +} + +export interface ModelPickerOptions { + /** Session token count; models with smaller context windows are disabled. */ + currentContextTokens?: number; + /** `provider/id` of the session's active model; highlighted and preselected. */ + currentSelector?: string; + /** Resolved role models in the same order used by the ctrl+p quick-role cycle. */ + quickRoles?: ReadonlyArray; + /** Complete ctrl+p order, including unavailable roles, to preserve segment colors. */ + quickRoleOrder?: ReadonlyArray; + /** Active quick role, highlighted when the search begins with `@`. */ + currentQuickRole?: string; +} + +/** Fixed chrome rows: top border, status row, footer, bottom border. */ +const CHROME_ROWS = 4; +/** Rows the browser renders around its list window (search + blank, blank + two detail rows). */ +const BROWSER_FRAME_ROWS = 5; +/** Minimum rows for the browser list window on short terminals. */ +const MIN_VISIBLE = 5; +/** Fraction of the terminal height the floating overlay occupies. */ +const HEIGHT_FRACTION = 0.4; + +const STATUS_HINT = "Session-only switch — role models stay unchanged"; +const QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session"; +const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · @ quick roles · Esc close"; +const QUICK_ROLE_FOOTER_HINT = "↑/↓ roles · Enter apply role model · type to search · Esc close"; + +/** + * The alt+p picker component. Hosted as a non-fullscreen bottom-anchored + * overlay (`ui.showOverlay(..., { anchor: "bottom-center" })`); keyboard-only, + * since mouse tracking is reserved for fullscreen overlays. + */ +export class ModelPickerComponent implements Component { + #tui: TUI; + #settings: Settings; + #registry: ModelRegistry; + #scopedModels: ReadonlyArray; + #browser: ModelBrowser; + #configError: string | undefined; + #currentSelector: string | undefined; + #currentQuickRoleSelector: string | undefined; + #modelItems: ModelBrowserItem[] = []; + #quickRoleItems: ModelBrowserItem[] = []; + #quickRoles = new Map(); + #roleMode = false; + + constructor( + tui: TUI, + settings: Settings, + registry: ModelRegistry, + scopedModels: ReadonlyArray, + callbacks: ModelPickerCallbacks, + options: ModelPickerOptions = {}, + ) { + this.#tui = tui; + this.#settings = settings; + this.#registry = registry; + this.#scopedModels = scopedModels; + this.#currentSelector = options.currentSelector; + this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined; + this.#quickRoleItems = this.#buildQuickRoleItems( + options.quickRoles ?? [], + options.quickRoleOrder ?? options.quickRoles?.map(entry => entry.role) ?? [], + ); + + this.#browser = new ModelBrowser(settings, { + currentContextTokens: options.currentContextTokens, + disableOverContext: true, + emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined), + }); + this.#browser.onActivate = item => { + const quickRole = this.#quickRoles.get(item.selector); + if (quickRole) { + callbacks.onPickRole?.(quickRole); + return; + } + callbacks.onPick(item.model, item.selector); + }; + this.#browser.onCancel = () => callbacks.onCancel(); + this.#browser.onQueryChange = query => this.#syncItemsForQuery(query); + + // Hydrate synchronously from the current registry snapshot so the first + // Enter after opening acts on cached models instead of being dropped + // while the offline refresh promise is still pending. + this.#syncFromRegistryState(); + if (options.currentSelector) { + this.#browser.selectSelector(options.currentSelector); + } + + // Reconcile with cached discovery state in the background. A --models + // scope is registry-independent, so the offline reload would only repeat + // the synchronous hydration above. + if (this.#scopedModels.length === 0) { + this.#registry + .refresh("offline") + .then(() => this.#syncFromRegistryState()) + .catch(error => { + this.#configError = error instanceof Error ? error.message : String(error); + }) + .finally(() => this.#tui.requestRender()); + } + } + + invalidate(): void {} + + /** Rebuild model items and role chips from the registry's in-memory state. */ + #syncFromRegistryState(): void { + let models: ReadonlyArray; + if (this.#scopedModels.length > 0) { + models = this.#scopedModels.map(scoped => scoped.model); + this.#configError = undefined; + } else { + const loadError = this.#registry.getError(); + this.#configError = loadError ? String(loadError) : undefined; + try { + models = this.#registry.getAvailable(); + } catch (error) { + this.#configError = error instanceof Error ? error.message : String(error); + models = []; + } + } + + const allModels = this.#scopedModels.length > 0 ? models : this.#registry.getAll(); + const roles = resolveRoleAssignments(this.#settings, allModels, models); + const storage = this.#settings.getStorage(); + const mruOrder = storage?.getModelUsageOrder() ?? []; + this.#modelItems = buildBrowserItems(models); + sortModelItems(this.#modelItems, { roles, mruOrder }); + this.#browser.setRoles(roles); + this.#browser.setMruOrder(mruOrder); + this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map()); + this.#syncItemsForQuery(this.#browser.query, true); + } + + /** Build virtual `@role` rows, colored by their ctrl+p segment position. */ + #buildQuickRoleItems( + quickRoles: ReadonlyArray, + quickRoleOrder: ReadonlyArray, + ): ModelBrowserItem[] { + const order = quickRoleOrder.length > 0 ? quickRoleOrder : quickRoles.map(entry => entry.role); + const palette = resolveSegmentPalette(order.length); + return quickRoles.map((entry, index) => { + const selector = `@${entry.role}`; + this.#quickRoles.set(selector, entry); + const orderIndex = order.indexOf(entry.role); + return { + provider: "", + id: selector, + model: entry.model, + selector, + labelColor: palette[(orderIndex >= 0 ? orderIndex : index) % palette.length], + }; + }); + } + + /** Switch browser content only when a leading `@` changes the search mode. */ + #syncItemsForQuery(query: string, refresh = false): void { + const roleMode = query.startsWith("@"); + const modeChanged = roleMode !== this.#roleMode; + if (!modeChanged && !refresh) return; + + this.#roleMode = roleMode; + this.#browser.setShowProvider(!roleMode); + this.#browser.setDisableOverContext(!roleMode); + this.#browser.setPreserveQueryOrder(roleMode); + const currentSelector = roleMode ? this.#currentQuickRoleSelector : this.#currentSelector; + this.#browser.setCurrentSelector(currentSelector); + this.#browser.setItems(roleMode ? this.#quickRoleItems : this.#modelItems); + if (modeChanged && currentSelector) { + this.#browser.selectSelector(currentSelector); + } + } + + handleInput(data: string): void { + // Mouse tracking is off outside fullscreen overlays; drop any stray SGR + // reports instead of feeding them to the search input. + if (data.startsWith("\x1b[<")) return; + this.#browser.handleInput(data); + } + + render(width: number): string[] { + const termRows = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40); + const listBudget = Math.floor(termRows * HEIGHT_FRACTION) - CHROME_ROWS - BROWSER_FRAME_ROWS; + this.#browser.setMaxVisible(Math.max(MIN_VISIBLE, listBudget)); + + const inner = Math.max(1, width - 4); + const status = this.#configError + ? theme.fg("error", ` ${this.#configError}`) + : theme.fg("muted", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`); + + const out: string[] = []; + out.push(topBorder(width, "Switch Model")); + out.push(row(status, width)); + for (const line of this.#browser.render(inner)) { + out.push(row(line, width)); + } + out.push(row(theme.fg("dim", this.#roleMode ? QUICK_ROLE_FOOTER_HINT : FOOTER_HINT), width)); + out.push(bottomBorder(width)); + return out; + } +} diff --git a/packages/coding-agent/src/modes/components/plan-review-overlay.ts b/packages/coding-agent/src/modes/components/plan-review-overlay.ts index 11f7a96d2..4d286cab6 100644 --- a/packages/coding-agent/src/modes/components/plan-review-overlay.ts +++ b/packages/coding-agent/src/modes/components/plan-review-overlay.ts @@ -82,6 +82,8 @@ export interface PlanReviewOverlayCallbacks { onPick: (label: string) => void; /** Invoked on Esc / cancel. */ onCancel: () => void; + /** Invoked with the current full plan text when the copy hotkey is pressed. */ + onCopyPlan?: (content: string) => void | Promise; /** Invoked when the external-editor key is pressed (overlay stays open). */ onExternalEditor?: () => void; /** Invoked when the external-editor key edits the active annotation draft. */ @@ -302,6 +304,10 @@ export class PlanReviewOverlay implements Component { this.callbacks.onExternalEditor(); return; } + if (this.callbacks.onCopyPlan && keyData === "c") { + void this.callbacks.onCopyPlan(joinPlanSections(this.#sections)); + return; + } if (matchesKey(keyData, "tab") || keyData === "\t") { this.#cycleRegion(1); return; @@ -677,6 +683,7 @@ export class PlanReviewOverlay implements Component { parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends"); break; } + if (this.callbacks.onCopyPlan) parts.push("c copy"); parts.push("tab regions"); if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`); parts.push(this.#helpSuffix); diff --git a/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md b/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md index 0fd41c895..cb68b772b 100644 --- a/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md +++ b/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md @@ -1,18 +1,14 @@ -# User ¶ -Fix the settings overlay crash. Wheeling past the last row throws. +¶user:Fix the settings overlay crash. Wheeling past the last row throws. -# Tool call ¶ -//Reading the select-list hit test -read(path="src/select-list.ts:140-180") +¶call:read(path="src/select-list.ts:140-180")//Reading the select-list hit test 162: const index = Math.floor(line / rowHeight); index is never checked against bounds. -# Assistant ¶ -Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash. +¶ai:Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash. -# User ¶ -Does the fix survive filtering? +¶user:Does the fix survive filtering? -# Assistant ¶ -Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw. +¶think:Check whether the clamp runs before or after filtering. + +¶ai:Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw. diff --git a/packages/coding-agent/src/modes/components/tips.txt b/packages/coding-agent/src/modes/components/tips.txt index a2c55f06c..7e8c60848 100644 --- a/packages/coding-agent/src/modes/components/tips.txt +++ b/packages/coding-agent/src/modes/components/tips.txt @@ -21,4 +21,5 @@ Pair up live: `/collab` shares your session through an end-to-end encrypted rela Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them! -Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice [NEW] \ No newline at end of file +Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice +Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y) \ No newline at end of file diff --git a/packages/coding-agent/src/modes/components/usage-row.ts b/packages/coding-agent/src/modes/components/usage-row.ts index 78b012623..8ce18ec1f 100644 --- a/packages/coding-agent/src/modes/components/usage-row.ts +++ b/packages/coding-agent/src/modes/components/usage-row.ts @@ -18,12 +18,11 @@ export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: parts.push(`${theme.icon.time} ${(ttftMs / 1000).toFixed(1)}s`); } if (durationMs && durationMs > MIN_DURATION_MS && usage.output > 0) { - // Throughput excludes TTFT — generation time is duration minus time-to-first-token. - const genMs = durationMs - (ttftMs ?? 0); - if (genMs > MIN_DURATION_MS) { - const tokPerSec = (usage.output / genMs) * 1000; - parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`); - } + // TPS over the total request duration — the post-TTFT window undercounts + // generation time when reasoning tokens are hidden before the first + // visible byte, inflating the rate. + const tokPerSec = (usage.output / durationMs) * 1000; + parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`); } const block = new Container(); block.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 673056be4..fdb4dfc3e 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -44,20 +44,19 @@ const NEW_GLOW_PERIOD_MS = 1500; * affordance surfaces this many times as often. */ const NEW_TIP_WEIGHT = 4; -/** Per-tip selection weights, parallel to {@link TIPS}. */ -const TIP_WEIGHTS: readonly number[] = TIPS.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1)); -const TIP_WEIGHT_TOTAL = TIP_WEIGHTS.reduce((sum, weight) => sum + weight, 0); - -/** Pick a tip at random, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}. - * Returns "" when no tips are embedded. */ -function pickWeightedTip(): string { - if (TIPS.length === 0) return ""; - let r = Math.random() * TIP_WEIGHT_TOTAL; - for (let i = 0; i < TIPS.length; i++) { - r -= TIP_WEIGHTS[i] ?? 1; - if (r < 0) return TIPS[i] ?? ""; +/** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}; + * `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty. + * Exported for tests. */ +export function pickWeightedTip(tips: readonly string[], r: number): string { + if (tips.length === 0) return ""; + const weights = tips.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1)); + const total = weights.reduce((sum, weight) => sum + weight, 0); + let acc = r * total; + for (let i = 0; i < tips.length; i++) { + acc -= weights[i] ?? 1; + if (acc < 0) return tips[i] ?? ""; } - return TIPS[TIPS.length - 1] ?? ""; + return tips[tips.length - 1] ?? ""; } type ColorEncoding = "ansi-16m" | "ansi-256"; @@ -161,7 +160,7 @@ export class WelcomeComponent implements Component { if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) { this.#selectedTip = "Please use nerdfont 😭."; } else { - this.#selectedTip = pickWeightedTip(); + this.#selectedTip = pickWeightedTip(TIPS, Math.random()); } } return this.#selectedTip || undefined; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index d06773c77..79fe1a0c0 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1266,7 +1266,15 @@ export class CommandController { this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); - this.ctx.ui.requestRender(); + // Same as the auto-compaction rebuild: a collapsed transcript is an + // intentional replacement, so drop the stale pre-compaction scrollback + // instead of repainting the shrunken frame below it. With collapse + // disabled the full history stays inline and scrollback is kept. + if (this.ctx.settings.get("display.collapseCompacted")) { + this.ctx.ui.requestRender(true, { clearScrollback: true }); + } else { + this.ctx.ui.requestRender(); + } } catch (error) { if (error instanceof CompactionCancelledError) { outcome = "cancelled"; diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index ddea4f86a..dee063c49 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -76,7 +76,7 @@ export class EventController { #lastVisibleBlockCount = 0; #renderedCustomMessages = new Set(); #lastIntent: string | undefined = undefined; - #backgroundToolCallIds = new Set(); + #backgroundTaskCallIds = new Set(); #readToolCallArgs = new Map>(); #readToolCallAssistantComponents = new Map(); #lastAssistantComponent: AssistantMessageComponent | undefined = undefined; @@ -281,7 +281,7 @@ export class EventController { this.#lastVisibleBlockCount = 0; this.#renderedCustomMessages.clear(); this.#lastIntent = undefined; - this.#backgroundToolCallIds.clear(); + this.#backgroundTaskCallIds.clear(); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#lastAssistantComponent = undefined; @@ -828,9 +828,9 @@ export class EventController { // The turn ended without running these calls (abort/error/TTSR rewind), // so they will never produce a result. Seal them so they stop animating // and freeze instead of pinning the transcript live region while a retry - // streams fresh blocks below them. Background tools keep updating. + // streams fresh blocks below them. Background task calls keep updating. for (const [toolCallId, component] of this.ctx.pendingTools.entries()) { - if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) { + if (!this.#backgroundTaskCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) { component.seal(); } } @@ -948,7 +948,7 @@ export class EventController { // While the call is still executing — a mixed blocking+async task // call whose jobs settle before its blocking subset — treat it as a // partial frame: `tool_execution_end` still owns the terminal result. - const isTerminal = isFinalAsyncState && this.#backgroundToolCallIds.has(event.toolCallId); + const isTerminal = isFinalAsyncState && this.#backgroundTaskCallIds.has(event.toolCallId); component.updateResult( { ...event.partialResult, isError: asyncState === "failed" }, !isTerminal, @@ -956,7 +956,7 @@ export class EventController { ); if (isTerminal) { this.ctx.pendingTools.delete(event.toolCallId); - this.#backgroundToolCallIds.delete(event.toolCallId); + this.#backgroundTaskCallIds.delete(event.toolCallId); } this.ctx.ui.requestRender(); } @@ -977,13 +977,7 @@ export class EventController { component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId); this.ctx.pendingTools.delete(event.toolCallId); } - const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state; - if (asyncState === "running") { - this.#backgroundToolCallIds.add(event.toolCallId); - } else { - this.#backgroundToolCallIds.delete(event.toolCallId); - this.#clearReadToolCall(event.toolCallId); - } + this.#clearReadToolCall(event.toolCallId); this.ctx.ui.requestRender(); } else { let component = this.ctx.pendingTools.get(event.toolCallId); @@ -996,29 +990,22 @@ export class EventController { component = group; this.ctx.pendingTools.set(event.toolCallId, group); } - const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state; - const isBackgroundRunning = asyncState === "running"; - component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId); - if (isBackgroundRunning) { - this.#backgroundToolCallIds.add(event.toolCallId); - } else { - this.ctx.pendingTools.delete(event.toolCallId); - this.#backgroundToolCallIds.delete(event.toolCallId); - this.#clearReadToolCall(event.toolCallId); - } + component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId); + this.ctx.pendingTools.delete(event.toolCallId); + this.#clearReadToolCall(event.toolCallId); this.ctx.ui.requestRender(); } } else { const component = this.ctx.pendingTools.get(event.toolCallId); if (component) { const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state; - const isBackgroundRunning = asyncState === "running"; - component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId); - if (isBackgroundRunning) { - this.#backgroundToolCallIds.add(event.toolCallId); + const isBackgroundTask = event.toolName === "task" && asyncState === "running"; + component.updateResult({ ...event.result, isError: event.isError }, isBackgroundTask, event.toolCallId); + if (isBackgroundTask) { + this.#backgroundTaskCallIds.add(event.toolCallId); } else { this.ctx.pendingTools.delete(event.toolCallId); - this.#backgroundToolCallIds.delete(event.toolCallId); + this.#backgroundTaskCallIds.delete(event.toolCallId); } if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) { if (event.toolName === "job" && component.canBeDisplacedBy("job")) { @@ -1098,7 +1085,7 @@ export class EventController { } await this.ctx.flushPendingModelSwitch(); for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) { - if (!this.#backgroundToolCallIds.has(toolCallId)) { + if (!this.#backgroundTaskCallIds.has(toolCallId)) { // A foreground tool still pending at turn end never delivered a result; // seal it so it freezes (and stops animating) rather than lingering in // the transcript live region as a streaming preview until the next thaw. @@ -1112,8 +1099,8 @@ export class EventController { this.ctx.pendingTools.delete(toolCallId); } } - this.#backgroundToolCallIds = new Set( - Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)), + this.#backgroundTaskCallIds = new Set( + Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)), ); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); @@ -1247,7 +1234,19 @@ export class EventController { this.ctx.lastAssistantUsage = undefined; this.ctx.rebuildChatFromMessages(); this.ctx.statusLine.invalidate(); - this.ctx.ui.requestRender(); + // When history collapses behind the summary divider, the frame + // shrinks far below the committed row count; without clearing, the + // differential renderer's "duplication, never loss" resync repaints + // the whole collapsed transcript (welcome box included) BELOW the + // stale pre-compaction scrollback. Compaction is an intentional + // transcript replacement then — same as auto-handoff below. With + // collapse disabled the rebuilt transcript keeps the full history, + // so the resync handles it and scrollback stays. + if (settings.get("display.collapseCompacted")) { + this.ctx.ui.requestRender(true, { clearScrollback: true }); + } else { + this.ctx.ui.requestRender(); + } } else if (event.errorMessage) { this.ctx.showWarning(event.errorMessage); } else if (isHandoffAction) { diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index f5aa2766a..e166b7add 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -13,6 +13,7 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny- import { expandEmoticons } from "../../modes/emoji-autocomplete"; import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references"; import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete"; +import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input"; import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command"; import type { InteractiveModeContext } from "../../modes/types"; import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" }; @@ -266,6 +267,8 @@ export class InputController { }); } this.ctx.editor.onEscape = () => { + // Side-channel panels are the topmost view. Esc dismisses them before + // touching loop mode, maintenance, or the underlying main turn. // Active context maintenance owns Esc: auto/manual compaction, // handoff generation, and auto-retry backoff all advertise // "(esc to cancel)". Dispatch on live session state instead of @@ -281,6 +284,13 @@ export class InputController { // (see EventController). Main-session maintenance still owns Esc and // stays cancellable from the main view (focused submit gates /compact // and handoff, so manual maintenance is main-only anyway). + if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) { + return; + } + if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) { + return; + } + if (!this.ctx.focusedAgentId) { const viewSession = this.ctx.viewSession; let aborted = false; @@ -308,12 +318,6 @@ export class InputController { } return; } - if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) { - return; - } - if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) { - return; - } if (this.ctx.focusedAgentId) { // Esc never interrupts the focused agent's turn: clear typed text, // else return the view to the main session. Interrupt via empty @@ -647,6 +651,16 @@ export class InputController { if (!text && !hasInputImages) return; + const queueBody = parseQueueShorthand(text); + if (queueBody !== undefined) { + await this.#queueForYield(queueBody, { + historyText: text, + images: inputImages, + imageLinks: inputImageLinks, + }); + return; + } + // Handle built-in slash commands if (text) { const slashResult = await executeBuiltinSlashCommand(text, { @@ -1114,6 +1128,126 @@ export class InputController { } } + /** Queue `/queue` input behind an active turn, or start it immediately when idle. */ + async handleQueueCommand(text: string): Promise { + const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined; + const imageLinks = + images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined; + await this.#queueForYield(text, { images, imageLinks }); + } + + async #queueForYield( + text: string, + options: { + historyText?: string; + images?: ImageContent[]; + imageLinks?: (string | undefined)[]; + }, + ): Promise { + const splitMessages = splitQueuedMessages(text); + if (splitMessages.length === 0 && !options.images?.length) { + this.ctx.editor.clearDraft(); + this.ctx.showWarning("Usage: /queue (or start a prompt with -> / =>)"); + return; + } + + const messages = splitMessages.length > 0 ? splitMessages : [""]; + const originalDraft = this.ctx.editor.getText(); + const images = options.images?.length ? [...options.images] : undefined; + const imageLinks = options.imageLinks + ? [...options.imageLinks] + : images + ? images.map(() => undefined) + : undefined; + this.ctx.editor.clearDraft(options.historyText); + + if (this.ctx.session.isCompacting) { + for (let index = 0; index < messages.length; index++) { + this.ctx.compactionQueuedMessages.push({ + text: messages[index] ?? "", + mode: "followUp", + images: index === 0 ? images : undefined, + }); + } + this.ctx.updatePendingMessagesDisplay(); + this.ctx.showStatus( + messages.length === 1 + ? "Queued message for after compaction" + : `Queued ${messages.length} messages for after compaction`, + ); + this.ctx.ui.requestRender(); + return; + } + + const startImmediately = !this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0; + let queuedCount = 0; + try { + if (startImmediately && this.ctx.onInputCallback) { + const first = messages[0] ?? ""; + const submission = this.ctx.startPendingSubmission({ + text: first, + images, + imageLinks, + streamingBehavior: "followUp", + }); + this.ctx.onInputCallback(submission); + queuedCount = 1; + } + while (queuedCount < messages.length) { + const message = messages[queuedCount] ?? ""; + const queuedImages = queuedCount === 0 ? images : undefined; + await this.ctx.withLocalSubmission( + message, + async () => { + if (startImmediately && queuedCount === 0) { + await this.ctx.session.prompt(message, { + images: queuedImages, + streamingBehavior: "followUp", + }); + } else { + await this.ctx.session.followUp(message, queuedImages); + } + }, + { imageCount: queuedImages?.length ?? 0 }, + ); + queuedCount++; + } + } catch (error) { + if (queuedCount === 0) { + this.ctx.editor.setText(originalDraft); + if (images) { + this.ctx.editor.pendingImages = images; + this.ctx.editor.pendingImageLinks = imageLinks ?? images.map(() => undefined); + this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks; + } + } else { + const remaining = messages.slice(queuedCount); + const restored = + remaining.length === 1 + ? `=> ${remaining[0]}` + : `=>\n${remaining + .map((message, index) => `${index + 1}. ${message.replaceAll("\n", "\n ")}`) + .join("\n")}`; + this.ctx.editor.setText(restored); + } + this.ctx.showError(error instanceof Error ? error.message : String(error)); + } + + this.ctx.updatePendingMessagesDisplay(); + if (queuedCount === messages.length) { + this.ctx.showStatus( + startImmediately + ? queuedCount === 1 + ? "Sent queued message" + : `Sent first message; queued ${queuedCount - 1} for later yields` + : queuedCount === 1 + ? "Queued message for when the agent yields" + : `Queued ${queuedCount} messages for when the agent yields`, + ); + } + this.ctx.ui.requestRender(); + } + /** Send editor text as a follow-up message (queued behind current stream). */ async handleFollowUp(): Promise { let text = this.ctx.editor.getExpandedText().trim(); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index a889a11bb..f28d95ad8 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -67,10 +67,12 @@ import { ExtensionDashboard } from "../components/extensions"; import { HistorySearchComponent } from "../components/history-search"; import { LoginDialogComponent } from "../components/login-dialog"; import { LogoutAccountSelectorComponent } from "../components/logout-account-selector"; -import { ModelHubComponent, type ModelHubMode } from "../components/model-hub"; +import { ModelHubComponent } from "../components/model-hub"; +import { ModelPickerComponent } from "../components/model-picker"; import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; import { ResetUsageSelectorComponent } from "../components/reset-usage-selector"; +import { renderSegmentTrack } from "../components/segment-track"; import { SessionSelectorComponent } from "../components/session-selector"; import { SettingsSelectorComponent } from "../components/settings-selector"; import { ToolExecutionComponent } from "../components/tool-execution"; @@ -458,6 +460,13 @@ export class SelectorController { this.ctx.rebuildChatFromMessages(); this.ctx.ui.resetDisplay(); break; + case "display.collapseCompacted": + // Rebuild swaps between the collapsed tail and the full inline + // history; full reset retires blocks already committed to native + // scrollback (mirrors cacheMissMarker). + this.ctx.rebuildChatFromMessages(); + this.ctx.ui.resetDisplay(); + break; case "tui.tight": setTuiTight(value as boolean); this.ctx.ui.invalidate(); @@ -593,7 +602,85 @@ export class SelectorController { } showModelSelector(options?: { temporaryOnly?: boolean }): void { - this.#showModelHub({ mode: options?.temporaryOnly ? "pick" : "roles" }); + if (options?.temporaryOnly) { + this.#showModelPicker(); + return; + } + this.#showModelHub({}); + } + + /** + * Compact session-only model picker (alt+p / `/switch`): a floating + * bottom-anchored overlay over the transcript. The current model is + * highlighted and preselected; a leading `@` searches ctrl+p quick roles. + */ + #showModelPicker(): void { + const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0; + const current = this.ctx.session.model; + const quickRoleOrder = this.ctx.settings.get("cycleOrder"); + const quickRoleCycle = this.ctx.session.getRoleModelCycle(quickRoleOrder); + let overlayHandle: OverlayHandle | undefined; + let closed = false; + const done = () => { + if (closed) return; + closed = true; + overlayHandle?.hide(); + this.focusActiveEditorArea(); + this.ctx.ui.requestRender(); + }; + const picker = new ModelPickerComponent( + this.ctx.ui, + this.ctx.settings, + this.ctx.session.modelRegistry, + this.ctx.session.scopedModels, + { + onPick: async (model, selector) => { + try { + // Session-only: update agent state but don't persist the model to settings. + await this.ctx.session.setModelTemporary(model); + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorBorderColor(); + const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M"; + this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`); + done(); + } catch (error) { + this.ctx.showError(error instanceof Error ? error.message : String(error)); + } + }, + onPickRole: async entry => { + try { + await this.ctx.session.applyRoleModel(entry); + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorBorderColor(); + this.ctx.showModelCycleTrack( + renderSegmentTrack( + quickRoleOrder.map(role => ({ label: role })), + quickRoleOrder.indexOf(entry.role), + ), + ); + done(); + } catch (error) { + this.ctx.showError(error instanceof Error ? error.message : String(error)); + } + }, + onCancel: done, + }, + { + currentContextTokens, + currentSelector: current ? `${current.provider}/${current.id}` : undefined, + quickRoles: quickRoleCycle?.models, + quickRoleOrder, + currentQuickRole: quickRoleCycle?.models[quickRoleCycle.currentIndex]?.role, + }, + ); + overlayHandle = this.ctx.ui.showOverlay(picker, { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + }); + this.ctx.ui.setFocus(picker); + this.ctx.ui.requestRender(); } /** @@ -602,13 +689,13 @@ export class SelectorController { * untouched underneath. `initialProviderId` preselects a provider's sidebar * entry — used when reopening the hub after a /login round-trip. */ - #showModelHub(hubOptions: { mode: ModelHubMode; initialProviderId?: string }): void { + #showModelHub(hubOptions: { initialProviderId?: string }): void { const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0; let overlayHandle: OverlayHandle | undefined; let hub: ModelHubComponent | undefined; let closed = false; const done = () => { - // Re-entrant guard: cancel paths (Esc, pick, login forward) may race; + // Re-entrant guard: cancel paths (Esc, login forward) may race; // the overlay must hide exactly once. if (closed) return; closed = true; @@ -623,7 +710,7 @@ export class SelectorController { this.ctx.session.modelRegistry, this.ctx.session.scopedModels, { - onAssign: async (model, role, thinkingLevel, selector, action) => { + onAssign: async (model, role, thinkingLevel, selector) => { // `auto` is session-global: never baked into a per-role model value // (it can't round-trip through `model:`). Apply it to the session // separately and persist via `defaultThinkingLevel`. @@ -631,18 +718,6 @@ export class SelectorController { const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel; const selectorValue = selector ?? `${model.provider}/${model.id}`; try { - if (action === "retryFallback") { - const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking); - const fallbackChains = this.ctx.settings.get("retry.fallbackChains"); - const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : []; - this.ctx.settings.set("retry.fallbackChains", { - ...fallbackChains, - [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)], - }); - const roleInfo = getRoleInfo(role, settings); - this.ctx.showStatus(`${roleInfo?.name ?? role} fallback model: ${fallbackSelector}`); - return; - } if (role === "default") { const { switched } = await this.ctx.session.setModel(model, role, { selector, @@ -686,17 +761,21 @@ export class SelectorController { this.ctx.showError(error instanceof Error ? error.message : String(error)); } }, - onPick: async (model, selector) => { + onFallbackChainChange: (role, chain) => { try { - // Session-only: update agent state but don't persist the model to settings. - await this.ctx.session.setModelTemporary(model); - this.ctx.statusLine.invalidate(); - this.ctx.updateEditorBorderColor(); - const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M"; + const chains = { ...this.ctx.settings.get("retry.fallbackChains") }; + if (chain.length === 0) { + delete chains[role]; + } else { + chains[role] = chain; + } + this.ctx.settings.set("retry.fallbackChains", chains); + const roleInfo = getRoleInfo(role, settings); this.ctx.showStatus( - `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`, + chain.length > 0 + ? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}` + : `${roleInfo?.name ?? role} fallbacks cleared`, ); - done(); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } @@ -718,8 +797,6 @@ export class SelectorController { onCancel: () => done(), }, { - mode: hubOptions.mode, - currentContextTokens, initialProviderId: hubOptions.initialProviderId, }, ); @@ -738,7 +815,7 @@ export class SelectorController { async #loginThenReopenModelHub(providerId: string): Promise { const succeeded = await this.#handleOAuthLogin(providerId); if (succeeded) { - this.#showModelHub({ mode: "roles", initialProviderId: providerId }); + this.#showModelHub({ initialProviderId: providerId }); } } diff --git a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts index 0fcec31fc..2be9fe24c 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -3,12 +3,14 @@ import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { prompt, Snowflake } from "@oh-my-pi/pi-utils"; import backgroundTanDispatchPrompt from "../../prompts/system/background-tan-dispatch.md" with { type: "text" }; +import tanContextSwitchPrompt from "../../prompts/system/tan-context-switch.md" with { type: "text" }; import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry"; import * as sdk from "../../sdk"; import type { AgentSession } from "../../session/agent-session"; import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE } from "../../session/messages"; import { SessionManager } from "../../session/session-manager"; import { createMCPProxyTools, createSubagentSettings } from "../../task/executor"; +import { USER_TODO_EDIT_CUSTOM_TYPE } from "../../tools/todo"; import type { InteractiveModeContext } from "../types"; const TAN_LABEL_PREVIEW_LENGTH = 80; @@ -66,6 +68,11 @@ export class TanCommandController { } const parentSessionId = session.sessionId; + // Providers route on `promptCacheKey ?? sessionId`, so the parent's live + // requests may cache under a pinned key that differs from its session id + // (the parent being itself a fork/tan). Mirror exactly what the parent + // populated the cache under — same rule as advisor and handoff calls. + const parentPromptCacheKey = session.agent.promptCacheKey ?? parentSessionId; const thinkingLevel = session.configuredThinkingLevel(); const systemPrompt = [...session.systemPrompt]; const toolNames = session.getActiveToolNames(); @@ -111,7 +118,7 @@ export class TanCommandController { systemPrompt, toolNames, providerSessionId: `${parentSessionId}:tan:${Snowflake.next()}`, - providerPromptCacheKey: parentSessionId, + providerPromptCacheKey: parentPromptCacheKey, modelRegistry, authStorage: modelRegistry.authStorage, settings, @@ -127,19 +134,51 @@ export class TanCommandController { disableExtensionDiscovery: true, }); clone = created.session; + clone.sessionManager?.appendSessionInit?.({ + systemPrompt: clone.systemPrompt ? clone.systemPrompt.join("\n\n") : systemPrompt.join("\n\n"), + task: trimmedWork, + tools: clone.getActiveToolNames ? clone.getActiveToolNames() : toolNames, + }); const abortClone = () => { void clone?.abort(); }; signal.addEventListener("abort", abortClone, { once: true }); + // The fork inherits the parent's todo list via session entries; + // its reminders would drag the tan back onto the parent's task. + // Clear runtime state and persist an empty edit so reloads agree. + clone.setTodoPhases([]); + cloneManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: [] }); + const injectContextSwitch = () => { + clone?.agent.appendMessage({ + role: "developer", + content: tanContextSwitchPrompt, + attribution: "agent", + timestamp: Date.now(), + }); + }; + // Compaction summarizes the fork notice away with the rest of the + // history, after which the clone re-adopts the parent's task as its + // own (the summary blends both). Re-inject after every successful + // compaction so the fork boundary survives summarization. + const unsubscribeCompaction = clone.subscribe(event => { + if (event.type === "auto_compaction_end" && event.result && !event.aborted) { + injectContextSwitch(); + } + }); try { if (signal.aborted) { abortClone(); throw new Error("Aborted before execution"); } + // Inject a context-switch developer message so the clone knows + // it is a tangential fork — its parent owns the prior conversation; + // this agent must focus exclusively on the user's request. + injectContextSwitch(); await clone.prompt(trimmedWork, { attribution: "user" }); await clone.waitForIdle(); return extractAssistantText(clone.getLastAssistantMessage()) || "(no output)"; } finally { + unsubscribeCompaction(); signal.removeEventListener("abort", abortClone); } } finally { diff --git a/packages/coding-agent/src/modes/controllers/todo-command-controller.ts b/packages/coding-agent/src/modes/controllers/todo-command-controller.ts index 3f3855268..9df53fa87 100644 --- a/packages/coding-agent/src/modes/controllers/todo-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/todo-command-controller.ts @@ -133,8 +133,7 @@ export class TodoCommandController { /** * True latest todo state for the user-facing /todo verbs. Reads from session - * entries so that completed/abandoned tasks remain visible after resume - * (where `session.getTodoPhases()` would have stripped them). + * entries or falls back to the active session state. */ #currentPhases(): TodoPhase[] { const fromEntries = getLatestTodoPhasesFromEntries(this.ctx.sessionManager.getBranch()); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index cc968c862..8311acbc2 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -120,6 +120,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo import { ToolError } from "../tools/tool-errors"; import { vocalizer } from "../tts/vocalizer"; import { renderTreeList } from "../tui/tree-list"; +import { copyToClipboard } from "../utils/clipboard"; import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; @@ -1613,9 +1614,12 @@ export class InteractiveMode implements InteractiveModeContext { } } this.chatContainer.clear(); - // Live display uses the compacted transcript tail; export/resume callers - // can still request the full inline compaction history. - const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true }); + // Live display collapses to the compacted transcript tail unless the + // user opted into the full inline history; export/resume callers choose + // their own mode. + const context = this.viewSession.buildTranscriptSessionContext({ + collapseCompactedHistory: settings.get("display.collapseCompacted"), + }); this.renderSessionContext(context); for (const child of liveComponents) { this.chatContainer.addChild(child); @@ -2517,6 +2521,7 @@ export class InteractiveMode implements InteractiveModeContext { { onPick: choice => finish(choice), onCancel: () => finish(undefined), + onCopyPlan: content => void this.#copyPlanToClipboard(content), onExternalEditor: dialogOptions?.onExternalEditor, onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit), onPlanEdited: dialogOptions?.onPlanEdited, @@ -2583,6 +2588,17 @@ export class InteractiveMode implements InteractiveModeContext { return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT; } + async #copyPlanToClipboard(content: string): Promise { + try { + await copyToClipboard(content); + this.showStatus("Copied plan to clipboard"); + } catch (error) { + this.showWarning( + `Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + async #openPlanInExternalEditor(planFilePath: string): Promise { const editorCmd = getEditorCommand(); if (!editorCmd) { @@ -4287,6 +4303,11 @@ export class InteractiveMode implements InteractiveModeContext { return this.#inputController.handleImagePaste(); } + /** Queue slash-command input behind the active turn. */ + handleQueueCommand(message: string): Promise { + return this.#inputController.handleQueueCommand(message); + } + handleBtwCommand(question: string): Promise { return this.#btwController.start(question); } diff --git a/packages/coding-agent/src/modes/queue-input.ts b/packages/coding-agent/src/modes/queue-input.ts new file mode 100644 index 000000000..ee357f2fb --- /dev/null +++ b/packages/coding-agent/src/modes/queue-input.ts @@ -0,0 +1,132 @@ +const QUEUE_PREFIXES: readonly string[] = ["->", "=>"]; +/** Prefix matcher shared by queue-list parsing and editor highlighting. */ +export const QUEUE_LIST_MARKER_RE = /^([\t ]*)(\d+|[A-Za-z]+)([.)])(?=[\t ]|$)/; +const CANONICAL_ROMAN_RE = /^(?=[MDCLXVI])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$/i; + +interface EnumeratedItem { + line: number; + indent: string; + marker: string; + punctuation: string; + content: string; +} + +interface EnumeratedList { + source: string; + lines: string[]; + items: EnumeratedItem[]; +} + +/** Extract the message body from the `->` / `=>` yield-queue shorthand. */ +export function parseQueueShorthand(text: string): string | undefined { + const prefix = QUEUE_PREFIXES.find(candidate => text.startsWith(candidate)); + return prefix ? text.slice(prefix.length).trim() : undefined; +} + +function parseEnumeratedItem(line: string, lineIndex: number): EnumeratedItem | undefined { + const match = QUEUE_LIST_MARKER_RE.exec(line); + if (!match) return undefined; + const [matched, indent, marker, punctuation] = match; + if (indent === undefined || marker === undefined || punctuation === undefined) return undefined; + return { line: lineIndex, indent, marker, punctuation, content: line.slice(matched.length).trimStart() }; +} + +function decodeDecimal(marker: string): number | undefined { + if (!/^\d+$/.test(marker)) return undefined; + const value = Number(marker); + return Number.isSafeInteger(value) ? value : undefined; +} + +function decodeRoman(marker: string): number | undefined { + if (!CANONICAL_ROMAN_RE.test(marker)) return undefined; + const values: Readonly> = { + I: 1, + V: 5, + X: 10, + L: 50, + C: 100, + D: 500, + M: 1000, + }; + const upper = marker.toUpperCase(); + let value = 0; + for (let index = 0; index < upper.length; index++) { + const current = values[upper[index] ?? ""]; + if (current === undefined) return undefined; + const next = values[upper[index + 1] ?? ""] ?? 0; + value += current < next ? -current : current; + } + return value; +} + +function decodeAlpha(marker: string): number | undefined { + if (!/^[A-Za-z]+$/.test(marker)) return undefined; + let value = 0; + for (const char of marker.toUpperCase()) { + value = value * 26 + char.charCodeAt(0) - 64; + if (!Number.isSafeInteger(value)) return undefined; + } + return value; +} + +function isSequential(markers: readonly string[], decode: (marker: string) => number | undefined): boolean { + let previous = decode(markers[0] ?? ""); + if (previous === undefined) return false; + for (let index = 1; index < markers.length; index++) { + const current = decode(markers[index] ?? ""); + if (current === undefined || current !== previous + 1) return false; + previous = current; + } + return true; +} + +function isEnumeratedSequence(items: readonly EnumeratedItem[]): boolean { + const markers = items.map(item => item.marker); + if (markers.every(marker => /^\d+$/.test(marker))) return isSequential(markers, decodeDecimal); + if ( + !markers.every(marker => marker === marker.toUpperCase()) && + !markers.every(marker => marker === marker.toLowerCase()) + ) { + return false; + } + return isSequential(markers, decodeRoman) || isSequential(markers, decodeAlpha); +} + +function parseEnumeratedList(text: string): EnumeratedList | undefined { + const source = text.trim(); + if (!source) return undefined; + const lines = source.split(/\r?\n/); + const first = parseEnumeratedItem(lines[0] ?? "", 0); + if (!first) return undefined; + + const items = [first]; + for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) { + const item = parseEnumeratedItem(lines[lineIndex] ?? "", lineIndex); + if (item?.indent === first.indent) items.push(item); + } + if (items.length < 2 || items.some(item => item.punctuation !== first.punctuation) || !isEnumeratedSequence(items)) { + return undefined; + } + return { source, lines, items }; +} + +/** Whether text currently forms a sequential queue list, including an unfinished trailing item. */ +export function isQueuedMessageList(text: string): boolean { + return parseEnumeratedList(text) !== undefined; +} + +/** Split a sequential numeric, Roman-numeral, or alphabetic list into queue entries. */ +export function splitQueuedMessages(text: string): string[] { + const list = parseEnumeratedList(text); + if (!list) { + const source = text.trim(); + return source ? [source] : []; + } + + const messages = list.items.map((item, index) => { + const nextLine = list.items[index + 1]?.line ?? list.lines.length; + return [item.content, ...list.lines.slice(item.line + 1, nextLine)].join("\n").trim(); + }); + while (messages.at(-1) === "") messages.pop(); + return messages.length > 0 && messages.every(Boolean) ? messages : [list.source]; +} diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 9d4151529..e6ae57aa4 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -225,6 +225,7 @@ export type SymbolKey = | "tool.debug" | "tool.mcp" | "tool.job" + | "tool.launch" | "tool.task" | "tool.todo" | "tool.memory" @@ -433,6 +434,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "tool.debug": "🐞", "tool.mcp": "🔌", "tool.job": "⚙", + "tool.launch": "🚀", "tool.task": "⇶", "tool.todo": "☑", "tool.memory": "🧠", @@ -742,6 +744,7 @@ const NERD_SYMBOLS: SymbolMap = { "tool.debug": "\uEAD8", "tool.mcp": "\uEB2D", "tool.job": "\uEBA2", + "tool.launch": "\uF135", "tool.task": "\uf4a0", "tool.todo": "\uEAB3", "tool.memory": "\uEACE", @@ -946,6 +949,7 @@ const ASCII_SYMBOLS: SymbolMap = { "tool.debug": "dbg", "tool.mcp": "<>", "tool.job": "job", + "tool.launch": "run", "tool.task": ">>>", "tool.todo": "[x]", "tool.memory": "mem", diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 1f3d13c18..cf3dd39eb 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -377,6 +377,8 @@ export interface InteractiveModeContext { handleCtrlZ(): void; handleDequeue(): void; handleImagePaste(): Promise; + /** Queue a message for delivery only after the active agent turn would stop. */ + handleQueueCommand(message: string): Promise; handleBtwCommand(question: string): Promise; handleTanCommand(work: string): Promise; hasActiveBtw(): boolean; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 447998a74..70af14750 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -45,7 +45,8 @@ import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails, } from "../../session/messages"; -import type { SessionContext } from "../../session/session-context"; +import type { SessionContext, StrippedToolCallsMarker } from "../../session/session-context"; +import { replaceTabs } from "../../tools/render-utils"; import { buildSkillCommandPrompt, invokeSkillCommandFromText, isKnownSkillCommand } from "../skill-command"; import { createAssistantMessageComponent } from "./interactive-context-helpers"; import { @@ -464,6 +465,26 @@ export class UiHelpers { this.ctx.pendingTools.set(content.id, component); } } + // Dangling toolCalls (no result on the resolved path — failed or + // retried turns, results on sibling branches) were stripped by the + // context build; surface a placeholder so the turn's activity is + // visibly elided instead of silently vanishing (the "bare thinking + // lines" transcript trap). + const strippedToolCalls = (message as AgentMessage & StrippedToolCallsMarker).strippedToolCalls ?? 0; + if (strippedToolCalls > 0) { + this.ctx.chatContainer.addChild( + new Text( + theme.fg( + "dim", + theme.italic( + `${strippedToolCalls} tool call${strippedToolCalls === 1 ? "" : "s"} elided — no result on this branch`, + ), + ), + 1, + 0, + ), + ); + } pendingUsage = this.ctx.settings.get("display.showTokenUsage") && assistantUsageIsBilled(message.usage) ? message.usage @@ -606,13 +627,14 @@ export class UiHelpers { this.ctx.pendingBashComponents = []; this.ctx.pendingPythonComponents = []; - // Live display uses the compacted transcript tail; export/resume callers - // can still request the full inline compaction history. Mid-turn rebuilds + // Live display collapses to the compacted transcript tail unless the + // user opted into the full inline history; export/resume callers can + // still request either mode. Mid-turn rebuilds // (focus attach/unfocus while a tool executes) keep dangling toolCalls so // the in-flight call re-renders as pending instead of vanishing; // renderSessionContext then keeps it in `pendingTools` for live routing. const context = this.ctx.viewSession.buildTranscriptSessionContext({ - collapseCompactedHistory: true, + collapseCompactedHistory: settings.get("display.collapseCompacted"), keepDanglingToolCalls: this.ctx.viewSession.isStreaming, }); this.ctx.renderSessionContext(context, { @@ -677,35 +699,33 @@ export class UiHelpers { this.ctx.pendingMessagesContainer.disposeChildren(); const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages; - const steeringMessages: Array<{ message: string; label: string }> = []; - for (const message of queuedMessages.steering) { - steeringMessages.push({ message, label: "Steer" }); - } + const steeringMessages = [...queuedMessages.steering]; for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) { - if (entry.mode === "steer") { - steeringMessages.push({ message: entry.text, label: "Steer" }); - } + if (entry.mode === "steer") steeringMessages.push(entry.text); } - const followUpMessages: Array<{ message: string; label: string }> = []; - for (const message of queuedMessages.followUp) { - followUpMessages.push({ message, label: "Follow-up" }); - } + const followUpMessages = [...queuedMessages.followUp]; for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) { - if (entry.mode === "followUp") { - followUpMessages.push({ message: entry.text, label: "Follow-up" }); - } + if (entry.mode === "followUp") followUpMessages.push(entry.text); } - const allMessages = [...steeringMessages, ...followUpMessages]; - if (allMessages.length > 0) { + const groups = [ + { label: "Steering", messages: steeringMessages }, + { label: "After yield", messages: followUpMessages }, + ].filter(group => group.messages.length > 0); + if (groups.length > 0) { this.ctx.pendingMessagesContainer.addChild(new Spacer(1)); - for (const entry of allMessages) { - const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`); - this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); + for (const group of groups) { + const heading = theme.fg("muted", `${group.label}${theme.sep.dot}${group.messages.length}`); + this.ctx.pendingMessagesContainer.addChild(new TruncatedText(heading, 1, 0)); + for (let index = 0; index < group.messages.length; index++) { + const message = replaceTabs(group.messages[index] ?? "").replace(/\r?\n/g, " ↵ "); + const queuedText = theme.fg("dim", ` ${index + 1}. ${message}`); + this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); + } } const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up"; - const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`); + const hintText = theme.fg("dim", ` ${theme.tree.hook} ${dequeueKey} to edit`); this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } } diff --git a/packages/coding-agent/src/prompts/system/downshift-checklist.md b/packages/coding-agent/src/prompts/system/downshift-checklist.md new file mode 100644 index 000000000..5b1fc7bfb --- /dev/null +++ b/packages/coding-agent/src/prompts/system/downshift-checklist.md @@ -0,0 +1,7 @@ +Before you consider this task finished, verify: + +- Consistency: if you changed a pattern, signature, or check in one place, grep for every other call site or duplicate copy that needs the identical change. A fix applied to only some of the matching sites is still a failure. +- Scope: if your diff does more than the minimal change needed to resolve the issue, confirm you have not altered behavior for any case outside the reported issue. Prefer the smallest correct diff over a broader rewrite. +- Verification: run the full test module or file the issue lives in, not just the one test you expect to flip. A change that breaks a sibling test is not a fix. + +Do not claim the task is complete until you have done these three checks. diff --git a/packages/coding-agent/src/prompts/system/downshift-continue.md b/packages/coding-agent/src/prompts/system/downshift-continue.md new file mode 100644 index 000000000..04dd27c9c --- /dev/null +++ b/packages/coding-agent/src/prompts/system/downshift-continue.md @@ -0,0 +1 @@ +Continue the task now — do not end your turn here. diff --git a/packages/coding-agent/src/prompts/system/downshift-plan.md b/packages/coding-agent/src/prompts/system/downshift-plan.md new file mode 100644 index 000000000..1a2d4b74c --- /dev/null +++ b/packages/coding-agent/src/prompts/system/downshift-plan.md @@ -0,0 +1,13 @@ +Stop and write the complete plan in your NEXT reply — before any further exploration. You have already seen enough to commit to a plan; do not defer this. + +First, state the plan itself, explicitly and comprehensively: + +- Every remaining step in execution order, with the exact files, symbols, commands, and checks involved. +- Known risks, edge cases, and how you will verify each step actually landed (specific commands, expected outputs). Never modify tests or verification assets to make checks pass. +- What is already done, stated briefly, so no step gets repeated. + +Be thorough and concrete — this plan is the reference for the remainder of the run. You may verify details with tools after the plan is written, never before. + +Then, only once the plan above is complete and detailed, in the SAME reply, capture it as a SUPER-DETAILED todo list (the todo tool): one item per concrete step from the plan — each naming its exact file/symbol/command target and its verification — not a handful of vague phase headings. The todo list must be precise enough that every item can be checked off against an observable result. + +This is a checkpoint, not a final answer: do not end your turn on the plan alone — after recording the todo list, continue the task; do not stop here. diff --git a/packages/coding-agent/src/prompts/system/plan-yolo-handoff.md b/packages/coding-agent/src/prompts/system/plan-yolo-handoff.md new file mode 100644 index 000000000..3661b51d2 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/plan-yolo-handoff.md @@ -0,0 +1,5 @@ +Plan approved: **{{title}}**. + +Read `{{planFilePath}}` and implement it now — full tool access is restored. Execute the plan top to bottom exactly as written; you were not part of drafting it, so treat every choice in it as already made. Do not ask for further approval and do not re-plan. + +When finished, re-read the plan and confirm every step was completed before ending your turn. diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 1e45733ed..5289091c5 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -192,7 +192,7 @@ EXECUTION WORKFLOW # 3. Decompose - Update todos as you go; skip them for trivial requests. Marking a todo done is a transition: start the next in the same turn. -- Plan only what makes the request work. Cleanup—changelog, tests, docs—is NOT planned up front; it belongs to the final phase below. +- Plan only what makes the request work. Cleanup—changelog, docs, removing scaffolding—is NOT planned up front; it belongs to the final phase below. Tests are cleanup only for permanent feature/bug-fix work (see Cleanup). # 4. Implement - Fix problems at the source. Remove obsolete code—no leftover comments, aliases, or re-exports. @@ -202,14 +202,16 @@ EXECUTION WORKFLOW {{#has tools "ask"}}- Ask before destructive commands or deleting code you didn't write.{{else}}- Don't run destructive git commands or delete code you didn't write.{{/has}} # 5. Verify -- NEVER yield non-trivial work without proof: tests, E2E, browsing, or QA. -- Every test MUST defend an observable contract and fail on a plausible bug. -- Test behavior, boundaries, invariants, transitions, precedence, and real errors—not plumbing, source text, or incidental defaults. -- Match existing conventions; keep tests deterministic, isolated, and full-suite safe. -- Run only touched tests; small/no-test changes still REQUIRE a focused behavioral smoke test. +- NEVER yield non-trivial work without proof that the deliverable works. The proof method depends on the ask: + - **Experiment / investigation** → run it. The output IS the proof. No tests. + - **UI change** → drive it in browser. Visual confirmation IS the proof. No tests unless the existing suite breaks and the break is real. + - **Bug fix** → reproduce the bug, apply the fix, confirm the reproduction no longer triggers. + - **Permanent feature / API change** → existing tests that cover the changed contract. Add a test only when the change introduces a new observable contract not already covered, or the user asked for one. +- Smoke test: run the thing, not a test file. Launch it, exercise the changed path, observe the result. +- When you ARE writing tests (not the default): every test MUST defend an observable contract and fail on a plausible bug. Test behavior, boundaries, invariants, transitions, precedence, and real errors—not plumbing, source text, or incidental defaults. Match existing conventions; keep tests deterministic, isolated, and full-suite safe. # 6. Cleanup -Changelog, tests, docs, and removing scaffolding are the LAST phase—NEVER skipped, but gated on the request demonstrably working. +Changelog and removing scaffolding are the LAST phase—NEVER skipped, but gated on the request demonstrably working. Tests and docs are cleanup ONLY when the work is a permanent feature change or bug fix, not for experiments or one-off investigations. - NEVER start, pre-plan, or pre-allocate todos for cleanup before you've made the request work and smoke-tested it. Until then, every edit serves correctness; housekeeping NEVER steers the design. - Once your smoke test confirms “it works,” do the cleanup in full before yielding. diff --git a/packages/coding-agent/src/prompts/system/tan-context-switch.md b/packages/coding-agent/src/prompts/system/tan-context-switch.md new file mode 100644 index 000000000..55468b15a --- /dev/null +++ b/packages/coding-agent/src/prompts/system/tan-context-switch.md @@ -0,0 +1,17 @@ + +The conversation above belongs to your parent session. +You are a fork created solely to handle the user's request below. + +Your parent agent is still working on the original task — that responsibility is +NOT yours. You have no obligations from the prior conversation. + +- Focus EXCLUSIVELY on the user's immediate request. Nothing else. +- NEVER continue, follow up on, or intervene in anything discussed before this + message. Those belong to the parent session. +- Your parent is CONCURRENTLY editing this same working directory. Files may + change between your reads, look mid-refactor, or fail to compile. That is the + parent's live work — NEVER fix, audit, or build on it, even if it looks broken. +- Any todo list, plan, or unfinished checklist from the prior conversation is + the parent's. NEVER resume or update it. +- After addressing the user's request, STOP. Do not work on ANY OTHER TASK. + diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 83ad3ba0a..fd5b6921b 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -5,6 +5,7 @@ Runs commands in the embedded shell — terminal ops: git, bun, cargo, python. The shell invokes **real binaries** with simple args. It is NOT full GNU Bash. Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact and does not depend on shell-specific regex/quoting (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`). +{{#if hasLaunch}}Long-running service, watcher, debugger, REPL, or process needing later input? MUST use `launch`, not bash.{{/if}} {{#if hasEval}}Anything below → `eval` cell, not bash: - Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language @@ -33,7 +34,7 @@ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a f - Internal URIs (`skill://`, `agent://`, …) auto-resolve to FS paths {{#if hasEval}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly.{{else}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Use a checked-in script, purpose-built tool, or single command that owns the output shape.{{/if}} {{#if asyncEnabled}} -- `async: true` for long-running commands when you don't need immediate output: returns a background job ID; result delivered as a follow-up. +- `async: true` defers reporting for finite commands that need no later input; completion arrives as a follow-up. {{/if}} @@ -42,6 +43,7 @@ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a f {{#if hasGrep}}- NEVER shell out to search content or files: `grep/rg` → `grep`.{{else}}- Avoid shelling out for broad content search; use an active search/read tool when one is available.{{/if}} {{#if hasRead}}{{#if hasGlob}}- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing.{{else}}- Prefer `read` for known file and directory reads. Only use shell listing when no file-listing tool is active.{{/if}}{{else}}{{#if hasGlob}}- Prefer `glob` for file discovery; avoid `find` when `glob` is active.{{else}}- If no file read/listing tool is active, keep shell inspection narrow and state that limitation.{{/if}}{{/if}} - Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://`. +{{#if hasLaunch}}- NEVER launch daemons, watchers, dev servers, debuggers, or REPLs through bash/background shell syntax — use `launch`.{{/if}} @@ -52,9 +54,9 @@ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a f {{#if asyncEnabled}} # Timeout and async -- `timeout` is seconds; nonzero values are clamped to `1..3600` and the process is killed on elapse. Set `timeout: 0` only for commands that must run until completion or explicit cancellation. -- `async: true` defers only reporting — it does NOT extend a nonzero timeout; use `timeout: 0` when a daemon or watcher must be cancellation-owned. -- Need a daemon or >3600s run? Use `async: true` with `timeout: 0` when the harness should keep it alive until cancellation, or detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls. +- `timeout` is seconds; nonzero values are clamped to `1..3600` and the process is killed on elapse. Set `timeout: 0` only for finite commands whose completion is cancellation-owned. +- `async: true` defers only reporting; it does NOT extend a nonzero timeout. +{{#if hasLaunch}}- Need a service, watcher, debugger, REPL, or later stdin? MUST use `launch`. NEVER use `cmd &`, `nohup`, or async bash as a process supervisor.{{else}}- Need a long-running process or >3600s run? Use an external process supervisor; avoid detached shell jobs you cannot later observe or stop.{{/if}} {{/if}} {{#if autoBackgroundEnabled}} diff --git a/packages/coding-agent/src/prompts/tools/browser.md b/packages/coding-agent/src/prompts/tools/browser.md index e0f6d22ff..27121f2b9 100644 --- a/packages/coding-agent/src/prompts/tools/browser.md +++ b/packages/coding-agent/src/prompts/tools/browser.md @@ -5,7 +5,7 @@ Drives real Chromium tab; full puppeteer access via JS. - Three actions: - `open` — acquire/reuse named tab (`name` defaults `"main"`). Optional `url` (navigate once ready), `viewport`, `dialogs: "accept" | "dismiss"` (auto-handle `alert`/`confirm`/`beforeunload`; else page hangs till you wire `page.on('dialog', …)`). - `close` — release tab by `name`, or all with `all: true`. `kill: true` also kills spawned-app process trees. - - `run` — execute JS in existing tab. `code` = async function body; `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Return value JSON-stringified into result; `display(value)` accumulates text/images. + - `run` — execute JS in existing tab. `code` = async function body; `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Return value JSON-stringified into result; `display(value)` accumulates text/images. `wait(ms)` sleeps; `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until truthy and resolves with that value (default 100ms interval; deadline min(30s, cell budget − 1s), named error on timeout) — use it instead of in-page polling Promises inside `tab.evaluate`. - Tabs survive `run` calls and in-process subagents — open once, reuse. - Browser kinds (`app` on `open`): - default (no `app`) → headless Chromium with stealth patches. @@ -13,7 +13,7 @@ Drives real Chromium tab; full puppeteer access via JS. - `app.cdp_url` → connect to existing CDP endpoint (e.g. `http://127.0.0.1:9222`). - `app.target` (with `path`/`cdp_url`) — substring on url+title picks BrowserWindow. - `tab` helpers; drop to raw puppeteer `page` for anything uncovered: - - `tab.goto(url, { waitUntil? })` — navigate. + - `tab.goto(url, { waitUntil? })` — navigate. A hung load fails ~1s before the cell budget with a named, catchable error and the pending navigation is stopped; for slow pages raise `timeout` or use `waitUntil: "domcontentloaded"`. - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot: `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Ids stable until next observe/goto. - `tab.ariaSnapshot(selector?, { depth?, boxes? })` — Playwright-format ARIA-tree YAML (nested roles + accessible names + `/url`/`/placeholder`), scoped to `selector` or the whole document. Every node carries a `[ref=eN]` id; `[cursor=pointer]` flags clickables. Captures dense, hierarchical structure/text that `observe()`'s flat list flattens away. Refs renumber from e1 each call and stay valid until the next `ariaSnapshot()`. - `tab.ref("e5")` — `[ref=eN]` from the last ariaSnapshot → element handle with the common action methods (`.click()`, `.type()`, `.fill()`, `.hover()`, `.evaluate()`, …); the primary way to act on a ref. For convenience `aria-ref=e5` also works inline in `tab.click`/`type`/`fill`/`waitFor`/`scrollIntoView` (e.g. `tab.click("aria-ref=e5")`). @@ -27,10 +27,10 @@ Drives real Chromium tab; full puppeteer access via JS. - `tab.waitForUrl(pattern, { timeout? })` — substring or `RegExp` (matches SPA pushState nav); returns matched URL. - `tab.waitForResponse(pattern, { timeout? })` — substring, `RegExp`, or `(response) => boolean`; returns puppeteer `HTTPResponse` (`.text()`/`.json()`/`.status()`/`.headers()`). - `tab.waitForNavigation({ waitUntil?, timeout? })` — resolves on the next navigation. Start it BEFORE the click/submit that triggers it; after `tab.goto` (which already waits) use `tab.waitForUrl`/`tab.waitForSelector` instead. - - `tab.evaluate(fn, …args)` — `page.evaluate` for ad-hoc DOM reads. + - `tab.evaluate(fn, …args)` — run ad-hoc code in the page's MAIN world. DOM and page-defined globals (`window.myFlag`) are visible; mutations affect the page. - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file. - `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable. -- Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.` error carrying a match-count diagnosis, never the whole-cell timeout; a selector matching nothing fails in ~2s (pass an explicit `{ timeout }` to `waitFor`/`waitForSelector` to wait out slow-appearing elements). +- Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.` error carrying a match-count diagnosis, never the whole-cell timeout; a selector matching nothing fails in ~2s (pass an explicit `{ timeout }` to `waitFor`/`waitForSelector` to wait out slow-appearing elements). A whole-cell timeout names the stalled op (including `wait(…)`) and any unhandled dialog blocking the page. diff --git a/packages/coding-agent/src/prompts/tools/launch.md b/packages/coding-agent/src/prompts/tools/launch.md new file mode 100644 index 000000000..615e4f9e5 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/launch.md @@ -0,0 +1,25 @@ +Launches and controls project-scoped long-running processes shared by every omp instance in the same directory. + + +- Long-running service, watcher, debugger, REPL, or process needing later input? MUST use `launch`, not `bash`. +- `start` launches `application` + `args` directly. `cwd` defaults to the session directory; `pty` defaults true. +- `ready.log` is a regex; `ready.port` is a TCP port. Both supplied? BOTH MUST pass. `ready.timeout` is seconds. +- Names are unique per project directory. A completed name MAY be started again; a live name MUST be stopped or restarted. +- `list`, `logs`, `wait`, `send`, `stop`, `restart`, and `describe` address the stable `name`. +- `logs` defaults to the last 100 lines. `head: true` reads the beginning. `grep` is a regex. +- `logs` with `follow: true` waits for output after `cursor`; reuse the returned cursor on the next call. +- `wait` blocks until readiness/exit/pattern or timeout. Use it only when blocked; do useful work instead of tight polling. +- `send.text` writes stdin; `enter` defaults true. `keys` supports ENTER, TAB, ESCAPE, CTRL_C, CTRL_D, UP, DOWN, LEFT, RIGHT. +- `send.signal` supports SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGKILL. PTY input is serialized; many clients MAY observe, but writes share one input stream. +- `stop` performs graceful process-tree termination before hard-kill. `restart` reuses the retained launch spec. +- `restart` policy defaults `no`; `on-failure` and `always` use bounded backoff. +- `persist: true` opts out of last-omp teardown. Otherwise the broker stops every non-persistent supervised process after the last omp in this directory exits. +- `detached: true` survives broker shutdown and all omp exits. It implies `persist` and disables PTY/stdin. + + + +- Long-running work MUST use `launch`, not async/background bash. +- Readiness MUST be observed; process creation alone is not readiness. +- Omit `persist` and `detached` unless their survival guarantees are required. +- Use `stop`; NEVER kill an unverified PID through bash. + diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index d03b414a4..b6abff059 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -109,7 +109,7 @@ import { obfuscateProviderContext, SecretObfuscator, } from "./secrets"; -import { AgentSession } from "./session/agent-session"; +import { AgentSession, type Downshift, type PlanYolo } from "./session/agent-session"; import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config"; import type { AuthStorage } from "./session/auth-storage"; import { @@ -407,6 +407,10 @@ export interface CreateAgentSessionOptions { thinkingLevel?: ConfiguredThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + /** Downshift from the starting model to a fast/cheap target at the first edit/write once the todo list exists. */ + downshift?: Downshift; + /** Force read-only plan mode at start, auto-approve on the model's first resolve call, then switch to execute. */ + planYolo?: PlanYolo; /** Provider-facing system prompt override. Replaces the fully rendered default blocks. */ systemPrompt?: string | string[] | ((defaultPrompt: string[]) => string | string[]); @@ -2863,6 +2867,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} agent, pruneToolDescriptions: inlineToolDescriptors, thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel, + downshift: options.downshift, + planYolo: options.planYolo, serviceTierByFamily: initialServiceTierByFamily, sessionManager, settings, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4dc6b659a..6e81ea1af 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -30,6 +30,7 @@ import { type AgentMessage, type AgentState, type AgentTool, + type AgentToolResult, type AgentTurnEndContext, AppendOnlyContextManager, type AsideMessage, @@ -251,6 +252,7 @@ import { parseTurnBudget } from "../modes/turn-budget"; import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink"; import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage"; import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow"; +import { resolveApprovedPlan } from "../plan-mode/approved-plan"; import { createPlanReadMatcher } from "../plan-mode/plan-protection"; import type { PlanModeState } from "../plan-mode/state"; import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" }; @@ -258,6 +260,9 @@ import goalModeContextPrompt from "../prompts/goals/goal-mode-context.md" with { import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" }; import parentIrcSteerTemplate from "../prompts/steering/parent-irc.md" with { type: "text" }; import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" }; +import downshiftChecklistPrompt from "../prompts/system/downshift-checklist.md" with { type: "text" }; +import downshiftContinuePrompt from "../prompts/system/downshift-continue.md" with { type: "text" }; +import downshiftPlanPrompt from "../prompts/system/downshift-plan.md" with { type: "text" }; import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" }; import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" }; import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" }; @@ -271,6 +276,7 @@ import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" w import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with { type: "text", }; +import planYoloHandoffPrompt from "../prompts/system/plan-yolo-handoff.md" with { type: "text" }; import rewindReportTemplate from "../prompts/system/rewind-report.md" with { type: "text" }; import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" }; import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" }; @@ -319,7 +325,7 @@ import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint" import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta"; import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils"; import { isAutoQaEnabled } from "../tools/report-tool-issue"; -import { buildResolveReminderMessage } from "../tools/resolve"; +import { buildResolveReminderMessage, type ResolveToolDetails, runResolveInvocation } from "../tools/resolve"; import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo"; import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { clampTimeout } from "../tools/tool-timeouts"; @@ -414,7 +420,31 @@ const MID_RUN_TODO_NUDGE_MUTATING_TOOLS: Record = { /** `customType` for the hidden mid-run todo nudge; `display: false`, so it reaches * the model but never renders in the TUI or transcript. */ const MID_RUN_TODO_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge"; - +/** Hidden plan nudge injected by downshift; scrubbed from the LLM context + * when the switch happens. */ +const DOWNSHIFT_PLAN_MESSAGE_TYPE = "downshift-plan"; +/** Hidden safety-net nudge forcing one more turn after a text-only reply to + * the plan nudge, which would otherwise end the run with no code written. */ +const DOWNSHIFT_CONTINUE_MESSAGE_TYPE = "downshift-continue"; +/** Hidden "verify before finishing" checklist steered into the run at the + * switch, aimed at the fast model's specific failure patterns: partial + * multi-site fixes, unnecessarily broad rewrites, and reported-test-only + * verification. */ +const DOWNSHIFT_CHECKLIST_MESSAGE_TYPE = "downshift-checklist"; +/** Tools whose first successful call triggers the switch — once the todo + * gate is open (see {@link AgentSession.#downshiftTodoSeen}). Bash is + * deliberately excluded: it doubles as exploration (ls/cat) and fired + * turn-1 switches in practice. `todo` is deliberately NOT a trigger: firing + * at the todo init handed the fast model 100% of the implementation with + * zero started work and measurably regressed pass rates. */ +const DOWNSHIFT_ACTION_TOOLS: Record = { + edit: true, + write: true, +}; +/** `customType` for the hidden hand-off message steered to the target model + * once PlanYolo auto-approves the plan. Unlike downshift's plan nudge this + * is never scrubbed — it IS the instruction the target model acts on. */ +const PLAN_YOLO_HANDOFF_MESSAGE_TYPE = "plan-yolo-handoff"; /** Abort reason for the Gemini reasoning-header runaway interrupt. Surfaced on the * discarded assistant turn only; never reaches the model. */ const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning"; @@ -600,8 +630,9 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = { /** * User-facing notice for a compaction dead end: maintenance freed too little - * to retry safely. `remedies` names the recovery actions available on the - * emitting path (the shake-rescue path can additionally offer `/shake images`). + * to retry safely. `remedies` names the recovery actions left on the emitting + * path — by the time the post-pass dead end fires, the tiered rescue has + * already attempted both elide and image-drop automatically. */ function compactionDeadEndWarning(remedies: string): string { return ( @@ -681,6 +712,34 @@ export interface AsyncJobSnapshot { } export type { ShakeMode, ShakeResult }; +/** + * Downshift: switches an active session one-way from its starting model to + * a fast/cheap `target` at the first completed turn that runs an edit/write + * tool once the todo list exists. A hidden plan nudge asks the starting + * model to write a plan, initialize its todo list from it, and start; the + * todo call opens the trigger gate (it never fires the switch itself), so + * the starting model always begins the implementation. A hidden + * checklist nudge asks the target model to verify its work before + * finishing. Both are always on — this is the one mechanism that won out + * over turn-count and ungated variants in testing. + */ +export interface Downshift { + target: Model; + thinkingLevel?: ConfiguredThinkingLevel; +} + +/** + * PlanYolo: forces the session into read-only plan mode at start, then + * auto-approves the plan the instant the model calls `resolve({ action: + * "apply" })` for it — no interactive review — and switches to a fast/cheap + * `target` model to implement it. The headless counterpart to interactive + * plan mode's "Approve and execute", for print/non-interactive runs where + * there is no one to click Approve. + */ +export interface PlanYolo { + target: Model; + thinkingLevel?: ConfiguredThinkingLevel; +} // ============================================================================ // Types @@ -696,6 +755,12 @@ export interface AgentSessionConfig { scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; /** Initial session thinking selector. */ thinkingLevel?: ConfiguredThinkingLevel; + /** Downshift from the starting model to a fast/cheap target at the first edit/write once the todo list exists. */ + downshift?: Downshift; + /** Force read-only plan mode at start, auto-approve on the model's first + * `resolve` call, then switch to the target to implement. */ + planYolo?: PlanYolo; + /** Initial per-family service tiers (OpenAI / Anthropic / Google) for the live session. */ serviceTierByFamily?: ServiceTierByFamily; /** Prompt templates for expansion */ @@ -1066,6 +1131,7 @@ export interface FreshSessionResult { /** Standard thinking levels */ +/** `retry.fallbackChains` config: chain key (role name or model selector) → ordered fallback selectors. */ type RetryFallbackChains = Record; type RetryFallbackRevertPolicy = "never" | "cooldown-expiry"; @@ -1078,6 +1144,7 @@ interface RetryFallbackSelector { } interface ActiveRetryFallbackState { + /** Chain key that produced this fallback: a model-role name or a model-selector key. */ role: string; originalSelector: string; originalThinkingLevel: ConfiguredThinkingLevel | undefined; @@ -1105,6 +1172,24 @@ function parseRetryFallbackSelector( }; } +/** + * `retry.fallbackChains` keys are either model-role names (`smol`, `default`) + * or model selectors (`provider/model-id[:thinking]`). Role names never + * contain a slash, so its presence marks a model-keyed chain whose primary is + * the key itself — the chain follows the model across role reassignments. + */ +function isRetryFallbackModelKey(key: string): boolean { + return key.includes("/"); +} + +/** + * A `provider/*` fallback-chain key: matches any active model of that provider, + * so one entry covers every current and future model behind the provider. + */ +function isRetryFallbackWildcardKey(key: string): boolean { + return key.endsWith("/*"); +} + function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string { return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel); } @@ -1593,6 +1678,17 @@ export class AgentSession { #autoThinking: boolean = false; /** The level `auto` last resolved to (for UI); undefined until a turn is classified. */ #autoResolvedLevel: Effort | undefined; + #downshift: Downshift | undefined; + /** True once the plan nudge has been queued; scrubbed from context at the switch. */ + #downshiftPlanInjected = false; + /** True once any successful `todo` call landed — opens the downshift + * trigger gate: the switch fires at the first edit/write AFTER the todo + * list exists (sessions without a todo tool skip the gate). */ + #downshiftTodoSeen = false; + #planYolo: PlanYolo | undefined; + #planYoloPreviousTools: string[] | undefined; + #planYoloArmed = false; + #promptTemplates: PromptTemplate[]; #slashCommands: FileSlashCommand[]; @@ -2082,6 +2178,253 @@ export class AgentSession { this.#emit(pending); } + /** Advance the one-way downshift switch at a completed assistant-turn boundary. */ + async #advanceDownshift(liveMessages: AgentMessage[], context: AgentTurnEndContext | undefined): Promise { + const downshift = this.#downshift; + if (!downshift || context?.message.role !== "assistant") return; + + // Structural safety net: every branch below assumes the agent loop will + // run another turn. It won't if THIS turn had no tool calls — the loop + // treats a text-only turn as "the agent is done" and ends the session + // with no further prompting. The plan nudge explicitly asks for a prose + // reply, which makes a text-only turn common right after it — observed + // silently killing production SWE-bench runs before any code was ever + // written. Force one more turn only in that specific, self-created + // hazard window. + if (this.#downshiftPlanInjected && context.toolResults.length === 0) { + this.agent.steer({ + role: "custom", + customType: DOWNSHIFT_CONTINUE_MESSAGE_TYPE, + content: downshiftContinuePrompt, + attribution: "agent", + display: false, + timestamp: Date.now(), + }); + } + + // Todo gate: the plan nudge instructs "finish the plan, then init the + // todo list from it and start" — so the switch waits until a todo list + // exists AND the model has actually started implementing (first + // edit/write). The todo call itself never triggers: firing there handed + // the fast model the whole implementation cold. Sessions without a todo + // tool skip the gate. + if (context.toolResults.some(result => result.toolName === "todo")) { + this.#downshiftTodoSeen = true; + } + const todoGateOpen = this.#downshiftTodoSeen || !this.#toolRegistry.has("todo"); + const action = todoGateOpen + ? context.toolResults.find(result => DOWNSHIFT_ACTION_TOOLS[result.toolName]) + : undefined; + if (!action) { + if (!this.#downshiftPlanInjected) { + this.#downshiftPlanInjected = true; + this.agent.steer({ + role: "custom", + customType: DOWNSHIFT_PLAN_MESSAGE_TYPE, + content: downshiftPlanPrompt, + display: false, + attribution: "agent", + timestamp: Date.now(), + }); + this.emitNotice("info", "Downshift: injected deep-plan nudge.", "downshift"); + } + return; + } + + await this.#waitForSessionMessagePersistence(context.message); + for (const toolResult of context.toolResults) { + await this.#waitForSessionMessagePersistence(toolResult); + } + + this.#scrubDownshiftPlanNudge(liveMessages); + const target = downshift.target; + if (this.model && modelsAreEqual(this.model, target)) { + this.#downshift = undefined; + return; + } + + await this.setModelTemporary(target, downshift.thinkingLevel, { ephemeral: true }); + this.#downshift = undefined; + this.emitNotice( + "info", + `Downshift: switched to ${target.provider}/${target.id} after first ${action.toolName} call.`, + "downshift", + ); + this.agent.steer({ + role: "custom", + customType: DOWNSHIFT_CHECKLIST_MESSAGE_TYPE, + content: downshiftChecklistPrompt, + attribution: "agent", + display: false, + timestamp: Date.now(), + }); + } + + /** + * Arm downshift outside the normal startup path (the `/downshift` slash + * command): sets the target and immediately steers the plan nudge rather + * than waiting for the next turn boundary, since an explicit manual + * invocation means "start this now." A no-op with a notice if a downshift + * is already armed and waiting. + */ + armDownshift(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void { + if (this.#downshift) { + this.emitNotice( + "info", + `Downshift: already armed for ${this.#downshift.target.provider}/${this.#downshift.target.id}, waiting for the first edit/write.`, + "downshift", + ); + return; + } + this.#downshift = { target, thinkingLevel }; + this.#downshiftPlanInjected = true; + this.agent.steer({ + role: "custom", + customType: DOWNSHIFT_PLAN_MESSAGE_TYPE, + content: downshiftPlanPrompt, + display: false, + attribution: "agent", + timestamp: Date.now(), + }); + this.emitNotice( + "info", + `Downshift: armed for ${target.provider}/${target.id} — will switch at the first edit/write once the todo list exists.`, + "downshift", + ); + } + + /** + * Remove the plan nudge from the LLM context before the model switch: the + * fast model inherits the plan the nudge produced, not the nudge itself. + * Splices the loop's live context array in place (the run streams from + * it) and mirrors the removal into agent state. The persisted transcript + * keeps the message for audit; a session reload re-materializes it, + * which is acceptable for downshift's single-run lifecycle. + */ + #scrubDownshiftPlanNudge(liveMessages: AgentMessage[]): void { + if (!this.#downshiftPlanInjected) return; + const isPlanNudge = (m: AgentMessage): boolean => + m.role === "custom" && m.customType === DOWNSHIFT_PLAN_MESSAGE_TYPE; + for (let i = liveMessages.length - 1; i >= 0; i--) { + if (isPlanNudge(liveMessages[i])) liveMessages.splice(i, 1); + } + const stateMessages = this.agent.state.messages; + const filtered = stateMessages.filter(m => !isPlanNudge(m)); + if (filtered.length !== stateMessages.length) this.agent.replaceMessages(filtered); + } + + /** + * Lazily arm PlanYolo before the first prompt is built: restricts tools to + * the plan-mode read-only set (plus `resolve`/`write`, both normally + * discovery-hidden), marks plan-mode state so `#buildPlanModeMessage` + * injects the standard plan-mode-active instructions on this and every + * following prompt, and registers the auto-approve resolve handler. + * Idempotent — a no-op once armed or when PlanYolo is not configured. + */ + async #armPlanYoloIfNeeded(): Promise { + if (!this.#planYolo || this.#planYoloArmed) return; + this.#planYoloArmed = true; + const previousTools = this.getActiveToolNames(); + const augmentations = ["resolve"]; + if (this.hasBuiltInTool("write")) augmentations.push("write"); + await this.setActiveToolsByName([...new Set([...previousTools, ...augmentations])]); + this.#planYoloPreviousTools = previousTools; + this.setPlanModeState({ + enabled: true, + planFilePath: this.getPlanReferencePath() || "local://PLAN.md", + workflow: "parallel", + }); + this.setStandingResolveHandler(input => this.#runPlanYoloApprovalResolve(input)); + } + + /** + * Standing resolve handler while PlanYolo's plan phase is active. Auto- + * approves the instant the model calls `resolve { action: "apply" }` for + * the plan — no interactive review, the headless counterpart to plan + * mode's "Approve and execute" — then restores tools, exits plan-mode + * state, switches to the configured `target`, and hands off the approved + * plan for it to implement. + */ + #runPlanYoloApprovalResolve(input: unknown): Promise> { + return runResolveInvocation(input as Parameters[0], { + sourceToolName: "plan_approval", + label: "Plan ready for approval", + apply: async (_reason, extra) => { + const planYolo = this.#planYolo; + const state = this.getPlanModeState(); + if (!planYolo || !state?.enabled) { + throw new ToolError("Plan mode is not active."); + } + const { planFilePath, title } = await resolveApprovedPlan({ + suppliedTitle: extra?.title, + statePlanFilePath: state.planFilePath, + readPlan: url => this.#readPlanYoloFile(url), + listPlanFiles: () => this.#listPlanYoloFiles(), + }); + const previousTools = this.#planYoloPreviousTools; + if (previousTools) { + await this.setActiveToolsByName(previousTools); + } + this.setStandingResolveHandler(null); + this.setPlanModeState(undefined); + this.#planYolo = undefined; + this.#planYoloPreviousTools = undefined; + await this.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true }); + this.emitNotice( + "info", + `Plan-yolo: plan approved, switched to ${planYolo.target.provider}/${planYolo.target.id} to implement "${title}".`, + "plan-yolo", + ); + this.agent.steer({ + role: "custom", + customType: PLAN_YOLO_HANDOFF_MESSAGE_TYPE, + content: prompt.render(planYoloHandoffPrompt, { planFilePath, title }), + attribution: "agent", + display: false, + timestamp: Date.now(), + }); + return { + content: [ + { type: "text" as const, text: `Plan approved. Implementing now with ${planYolo.target.id}.` }, + ], + details: { planFilePath, title, planExists: true }, + }; + }, + }); + } + + async #readPlanYoloFile(planFilePath: string): Promise { + const resolvedPath = planFilePath.startsWith("local:") + ? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), this.#localProtocolOptions()) + : resolveToCwd(planFilePath, this.sessionManager.getCwd()); + try { + return await Bun.file(resolvedPath).text(); + } catch (error) { + if (isEnoent(error)) return null; + throw error; + } + } + + /** `local://` URLs of plan files in the session-local root, newest first — + * a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */ + async #listPlanYoloFiles(): Promise { + const localRoot = resolveLocalUrlToPath("local://", this.#localProtocolOptions()); + try { + const entries = await fs.promises.readdir(localRoot, { withFileTypes: true }); + const plans = await Promise.all( + entries + .filter(entry => entry.isFile() && /plan\.md$/i.test(entry.name)) + .map(async entry => { + const stat = await fs.promises.stat(path.join(localRoot, entry.name)).catch(() => null); + return { url: `local://${entry.name}`, mtime: stat?.mtimeMs ?? 0 }; + }), + ); + return plans.sort((a, b) => b.mtime - a.mtime).map(plan => plan.url); + } catch { + return []; + } + } + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; @@ -2102,7 +2445,14 @@ export class AgentSession { } else { this.#thinkingLevel = config.thinkingLevel; } + if (config.downshift) { + this.#downshift = config.downshift; + } + if (config.planYolo) { + this.#planYolo = config.planYolo; + } this.#applyThinkingLevelToAgent(this.#thinkingLevel); + this.#promptTemplates = config.promptTemplates ?? []; this.#slashCommands = config.slashCommands ?? []; this.#extensionRunner = config.extensionRunner; @@ -2175,6 +2525,7 @@ export class AgentSession { }); if (detection) this.#maybeInjectToolCallLoopRedirect(messages, detection); } + await this.#advanceDownshift(messages, context); this.#advisorPrimaryTurnsCompleted++; if (this.#advisors.length > 0) { for (const a of this.#advisors) { @@ -3855,6 +4206,16 @@ export class AgentSession { if (event.message.role === "assistant") { this.#lastAssistantMessage = event.message; const assistantMsg = event.message as AssistantMessage; + // Fold this turn's timing into per-model perf aggregates (drives the + // /models TPS/TTFT display). Errored turns measure nothing; aborted + // turns with reported usage are still valid throughput samples. + if (assistantMsg.stopReason !== "error" && assistantMsg.duration !== undefined) { + this.settings.getStorage()?.recordModelPerf(`${assistantMsg.provider}/${assistantMsg.model}`, { + outputTokens: assistantMsg.usage.output, + durationMs: assistantMsg.duration, + ttftMs: assistantMsg.ttft, + }); + } if ( assistantMsg.disabledFeatures?.includes("priority") && this.#serviceTierByFamily.anthropic === "priority" @@ -4141,6 +4502,17 @@ export class AgentSession { await emitAgentEndNotification(); return; } + } else if (this.#isHardErrorFallbackEligible(msg)) { + // A non-retryable hard error on a model covered by a configured + // fallback chain: retrying the SAME model is pointless, but a + // DIFFERENT model is a fresh chance — consult the chain before + // surfacing the failure. #handleRetryableError bails out (no + // backoff-retry of the failing model) when no switch happens. + const didRetry = await this.#handleRetryableError(msg, { hardErrorFallback: true }); + if (didRetry) { + await emitAgentEndNotification(); + return; + } } // Classifier refusals are persisted-skipped above; also prune the trailing // stub from active context so the next turn's prompt does not replay it. @@ -7876,6 +8248,8 @@ export class AgentSession { await this.#checkCompaction(lastAssistant, false, false, false); } + await this.#armPlanYoloIfNeeded(); + // Build messages array (session context, eager todo prelude, then active prompt message) const messages: AgentMessage[] = []; const planReferenceMessage = await this.#buildPlanReferenceMessage?.(); @@ -8759,12 +9133,7 @@ export class AgentSession { #syncTodoPhasesFromBranch(): void { const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch()); - // Strip completed/abandoned tasks — they were done in a previous run, - // so they have no bearing on progress tracking for the new turn. - for (const phase of phases) { - phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned"); - } - this.setTodoPhases(phases.filter(p => p.tasks.length > 0)); + this.setTodoPhases(phases); } #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] { @@ -9651,6 +10020,11 @@ export class AgentSession { * candidate is small or the session has been idle long enough that the * provider prompt cache is cold), so it is cheap to run every turn. Gated * on the `compaction.supersedeReads` and `compaction.dropUseless` settings. + * + * Persists via `rewriteEntries` like every other history rewrite — the + * session file must match the live (pruned) context or file-based forks + * (`/fork`, `/tan`) and resume rebuild a divergent prefix and cold-miss the + * provider prompt cache. */ async #pruneStaleToolResults(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const { supersedeReads, dropUseless } = this.settings.getGroup("compaction"); @@ -9673,6 +10047,7 @@ export class AgentSession { return undefined; } + await this.sessionManager.rewriteEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); this.#resetAllAdvisorRuntimes(); @@ -10114,6 +10489,7 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); + this.#rebasePendingContextSnapshotAfterCompaction(); // Compaction discarded the conversation history that carried the approved // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads // the plan from disk and re-injects it on the next turn (issue #1246). @@ -12508,49 +12884,85 @@ export class AgentSession { } /** - * Last-resort reducer when {@link #runAutoCompaction} would otherwise dead-end. - * The summarizer cut at the only available turn boundary, but the kept tail is - * still over the recovery band because a single recent turn (a large - * tool-result, a heavy fenced/XML block) is itself bigger than the band and - * `findCutPoint` cannot cut inside one message. `shake("elide")` reaches INSIDE - * that tail — it offloads heavy tool-result / block content to one - * `artifact://` blob and leaves a recoverable placeholder — so residual context - * genuinely drops instead of the guard pausing maintenance and looping the - * warning. Without it the guard would pause/warn here; with it the caller - * re-tests its progress predicate after the elide pass and only falls through - * to the warning when residual stays over. + * Last-resort tiered reducer when {@link #runAutoCompaction} would otherwise + * dead-end. The summarizer cut at the only available turn boundary, but the + * kept tail is still over the recovery band because a single recent turn (a + * large tool-result, a heavy fenced/XML block, attached images) is itself + * bigger than the band and `findCutPoint` cannot cut inside one message. * - * Image-only tails are out of scope: `collectShakeRegions` skips image-only - * tool results and user-message images aren't counted by the local estimate - * that gates the dead-end, so those still surface the warning (remedy: - * `/shake images`). + * Tier 1 — `shake("elide")` reaches INSIDE that tail: heavy tool-result / + * block content is offloaded to one `artifact://` blob behind a recoverable + * placeholder. Skipped when this pass already ran a shake (`skipElide`). + * Tier 2 — `dropImages()`: the manual `/shake images` remedy, automated. + * Image blocks are stripped from the branch; unlike elided text they are NOT + * artifact-recoverable, so this tier only runs once elide has failed the + * progress re-test. * - * Returns the elide {@link ShakeResult} when something was offloaded (so the - * caller can re-test and report), or `undefined` when nothing was eligible or - * the pass aborted/failed. + * Each tier that rewrote history re-anchors the in-flight context snapshot, + * then the caller's progress predicate is re-tested; the first tier that + * restores progress emits one info notice describing everything freed and + * stops. Returns whether progress was restored — `false` falls through to + * the dead-end warning. */ - async #tryShakeRescueForDeadEnd(signal: AbortSignal): Promise { - if (signal.aborted) return undefined; + async #rescueCompactionDeadEnd( + signal: AbortSignal, + options: { skipElide: boolean; hasProgress: () => boolean }, + ): Promise { + if (signal.aborted) return false; + let elided = 0; + let elidedTokens = 0; + let elideSink = "placeholders"; + if (!options.skipElide) { + try { + const result = await this.shake("elide", { signal }); + elided = result.toolResultsDropped + result.blocksDropped; + elidedTokens = result.tokensFreed; + if (result.artifactId) elideSink = "an artifact"; + if (elided > 0) { + // The elide pass rewrote history; re-anchor the in-flight snapshot + // so the caller's headroom/retry-fit re-test measures the shaken + // context. + this.#rebasePendingContextSnapshotAfterCompaction(); + } + } catch (error) { + logger.warn("Dead-end shake rescue failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + if (elided > 0 && options.hasProgress()) { + this.emitNotice( + "info", + `Compaction dead-end recovery: ${this.#describeElideRescue(elided, elidedTokens, elideSink)} so maintenance could make progress.`, + "compaction", + ); + return true; + } + } + if (signal.aborted) return false; + let imagesDropped = 0; try { - const result = await this.shake("elide", { signal }); - return result.toolResultsDropped + result.blocksDropped > 0 ? result : undefined; + imagesDropped = (await this.dropImages()).removed; + if (imagesDropped > 0) this.#rebasePendingContextSnapshotAfterCompaction(); } catch (error) { - logger.warn("Dead-end shake rescue failed", { + logger.warn("Dead-end image-drop rescue failed", { error: error instanceof Error ? error.message : String(error), }); - return undefined; } + if (imagesDropped > 0 && options.hasProgress()) { + const elidedPart = elided > 0 ? `${this.#describeElideRescue(elided, elidedTokens, elideSink)} and ` : ""; + this.emitNotice( + "info", + `Compaction dead-end recovery: ${elidedPart}dropped ${imagesDropped} attached image${imagesDropped === 1 ? "" : "s"} so maintenance could make progress.`, + "compaction", + ); + return true; + } + return false; } - /** Notice describing a successful dead-end elide rescue. */ - #emitShakeRescueNotice(result: ShakeResult): void { - const elided = result.toolResultsDropped + result.blocksDropped; - const sink = result.artifactId ? "an artifact" : "placeholders"; - this.emitNotice( - "info", - `Compaction dead-end recovery: elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${result.tokensFreed.toLocaleString()} tokens) to ${sink} so maintenance could make progress.`, - "compaction", - ); + /** Notice fragment for a dead-end elide tier: what was freed and where it went. */ + #describeElideRescue(elided: number, tokensFreed: number, sink: string): string { + return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`; } /** @@ -13058,6 +13470,7 @@ export class AgentSession { const newEntries = this.sessionManager.getEntries(); const sessionContext = this.buildDisplaySessionContext(); this.agent.replaceMessages(sessionContext.messages); + this.#rebasePendingContextSnapshotAfterCompaction(); // Compaction discarded the conversation history that carried the approved // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads // the plan from disk and re-injects it on the next turn (issue #1246). @@ -13091,23 +13504,28 @@ export class AgentSession { details, preserveData, }; - await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); - - // Post-maintenance progress guard. Snapcompact can project over budget and - // fall back to a context-full summary; the summarizer keeps `keepRecentTokens` - // of recent history verbatim and findCutPoint can only cut at turn - // boundaries (never tool results), so a single oversized recent turn (e.g. a - // huge tool result) leaves the rewritten context still above threshold. - // Scheduling the continuation regardless means the next agent_end re-enters - // #checkCompaction over the same oversized tail and re-fires forever. The - // retry and the threshold auto-continue use different progress tests (a - // recoverable overflow only has to fit; the auto-continue thrash needs the - // stricter recovery band), so each branch evaluates its own below. + // Post-maintenance progress guard — evaluated BEFORE emitting + // auto_compaction_end so the TUI rebuild triggered by that event + // already reflects any rescue rewrite (elide / image-drop) and the + // dead-end warning stamped on the compaction entry. Snapcompact can + // project over budget and fall back to a context-full summary; the + // summarizer keeps `keepRecentTokens` of recent history verbatim and + // findCutPoint can only cut at turn boundaries (never tool results), + // so a single oversized recent turn (e.g. a huge tool result) leaves + // the rewritten context still above threshold. Scheduling the + // continuation regardless means the next agent_end re-enters + // #checkCompaction over the same oversized tail and re-fires forever. + // The retry and the threshold auto-continue use different progress + // tests (a recoverable overflow only has to fit; the auto-continue + // thrash needs the stricter recovery band), so each branch evaluates + // its own below. let continuationScheduled = false; // A non-idle pass that wanted to continue (retry or auto-continue) but freed // too little for that path to proceed is a dead-end: warn once so the user // understands why maintenance paused instead of silently looping. let noProgressDeadEnd = false; + let retryFits = false; + let hasHeadroom = false; if (willRetry) { const messages = this.agent.state.messages; @@ -13123,6 +13541,7 @@ export class AgentSession { (reason === "incomplete" && lastAssistant.stopReason === "length"); if (shouldDrop) { this.agent.replaceMessages(messages.slice(0, -1)); + this.#rebasePendingContextSnapshotAfterCompaction(); } } @@ -13131,18 +13550,14 @@ export class AgentSession { // won't include) is excluded. Reusing the auto-continue recovery band // here turned recoverable overflows into manual dead-ends (#3412 review), // so use the looser fit budget. - let retryFits = this.#compactionCreatedRetryFit(); - if (!retryFits && !fallbackFromShake) { - const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal); - if (rescue && this.#compactionCreatedRetryFit()) { - retryFits = true; - this.#emitShakeRescueNotice(rescue); - } + retryFits = this.#compactionCreatedRetryFit(); + if (!retryFits) { + retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { + skipElide: fallbackFromShake, + hasProgress: () => this.#compactionCreatedRetryFit(), + }); } - if (retryFits) { - this.#scheduleAgentContinue({ delayMs: 100, generation }); - continuationScheduled = true; - } else { + if (!retryFits) { noProgressDeadEnd = true; } } else if (reason !== "idle") { @@ -13153,23 +13568,36 @@ export class AgentSession { // when auto-continue is disabled, a no-headroom threshold pass must still // block later automatic continuations (todo reminders/session_stop hooks) // from re-entering the same oversized context. - let hasHeadroom = this.#compactionCreatedHeadroom(); - if (!hasHeadroom && !fallbackFromShake) { - const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal); - if (rescue && this.#compactionCreatedHeadroom()) { - hasHeadroom = true; - this.#emitShakeRescueNotice(rescue); - } + hasHeadroom = this.#compactionCreatedHeadroom(); + if (!hasHeadroom) { + hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, { + skipElide: fallbackFromShake, + hasProgress: () => this.#compactionCreatedHeadroom(), + }); } - if (hasHeadroom) { - if (shouldAutoContinue) { - this.#scheduleAutoContinuePrompt(generation); - continuationScheduled = true; - } - } else { + if (!hasHeadroom) { noProgressDeadEnd = true; } } + + const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined; + if (deadEndWarning && savedCompactionEntry) { + // Stamp the divider: the compaction bar badges the dead-end and + // carries the full warning in its ctrl+o detail, so the pause + // stays explained even after the notice row scrolls away. + savedCompactionEntry.warning = deadEndWarning; + await this.sessionManager.rewriteEntries(); + } + + await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry }); + + if (retryFits) { + this.#scheduleAgentContinue({ delayMs: 100, generation }); + continuationScheduled = true; + } else if (hasHeadroom && shouldAutoContinue) { + this.#scheduleAutoContinuePrompt(generation); + continuationScheduled = true; + } if (!continuationScheduled && !suppressContinuation && this.agent.hasQueuedMessages()) { // Auto-compaction can complete while follow-up/steering/custom messages are waiting. // Kick the loop so queued messages are actually delivered. This remains separate @@ -13182,12 +13610,8 @@ export class AgentSession { continuationScheduled = true; } - if (noProgressDeadEnd) { - this.emitNotice( - "warning", - compactionDeadEndWarning("clear large tool output, run `/shake images` to drop attached images,"), - "compaction", - ); + if (deadEndWarning) { + this.emitNotice("warning", deadEndWarning, "compaction"); } if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION; return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE; @@ -13515,36 +13939,68 @@ export class AgentSession { const configuredChains = this.settings.get("retry.fallbackChains"); if (configuredChains === undefined) return; if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) { - const msg = "retry.fallbackChains must be a mapping of role names to selector arrays."; + const msg = "retry.fallbackChains must be a mapping of role names or model selectors to selector arrays."; logger.warn(msg); this.configWarnings.push(msg); return; } - for (const [role, chain] of Object.entries(configuredChains)) { + for (const key in configuredChains) { + const chain = (configuredChains as RetryFallbackChains)[key]; + const keyKind = isRetryFallbackModelKey(key) ? "model" : "role"; + if (keyKind === "model") { + if (isRetryFallbackWildcardKey(key)) { + const provider = key.slice(0, -2); + if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) { + const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`; + logger.warn(msg); + this.configWarnings.push(msg); + } + } else { + const parsedKey = parseRetryFallbackSelector(key, this.#modelRegistry); + if (!parsedKey) { + const msg = `Invalid model selector key in retry.fallbackChains: ${key}`; + logger.warn(msg); + this.configWarnings.push(msg); + } else if (!this.#modelRegistry.find(parsedKey.provider, parsedKey.id)) { + const msg = `retry.fallbackChains key references unknown model: ${key}`; + logger.warn(msg); + this.configWarnings.push(msg); + } + } + } if (!Array.isArray(chain)) { - const msg = `Fallback chain for role '${role}' must be an array of selector strings.`; + const msg = `Fallback chain for ${keyKind} '${key}' must be an array of selector strings.`; logger.warn(msg); this.configWarnings.push(msg); continue; } for (const selectorStr of chain) { if (typeof selectorStr !== "string") { - const msg = `Fallback chain for role '${role}' contains a non-string selector.`; + const msg = `Fallback chain for ${keyKind} '${key}' contains a non-string selector.`; logger.warn(msg); this.configWarnings.push(msg); continue; } + if (isRetryFallbackWildcardKey(selectorStr)) { + const provider = selectorStr.slice(0, -2); + if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) { + const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`; + logger.warn(msg); + this.configWarnings.push(msg); + } + continue; + } const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry); if (!parsed) { - const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`; + const msg = `Invalid fallback selector format in ${keyKind} '${key}': ${selectorStr}`; logger.warn(msg); this.configWarnings.push(msg); continue; } const exists = this.#modelRegistry.find(parsed.provider, parsed.id); if (!exists) { - const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`; + const msg = `Fallback chain for ${keyKind} '${key}' references unknown model: ${selectorStr}`; logger.warn(msg); this.configWarnings.push(msg); } @@ -13557,6 +14013,8 @@ export class AgentSession { } #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined { + if (isRetryFallbackWildcardKey(role)) return undefined; + if (isRetryFallbackModelKey(role)) return parseRetryFallbackSelector(role, this.#modelRegistry); const configuredSelector = this.settings.getModelRole(role); return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined; } @@ -13578,6 +14036,13 @@ export class AgentSession { this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs); } + /** + * Map the failing model selector to the chain key that owns it, by + * specificity: an exact model-selector key, then a `provider/*` wildcard, + * then a model role whose current assignment matches, then `default`. + * Model-oriented keys win over roles so a chain follows the model across + * role reassignments. + */ #resolveRetryFallbackRole(currentSelector: string): string | undefined { const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); if (!parsedCurrent) return undefined; @@ -13591,18 +14056,33 @@ export class AgentSession { ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent) : undefined; - for (const role of Object.keys(chains)) { - const primarySelector = this.#getRetryFallbackPrimarySelector(role); - if (primarySelector?.raw === currentSelector) return role; + const exactModelKeys: string[] = []; + const roleKeys: string[] = []; + for (const key in chains) { + if (!isRetryFallbackModelKey(key)) roleKeys.push(key); + else if (!isRetryFallbackWildcardKey(key)) exactModelKeys.push(key); } - for (const role of Object.keys(chains)) { - const primarySelector = this.#getRetryFallbackPrimarySelector(role); - if (!primarySelector) continue; - if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role; - const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector); - if (primaryBaseSelector === currentBaseSelector) return role; - if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role; + const matchesCurrent = (primary: RetryFallbackSelector | undefined): boolean => { + if (!primary) return false; + if (primary.raw === currentSelector || (currentPlainSelector && primary.raw === currentPlainSelector)) { + return true; + } + const base = formatRetryFallbackBaseSelector(primary); + return base === currentBaseSelector || (!!currentPlainBaseSelector && base === currentPlainBaseSelector); + }; + + // 1. Exact model-selector keys — most specific. + for (const key of exactModelKeys) { + if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key; } + // 2. Provider wildcard (`provider/*`) — any active model of this provider. + const wildcardKey = `${parsedCurrent.provider}/*`; + if (Array.isArray(chains[wildcardKey])) return wildcardKey; + // 3. Role keys — matched by the role's currently-assigned model. + for (const key of roleKeys) { + if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key; + } + // 4. The default chain, when default has no explicit role primary. const defaultChain = chains.default; if ( Array.isArray(defaultChain) && @@ -13614,13 +14094,45 @@ export class AgentSession { return undefined; } - #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] { - const primarySelector = this.#getRetryFallbackPrimarySelector(role); - if (!primarySelector) return []; - const chain = [primarySelector]; - const seen = new Set([primarySelector.raw]); + /** + * Parse one configured chain entry. A `provider/*` entry keeps the failing + * model's id and swaps the provider (google-antigravity/x → google/x); + * ids the target provider lacks are skipped by the candidate loop's + * registry lookup. + */ + #parseRetryFallbackChainEntry( + entry: string, + current: RetryFallbackSelector | undefined, + ): RetryFallbackSelector | undefined { + if (isRetryFallbackWildcardKey(entry)) { + if (!current) return undefined; + const provider = entry.slice(0, -2); + return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined }; + } + return parseRetryFallbackSelector(entry, this.#modelRegistry); + } + + #getRetryFallbackEffectiveChain(role: string, currentSelector?: string): RetryFallbackSelector[] { + const parsedCurrent = currentSelector + ? parseRetryFallbackSelector(currentSelector, this.#modelRegistry) + : undefined; + const seen = new Set(); + const chain: RetryFallbackSelector[] = []; + if (isRetryFallbackWildcardKey(role)) { + // A wildcard key has no fixed primary: the active model is the + // primary, followed by the configured provider-level fallbacks. + if (parsedCurrent) { + chain.push(parsedCurrent); + seen.add(parsedCurrent.raw); + } + } else { + const primarySelector = this.#getRetryFallbackPrimarySelector(role); + if (!primarySelector) return []; + chain.push(primarySelector); + seen.add(primarySelector.raw); + } for (const selector of this.#getRetryFallbackChains()[role] ?? []) { - const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry); + const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); @@ -13629,7 +14141,7 @@ export class AgentSession { } #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] { - let chain = this.#getRetryFallbackEffectiveChain(role); + let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector); const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); if (chain.length === 0 && role === "default" && parsedCurrent) { const chains = this.#getRetryFallbackChains(); @@ -13642,7 +14154,7 @@ export class AgentSession { const seen = new Set([parsedCurrent.raw]); chain = [parsedCurrent]; for (const selector of defaultChain) { - const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry); + const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); @@ -13767,6 +14279,34 @@ export class AgentSession { return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined; } + /** + * True when a turn failed with a hard (non-retryable) provider error but a + * configured `retry.fallbackChains` entry covers the active model: the same + * model is not worth retrying, yet a DIFFERENT model is a fresh chance, so + * the chain is consulted before the error becomes final. Skips failures a + * model switch cannot fix or must not replay: cancellations (abort-flavored + * errors are not model faults), context overflow (compaction's job), + * classifier refusals (chain consult is handled on the retryable path with + * `pinFallback`), and turns that already emitted a tool call (replaying + * could duplicate work). + */ + #isHardErrorFallbackEligible(message: AssistantMessage): boolean { + if (message.stopReason !== "error") return false; + const model = this.model; + if (!model) return false; + const retrySettings = this.settings.getGroup("retry"); + if (!retrySettings.enabled || !retrySettings.modelFallback) return false; + if (this.#isClassifierRefusal(message)) return false; + const id = this.#classifyRetryMessage(message); + if (AIError.is(id, AIError.Flag.Abort) || AIError.is(id, AIError.Flag.UserInterrupt)) return false; + if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false; + if (this.#hasReplayUnsafeToolOutput(message)) return false; + const currentSelector = formatRetryFallbackSelector(model, this.thinkingLevel); + const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector); + if (!role) return false; + return this.#findRetryFallbackCandidates(role, currentSelector).length > 0; + } + /** * Switch the active model from a Fireworks Fast (`-fast`) variant to its base * (Standard) id and stick there for the rest of the session — the auto @@ -13890,12 +14430,16 @@ export class AgentSession { } /** - * Handle retryable errors with exponential backoff. + * Handle retryable errors with exponential backoff, credential rotation, and + * model-fallback chains. Also entered for NON-retryable errors when a switch + * is the recovery (`fireworksFastFallback`, `hardErrorFallback`): then a + * successful model switch retries immediately, and a failed switch surfaces + * the error without a same-model backoff retry. * @returns true if retry was initiated, false if max retries exceeded or disabled */ async #handleRetryableError( message: AssistantMessage, - options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean }, + options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean; hardErrorFallback?: boolean }, ): Promise { const retrySettings = this.settings.getGroup("retry"); // The Fireworks Fast→base degrade is an intrinsic model-selection safety net, @@ -13915,20 +14459,13 @@ export class AgentSession { this.#retryResolve = resolve; } - if (this.#retryAttempt > retrySettings.maxRetries) { - await this.#persistRetryLifecycleErrorMessage(message); - // Max retries exceeded, emit final failure and reset - await this.#emitSessionEvent({ - type: "auto_retry_end", - success: false, - attempt: this.#retryAttempt - 1, - finalError: message.errorMessage, - }); - this.#clearPendingRecoveredRetryErrors(); - this.#retryAttempt = 0; - this.#resolveRetry(); // Resolve so waitForRetry() completes - return false; - } + // All attempts on the current model are spent. Don't fail yet: the + // fallback chain below gets one last consult. Credential rotation can + // consume the entire budget without the fallback branch ever running + // (every rotation sets switchedCredential and skips it), so without + // this last resort a provider-wide usage cap never fails over to the + // configured chain. + const retryBudgetExhausted = this.#retryAttempt > retrySettings.maxRetries; const errorMessage = message.errorMessage || "Unknown error"; const id = this.#classifyRetryMessage(message); @@ -13947,7 +14484,12 @@ export class AgentSession { this.#resetCurrentResponsesProviderSession("stale replay error"); } - if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) { + if ( + !retryBudgetExhausted && + this.model && + !staleOpenAIResponsesReplayError && + AIError.is(id, AIError.Flag.UsageLimit) + ) { const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage)); const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached( this.model.provider, @@ -13993,7 +14535,9 @@ export class AgentSession { const allowModelFallback = options?.allowModelFallback !== false; const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined; if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) { - if (allowModelFallback && retrySettings.modelFallback) { + // A refusal chain stops at the retry budget: the exhausted-attempt + // last resort is for provider failures, not classifier decisions. + if (allowModelFallback && retrySettings.modelFallback && !(retryBudgetExhausted && classifierRefusal)) { if (!classifierRefusal) { this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage); } @@ -14012,16 +14556,41 @@ export class AgentSession { delayMs = parsedRetryAfterMs; } } + if (retryBudgetExhausted) { + if (!switchedModel) { + await this.#persistRetryLifecycleErrorMessage(message); + // Max retries exceeded and no fallback model to switch to: emit + // final failure and reset. + await this.#emitSessionEvent({ + type: "auto_retry_end", + success: false, + attempt: this.#retryAttempt - 1, + finalError: message.errorMessage, + }); + this.#clearPendingRecoveredRetryErrors(); + this.#retryAttempt = 0; + this.#resolveRetry(); // Resolve so waitForRetry() completes + return false; + } + // The fallback model gets a fresh retry budget — leaving the spent + // counter in place would exhaust it again on its first error. + this.#retryAttempt = 1; + } if (classifierRefusal && !switchedModel) { this.#retryAttempt = 0; this.#resolveRetry(); return false; } - // Fast→base was requested but the base switch could not happen (e.g. the - // base model has no credential). Don't fall through to backing-off and - // retrying the failing fast model for a hard router error that the generic - // classifier wouldn't retry — surface it instead. - if (options?.fireworksFastFallback && !switchedModel && !this.#isRetryableError(message)) { + // A fallback switch was the whole reason we entered (Fast→base degrade or + // a hard-error chain consult) but it could not happen (e.g. no candidate + // has a credential). Don't fall through to backing-off and retrying the + // failing model for an error the generic classifier wouldn't retry — + // surface it instead. + if ( + (options?.fireworksFastFallback || options?.hardErrorFallback) && + !switchedModel && + !this.#isRetryableError(message) + ) { this.#retryAttempt = 0; this.#resolveRetry(); return false; @@ -14739,10 +15308,13 @@ export class AgentSession { // Side-channel turns must not share OpenAI/Codex append-only // conversation state with the main agent turn: IRC and /btw can run // while the main turn is mid-tool-call. Keep the prompt-cache key - // stable, but give provider routing a unique request lineage. + // stable, but give provider routing a unique request lineage. The + // shared provider state map is still required so Codex can allocate + // websocket state under that side-channel session id. sessionId: `${cacheSessionId}:side:${Snowflake.next()}`, promptCacheKey: cacheSessionId, - preferWebsockets: false, + preferWebsockets: this.#preferWebsockets, + providerSessionState: this.#providerSessionState, reasoning: toReasoningEffort(this.thinkingLevel), disableReasoning: shouldDisableReasoning(this.thinkingLevel), hideThinkingSummary: this.agent.hideThinkingSummary, @@ -15792,6 +16364,28 @@ export class AgentSession { this.#contextUsageRevision++; } + /** + * Rebase the in-flight pending context snapshot onto the current message + * set after a compaction (or its dead-end rescue) rewrote history mid-run. + * The snapshot captures the prompt as submitted at run start and lives for + * the whole run; once a compaction entry lands, every earlier usage anchor + * is hidden from {@link getContextBreakdown}, so the stale run-start figure + * would be reported as live context until the next provider response. That + * inflated residual is what the post-compaction headroom/retry-fit checks + * measure — a run that started above the recovery band then trips the + * "freed too little context" dead-end even when compaction genuinely + * shrank the context. No-op while no prompt is in flight. + */ + #rebasePendingContextSnapshotAfterCompaction(): void { + if (!this.#pendingContextSnapshot) return; + const nonMessageTokens = computeNonMessageTokens(this); + this.#setPendingContextSnapshot({ + promptTokens: nonMessageTokens + this.messages.reduce((sum, msg) => sum + estimateTokens(msg), 0), + nonMessageTokens, + cutoffCount: this.messages.length, + }); + } + #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void { if (model?.provider !== "anthropic") return; this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, { diff --git a/packages/coding-agent/src/session/agent-storage.ts b/packages/coding-agent/src/session/agent-storage.ts index abb9c7eac..345edafa7 100644 --- a/packages/coding-agent/src/session/agent-storage.ts +++ b/packages/coding-agent/src/session/agent-storage.ts @@ -8,7 +8,7 @@ import { SqliteAuthCredentialStore, type StoredAuthCredential, } from "@oh-my-pi/pi-ai"; -import { getAgentDbPath, isRecord, logger } from "@oh-my-pi/pi-utils"; +import { AsyncDrain, getAgentDbPath, getStatsDbPath, isRecord, logger } from "@oh-my-pi/pi-utils"; import type { RawSettings as Settings } from "../config/settings"; /** Row shape for settings table queries */ @@ -23,8 +23,100 @@ type ModelUsageRow = { last_used_at: number; }; -/** Bump when schema changes require migration */ -const SCHEMA_VERSION = 5; +/** Row shape for model_perf table queries */ +type ModelPerfRow = { + model_key: string; + samples: number; + output_tokens: number; + gen_ms: number; + ttft_samples: number; + ttft_ms: number; +}; + +/** Row shape read from an `omp stats` messages table during backfill. */ +type StatsMessageRow = { + rowid: number; + timestamp: number; + provider: string; + model: string; + output_tokens: number; + duration: number; + ttft: number | null; +}; + +/** Per-model running sums accumulated during a backfill walk. */ +type PerfAccum = { + samples: number; + outputTokens: number; + genMs: number; + ttftSamples: number; + ttftMs: number; +}; + +/** One completed request's timing, folded into the per-model aggregates. */ +export interface ModelPerfSample { + /** Output tokens the provider reported for the turn. */ + outputTokens: number; + /** Total request duration in milliseconds. */ + durationMs: number; + /** Time to first token in milliseconds; omit when the provider did not report one. */ + ttftMs?: number; +} + +/** Validated, insert-ready model_perf sample (see {@link normalizeModelPerfSample}). */ +type ModelPerfInsert = { + modelKey: string; + outputTokens: number; + durationMs: number; + ttftSamples: 0 | 1; + ttftMs: number; +}; + +/** Recency-weighted per-model performance averages. */ +export interface ModelPerfStats { + /** Decayed sample count backing the averages. */ + samples: number; + /** Average output tokens/sec over the total request duration. */ + tps: number; + /** Average time-to-first-token in milliseconds; null when no sample reported one. */ + ttftMs: number | null; +} + +/** + * Decay threshold for model_perf running sums: once a model accumulates this + * many samples, each new sample first halves every aggregate, turning the + * plain average into a recency-weighted one (provider speeds drift over time). + */ +const MODEL_PERF_DECAY_AT = 256; +/** meta-table marker set once historical stats.db rows have been imported into model_perf. */ +const MODEL_PERF_BACKFILL_KEY = "model_perf_backfill"; +/** Batch window for deferred model_perf writes; matches prompt-history's drain cadence. */ +const MODEL_PERF_FLUSH_DELAY_MS = 100; +/** Backfill ignores stats.db history older than this; decay makes stale provider speeds worthless anyway. */ +const MODEL_PERF_BACKFILL_MAX_AGE_MS = 90 * 86_400_000; +/** Rows fetched per synchronous backfill chunk — keeps per-chunk event-loop blocking under ~20ms even on cold I/O. */ +const MODEL_PERF_BACKFILL_CHUNK = 2048; +/** Hard ceiling on rows scanned per backfill run, whatever the age cutoff admits — bounds total CPU on very high-volume databases (models only seen earlier than the newest N measurable rows get no backfill). */ +const MODEL_PERF_BACKFILL_MAX_ROWS = 250_000; + +/** + * Validates one request timing and shapes it for the model_perf upsert. + * Returns null for unmeasurable samples (no tokens, no duration). Out-of-range + * TTFT (>= duration) is bogus latency data; the sample still measures throughput. + */ +function normalizeModelPerfSample(modelKey: string, sample: ModelPerfSample): ModelPerfInsert | null { + const { outputTokens, durationMs } = sample; + if (!Number.isFinite(outputTokens) || outputTokens <= 0) return null; + if (!Number.isFinite(durationMs) || durationMs <= 0) return null; + const ttftMs = + sample.ttftMs !== undefined && Number.isFinite(sample.ttftMs) && sample.ttftMs > 0 && sample.ttftMs < durationMs + ? sample.ttftMs + : undefined; + return { modelKey, outputTokens, durationMs, ttftSamples: ttftMs !== undefined ? 1 : 0, ttftMs: ttftMs ?? 0 }; +} + +/** Current agent.db schema version; bump when schema changes require migration. */ +export const SCHEMA_VERSION = 6; const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; /** Singleton instances per database path */ @@ -42,9 +134,18 @@ export class AgentStorage { #listSettingsStmt: Statement; #upsertModelUsageStmt: Statement; #listModelUsageStmt: Statement; + #upsertModelPerfStmt: Statement; + #listModelPerfStmt: Statement; #modelUsageCache: string[] | null = null; + /** Only the real user db auto-imports stats.db history; custom paths (tests, embedding) opt in explicitly. */ + #autoPerfBackfill: boolean; + /** One backfill *check* per process; the persistent gate is the meta marker. */ + #perfBackfillChecked = false; + /** Coalesces per-turn perf samples into one deferred transaction off the turn's hot path. */ + #perfDrain = new AsyncDrain(MODEL_PERF_FLUSH_DELAY_MS); private constructor(dbPath: string) { + this.#autoPerfBackfill = dbPath === getAgentDbPath(); this.#ensureDir(dbPath); try { this.#db = new Database(dbPath); @@ -72,6 +173,22 @@ export class AgentStorage { this.#listModelUsageStmt = this.#db.prepare( "SELECT model_key, last_used_at FROM model_usage ORDER BY last_used_at DESC", ); + // Recency-weighted upsert: past MODEL_PERF_DECAY_AT samples, every new + // sample first halves the aggregates so old measurements fade out. + this.#upsertModelPerfStmt = this.#db.prepare( + `INSERT INTO model_perf (model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms, updated_at) +VALUES (?1, 1, ?2, ?3, ?4, ?5, ${SQLITE_NOW_EPOCH}) +ON CONFLICT(model_key) DO UPDATE SET + samples = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.samples / 2 ELSE model_perf.samples END) + 1, + output_tokens = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.output_tokens * 0.5 ELSE model_perf.output_tokens END) + excluded.output_tokens, + gen_ms = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.gen_ms * 0.5 ELSE model_perf.gen_ms END) + excluded.gen_ms, + ttft_samples = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.ttft_samples * 0.5 ELSE model_perf.ttft_samples END) + excluded.ttft_samples, + ttft_ms = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.ttft_ms * 0.5 ELSE model_perf.ttft_ms END) + excluded.ttft_ms, + updated_at = ${SQLITE_NOW_EPOCH}`, + ); + this.#listModelPerfStmt = this.#db.prepare( + "SELECT model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms FROM model_perf", + ); } /** @@ -93,6 +210,21 @@ CREATE TABLE IF NOT EXISTS model_usage ( last_used_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) ); +CREATE TABLE IF NOT EXISTS model_perf ( + model_key TEXT PRIMARY KEY, + samples REAL NOT NULL DEFAULT 0, + output_tokens REAL NOT NULL DEFAULT 0, + gen_ms REAL NOT NULL DEFAULT 0, + ttft_samples REAL NOT NULL DEFAULT 0, + ttft_ms REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); `); @@ -175,6 +307,14 @@ CREATE TABLE settings ( if (fromVersion < 5) { this.#migrateSchemaV4ToV5(); } + if (fromVersion < 6) { + // v5 → v6: TPS switched from the post-TTFT decode window to total + // request duration (hidden reasoning made decode-window rates bogus). + // Purge the old aggregates and re-arm the stats.db backfill so + // history is re-imported through the corrected fold. + this.#db.run("DELETE FROM model_perf"); + this.#db.prepare("DELETE FROM meta WHERE key = ?").run(MODEL_PERF_BACKFILL_KEY); + } } #migrateSchemaV4ToV5(): void { @@ -257,6 +397,8 @@ FROM model_usage_legacy this.#listSettingsStmt.finalize(); this.#upsertModelUsageStmt.finalize(); this.#listModelUsageStmt.finalize(); + this.#upsertModelPerfStmt.finalize(); + this.#listModelPerfStmt.finalize(); // SqliteAuthCredentialStore.close() finalizes its own statements and // closes the shared #db handle — must run after our statements finalize. this.#authStore.close(); @@ -317,6 +459,191 @@ FROM model_usage_legacy } } + /** + * Folds one completed request's timing into the model's perf aggregates. + * TPS is measured over the total request duration — not the post-TTFT + * decode window, which undercounts generation time (and so inflates the + * rate) when reasoning tokens are generated before the first visible + * token. Invalid samples (no tokens, no duration) are dropped. + * + * Deferred like prompt history: samples are batched and written in one + * transaction after {@link MODEL_PERF_FLUSH_DELAY_MS}, keeping SQLite off + * the turn-completion hot path. Fire-and-forget safe — flush failures are + * logged, never thrown; await the returned promise only to observe the flush. + * @param modelKey - Model key in "provider/modelId" format + */ + recordModelPerf(modelKey: string, sample: ModelPerfSample): Promise { + const row = normalizeModelPerfSample(modelKey, sample); + if (!row) return Promise.resolve(); + return this.#perfDrain.push(row, rows => this.#flushModelPerf(rows)); + } + + #flushModelPerf(rows: ModelPerfInsert[]): void { + // Kick the one-time history import too, so aggregates populate even if + // the user never opens /models. Additive merge makes ordering with live + // samples irrelevant. + this.#kickModelPerfBackfill(); + try { + this.#db.transaction((batch: ModelPerfInsert[]) => { + for (const row of batch) this.#foldModelPerf(row); + })(rows); + } catch (error) { + logger.warn("AgentStorage failed to record model perf", { error: String(error) }); + } + } + + #foldModelPerf(row: ModelPerfInsert): void { + this.#upsertModelPerfStmt.run(row.modelKey, row.outputTokens, row.durationMs, row.ttftSamples, row.ttftMs); + } + + /** + * Returns recency-weighted TPS/TTFT averages for every model with recorded + * requests, keyed by "provider/modelId". Read by the /models browser. + * Also kicks the one-time background stats.db import; until it completes, + * models without live samples are simply absent. + */ + getModelPerf(): Map { + this.#kickModelPerfBackfill(); + const stats = new Map(); + try { + for (const row of this.#listModelPerfStmt.all() as ModelPerfRow[]) { + if (row.gen_ms <= 0 || row.output_tokens <= 0) continue; + stats.set(row.model_key, { + samples: row.samples, + tps: (row.output_tokens * 1000) / row.gen_ms, + ttftMs: row.ttft_samples > 0 ? row.ttft_ms / row.ttft_samples : null, + }); + } + } catch (error) { + logger.warn("AgentStorage failed to read model perf", { error: String(error) }); + } + return stats; + } + + /** + * One-time, non-blocking import of historical request timings from the + * `omp stats` database (`~/.omp/stats.db`) into model_perf. Fire-and-forget: + * the walk runs in bounded chunks with event-loop yields between them + * (bun:sqlite is synchronous — an unbounded scan here froze the TUI for + * ~30s on multi-million-row stats databases), and the persistent meta + * marker is only set on success so a crash or error retries next process. + * A missing stats.db leaves the marker unset so a later `omp stats` run + * still gets imported. No-op for non-default db paths. + */ + #kickModelPerfBackfill(): void { + if (!this.#autoPerfBackfill || this.#perfBackfillChecked) return; + this.#perfBackfillChecked = true; + try { + const marker = this.#db.prepare("SELECT value FROM meta WHERE key = ?").get(MODEL_PERF_BACKFILL_KEY); + if (marker) return; + const statsDbPath = getStatsDbPath(); + if (!fs.existsSync(statsDbPath)) return; + void this.backfillModelPerfFromStats(statsDbPath) + .then(imported => { + this.#db + .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") + .run(MODEL_PERF_BACKFILL_KEY, "complete"); + logger.info("AgentStorage imported model perf history from stats.db", { imported }); + }) + .catch(error => { + logger.warn("AgentStorage model perf backfill failed", { error: String(error) }); + }); + } catch (error) { + logger.warn("AgentStorage model perf backfill failed", { error: String(error) }); + } + } + + /** + * Imports recent measurable request rows from an `omp stats` database + * (`messages` table) into the model_perf aggregates. Walks newest-first + * over the timestamp index in {@link MODEL_PERF_BACKFILL_CHUNK}-row chunks, + * yielding to the event loop between chunks, and keeps at most + * {@link MODEL_PERF_DECAY_AT} rows per model within the + * {@link MODEL_PERF_BACKFILL_MAX_AGE_MS} window — beyond either bound the + * live decay would erase the contribution anyway. Errored turns are + * excluded; aborted turns with reported usage count, matching live capture. + * Sums land in one additive transaction at the end, so concurrent live + * samples merge correctly regardless of order. + * @param statsDbPath - Path to a stats.db file; opened read-only + * @returns Number of rows folded in + * @throws When the stats db cannot be opened or queried + */ + async backfillModelPerfFromStats(statsDbPath: string): Promise { + const statsDb = new Database(statsDbPath, { readonly: true }); + try { + statsDb.run("PRAGMA busy_timeout = 5000"); + const select = statsDb.prepare( + `SELECT rowid, timestamp, provider, model, output_tokens, duration, ttft +FROM messages +WHERE (timestamp < ?1 OR (timestamp = ?1 AND rowid < ?2)) + AND timestamp >= ?3 + AND duration > 0 AND output_tokens > 0 AND stop_reason != 'error' +ORDER BY timestamp DESC, rowid DESC +LIMIT ?4`, + ); + const cutoff = Date.now() - MODEL_PERF_BACKFILL_MAX_AGE_MS; + const sums = new Map(); + let cursorTimestamp = Number.MAX_SAFE_INTEGER; + let cursorRowid = Number.MAX_SAFE_INTEGER; + let scanned = 0; + let imported = 0; + while (scanned < MODEL_PERF_BACKFILL_MAX_ROWS) { + const chunk = Math.min(MODEL_PERF_BACKFILL_CHUNK, MODEL_PERF_BACKFILL_MAX_ROWS - scanned); + const rows = select.all(cursorTimestamp, cursorRowid, cutoff, chunk) as StatsMessageRow[]; + if (rows.length === 0) break; + scanned += rows.length; + const last = rows[rows.length - 1]; + cursorTimestamp = last.timestamp; + cursorRowid = last.rowid; + for (const row of rows) { + const key = `${row.provider}/${row.model}`; + let accum = sums.get(key); + if (accum && accum.samples >= MODEL_PERF_DECAY_AT) continue; + const normalized = normalizeModelPerfSample(key, { + outputTokens: row.output_tokens, + durationMs: row.duration, + ttftMs: row.ttft ?? undefined, + }); + if (!normalized) continue; + if (!accum) { + accum = { samples: 0, outputTokens: 0, genMs: 0, ttftSamples: 0, ttftMs: 0 }; + sums.set(key, accum); + } + accum.samples += 1; + accum.outputTokens += normalized.outputTokens; + accum.genMs += normalized.durationMs; + accum.ttftSamples += normalized.ttftSamples; + accum.ttftMs += normalized.ttftMs; + imported++; + } + if (rows.length < chunk) break; + // Yield so a chunked walk never freezes the TUI (bun:sqlite is sync). + await Bun.sleep(0); + } + if (sums.size > 0) { + const upsert = this.#db.prepare( + `INSERT INTO model_perf (model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms, updated_at) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ${SQLITE_NOW_EPOCH}) +ON CONFLICT(model_key) DO UPDATE SET + samples = model_perf.samples + excluded.samples, + output_tokens = model_perf.output_tokens + excluded.output_tokens, + gen_ms = model_perf.gen_ms + excluded.gen_ms, + ttft_samples = model_perf.ttft_samples + excluded.ttft_samples, + ttft_ms = model_perf.ttft_ms + excluded.ttft_ms, + updated_at = ${SQLITE_NOW_EPOCH}`, + ); + this.#db.transaction(() => { + for (const [key, accum] of sums) { + upsert.run(key, accum.samples, accum.outputTokens, accum.genMs, accum.ttftSamples, accum.ttftMs); + } + })(); + } + return imported; + } finally { + statsDb.close(); + } + } + /** * Checks if any auth credentials exist in storage. * @returns True if at least one credential is stored diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index 7ef5907a5..06635de90 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -1,7 +1,7 @@ import { Database, type Statement } from "bun:sqlite"; import * as fs from "node:fs"; import * as path from "node:path"; -import { getHistoryDbPath, logger } from "@oh-my-pi/pi-utils"; +import { AsyncDrain, getHistoryDbPath, logger } from "@oh-my-pi/pi-utils"; export interface HistoryEntry { id: number; @@ -28,39 +28,6 @@ function escapeLikePattern(text: string): string { return text.replace(/[\\%_]/g, "\\$&"); } -class AsyncDrain { - #queue?: T[]; - #promise = Promise.resolve(); - - constructor(readonly delayMs: number = 0) {} - - push(value: T, hnd: (values: T[]) => Promise | void): Promise { - let queue = this.#queue; - if (!queue) { - this.#queue = queue = []; - const { promise, resolve, reject } = Promise.withResolvers(); - const exec = (): void => { - try { - if (this.#queue === queue) { - this.#queue = undefined; - } - resolve(hnd(queue!)); - } catch (error) { - reject(error); - } - }; - if (this.delayMs > 0) { - setTimeout(exec, this.delayMs); - } else { - queueMicrotask(exec); - } - this.#promise = promise; - } - queue.push(value); - return this.#promise; - } -} - export class HistoryStorage { #db: Database; static #instance?: HistoryStorage; diff --git a/packages/coding-agent/src/session/session-context.test.ts b/packages/coding-agent/src/session/session-context.test.ts index 4744c69cb..914434999 100644 --- a/packages/coding-agent/src/session/session-context.test.ts +++ b/packages/coding-agent/src/session/session-context.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import * as snapcompact from "@oh-my-pi/snapcompact"; import type { CompactionSummaryMessage } from "./messages"; -import { buildSessionContext } from "./session-context"; +import { buildSessionContext, type StrippedToolCallsMarker } from "./session-context"; import type { SessionEntry } from "./session-entries"; const timestamp = "2026-07-09T00:00:00.000Z"; @@ -129,12 +129,16 @@ function danglingCallIds(messages: AgentMessage[]): string[] { } describe("buildSessionContext dangling toolCalls", () => { - it("strips a dangling toolCall-only assistant turn from the transcript by default", () => { + it("strips a dangling toolCall from the transcript but keeps the turn with a stripped marker", () => { const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { transcript: true }); expect(danglingCallIds(context.messages)).toEqual([]); - // The turn had nothing but the dangling call, so the whole message drops. - expect(context.messages.some(message => message.role === "assistant")).toBe(false); + // The turn survives (even content-less) carrying the marker so the TUI + // renders a placeholder row instead of silently erasing the activity. + const assistant = context.messages.find(message => message.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant?.content).toEqual([]); + expect((assistant as AgentMessage & StrippedToolCallsMarker).strippedToolCalls).toBe(1); }); it("keeps a dangling toolCall in transcript mode with keepDanglingToolCalls", () => { @@ -146,11 +150,12 @@ describe("buildSessionContext dangling toolCalls", () => { expect(danglingCallIds(context.messages)).toEqual(["call-1"]); }); - it("always strips dangling toolCalls from the LLM context", () => { + it("always strips dangling toolCalls from the LLM context and drops the emptied turn", () => { const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { keepDanglingToolCalls: true, }); expect(danglingCallIds(context.messages)).toEqual([]); + expect(context.messages.some(message => message.role === "assistant")).toBe(false); }); }); diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index cb830ba3d..eee08d6dc 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -131,6 +131,16 @@ export interface BuildSessionContextOptions { keepDanglingToolCalls?: boolean; } +/** + * Display-only marker set on transcript assistant messages whose dangling + * `toolCall` blocks were stripped (no paired result on the resolved path — + * failed/retried turns, results on sibling branches). The TUI renders a + * placeholder row from it so the turn's activity never silently vanishes. + */ +export interface StrippedToolCallsMarker { + strippedToolCalls?: number; +} + /** * Build the session context from entries using tree traversal. * If leafId is provided, walks from that entry to root. @@ -353,6 +363,7 @@ export function buildSessionContext( undefined, undefined, snapcompactHistoryBlocksForContext(snapcompactArchive, options), + entry.warning, ), ); } else { @@ -385,6 +396,7 @@ export function buildSessionContext( providerPayload, undefined, snapcompactHistoryBlocksForContext(snapcompactArchive, options), + compaction.warning, ); // Agent context (non-transcript): summary first so the LLM sees the // compacted context before recent messages. @@ -468,10 +480,11 @@ export function buildSessionContext( for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (message.role !== "assistant") continue; - const hasDangling = message.content.some( - block => block.type === "toolCall" && !pairedToolResultIds.has(block.id), - ); - if (!hasDangling) continue; + let strippedToolCalls = 0; + for (const block of message.content) { + if (block.type === "toolCall" && !pairedToolResultIds.has(block.id)) strippedToolCalls++; + } + if (strippedToolCalls === 0) continue; const normalized = message.content .filter( block => @@ -483,13 +496,17 @@ export function buildSessionContext( ? { ...block, thinkingSignature: undefined } : block, ); - if (normalized.length === 0) { + if (normalized.length === 0 && !options?.transcript) { messages.splice(i, 1); - if (options?.transcript) { - cacheMissExplainedAt.splice(i, 1); - } } else { - messages[i] = { ...message, content: normalized }; + const rewritten = { ...message, content: normalized }; + if (options?.transcript) { + // Display transcript: keep the turn (even content-less) and mark + // how many calls were dropped so the TUI renders a placeholder + // row instead of silently erasing the turn's activity. + (rewritten as AgentMessage & StrippedToolCallsMarker).strippedToolCalls = strippedToolCalls; + } + messages[i] = rewritten; } } } diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 59ed3e506..7dc763a95 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -92,6 +92,12 @@ export interface CompactionEntry extends SessionEntryBase { preserveData?: Record; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromExtension?: boolean; + /** + * Dead-end warning from the post-pass progress guard: the pass completed + * but freed too little for maintenance to continue. Rendered on the + * compaction divider. + */ + warning?: string; } export interface BranchSummaryEntry extends SessionEntryBase { diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 776fe7f13..72ec7cfb9 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -6,6 +6,7 @@ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; +import { expandRoleAlias, getModelMatchPreferences, resolveCliModel } from "../config/model-resolver"; import { applyProviderGlobalsFromSettings } from "../config/provider-globals"; import type { SettingPath, SettingValue } from "../config/settings"; import { settings } from "../config/settings"; @@ -326,6 +327,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ if (prompt) return { prompt }; }, }, + { + name: "queue", + description: "Queue a message for after the agent yields", + inlineHint: "", + allowArgs: true, + handleTui: async (command, runtime) => { + await runtime.ctx.handleQueueCommand(command.args); + }, + }, { name: "model", aliases: ["models"], @@ -451,6 +461,30 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ runtime.ctx.editor.setText(""); }, }, + { + name: "downshift", + description: "Switch to a fast/cheap model at the next action (works even without --downshift)", + acpDescription: "Downshift at the next action", + handle: async (_command, runtime) => { + const rolePattern = expandRoleAlias("pi/smol", runtime.settings); + const resolved = resolveCliModel({ + cliModel: rolePattern, + modelRegistry: runtime.session.modelRegistry, + preferences: getModelMatchPreferences(runtime.settings), + }); + if (resolved.error || !resolved.model) { + return usage(resolved.error ?? `Model "${rolePattern}" not found`, runtime); + } + if (!runtime.session.modelRegistry.hasConfiguredAuth(resolved.model)) { + return usage(`No API key for ${resolved.model.provider}/${resolved.model.id}`, runtime); + } + runtime.session.armDownshift(resolved.model, resolved.thinkingLevel); + await runtime.output( + `Downshift on: switching to ${resolved.model.provider}/${resolved.model.id} at the next edit/write (todo-gated).`, + ); + return commandConsumed(); + }, + }, { name: "advisor", description: "Toggle the advisor (a second model that reviews each turn and injects notes)", diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 14e67375b..e272c230c 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -193,10 +193,9 @@ type ManagedBashJobCompletion = interface ManagedBashJobHandle { jobId: string; - label: string; completion: Promise; getLatestText: () => string; - setBackgrounded: (backgrounded: boolean) => void; + stopUpdates: () => void; } function normalizeResultOutput(result: BashResult | BashInteractiveResult): string { @@ -327,6 +326,10 @@ function formatExitCodeNotice(exitCode: number): string { return `Command exited with code ${exitCode}`; } +function formatBackgroundNotice(jobId: string): string { + return `Backgrounded as job ${jobId}; result will be delivered automatically.`; +} + /** * Strip the trailing occurrence of `notice` (plus a single surrounding newline * on each side) so the TUI can echo the value via a styled footer label @@ -355,6 +358,11 @@ function stripExitCodeNotice(text: string, exitCode: number | undefined): string return stripTrailingNotice(text, formatExitCodeNotice(exitCode)); } +function stripBackgroundNotice(text: string, async: BashToolDetails["async"] | undefined): string { + if (async?.state !== "running") return text; + return stripTrailingNotice(text, formatBackgroundNotice(async.jobId)); +} + /** * Bash tool implementation. * @@ -389,6 +397,7 @@ export class BashTool implements AgentTool; onUpdate?: AgentToolUpdateCallback; - startBackgrounded: boolean; + forwardUpdates: boolean; }): ManagedBashJobHandle { const manager = this.session.asyncJobManager; if (!manager) { @@ -576,7 +580,7 @@ export class BashTool implements AgentTool 120 ? `${options.command.slice(0, 117)}...` : options.command; let latestText = ""; - let backgrounded = options.startBackgrounded; + let forwardUpdates = options.forwardUpdates; const completion = Promise.withResolvers(); const jobId = manager.register( @@ -632,11 +636,12 @@ export class BashTool implements AgentTool { + onProgress: async text => { latestText = text; + if (!forwardUpdates) return; await options.onUpdate?.({ content: [{ type: "text", text }], - details: backgrounded ? ((details ?? {}) as BashToolDetails) : {}, + details: {}, }); }, }, @@ -644,11 +649,10 @@ export class BashTool implements AgentTool latestText, - setBackgrounded: (nextBackgrounded: boolean) => { - backgrounded = nextBackgrounded; + stopUpdates: () => { + forwardUpdates = false; }, }; } @@ -813,9 +817,9 @@ export class BashTool implements AgentTool(config: ShellRendererConfig) { ) { return cachedLines; } - const strippedOutput = stripOutputNotice(rawOutput, details?.meta); + const withoutBackground = stripBackgroundNotice(rawOutput, details?.async); + const strippedOutput = stripOutputNotice(withoutBackground, details?.meta); const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode); const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs); const rawOutputArtifact = stripRawOutputArtifactNotice(withoutWall); @@ -1317,6 +1322,9 @@ export function createShellRenderer(config: ShellRendererConfig) { const requestedTimeoutSeconds = details?.requestedTimeoutSeconds; const wallTimeMs = details?.wallTimeMs; const statsParts: string[] = []; + if (details?.async?.state === "running") { + statsParts.push(`Backgrounded: ${details.async.jobId}`); + } if (wallTimeMs !== undefined) { statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`); } diff --git a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts index 262f4d19d..950fceeb4 100644 --- a/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts +++ b/packages/coding-agent/src/tools/browser/cmux/cmux-tab.ts @@ -12,7 +12,12 @@ import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors"; import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot"; import { DEFAULT_VIEWPORT } from "../launch"; import { extractReadableFromHtml, type ReadableFormat } from "../readable"; -import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation"; +import { + bindBrowserRunFacade, + resolvePredicateTimeout, + type WaitPredicateOptions, + waitForBrowserRun, +} from "../run-cancellation"; import { cloneSafe, RunOutput } from "../run-output"; import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol"; import { @@ -1352,7 +1357,17 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi assert: (cond: unknown, text?: string): void => { if (!cond) throw new ToolError(text ?? "Assertion failed"); }, - wait: (ms: number): Promise => waitForBrowserRun(ms, signal), + wait: (msOrPredicate: number | (() => unknown), waitOpts?: WaitPredicateOptions): Promise => + waitForBrowserRun( + msOrPredicate, + signal, + typeof msOrPredicate === "number" + ? waitOpts + : { + timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout), + interval: waitOpts?.interval, + }, + ), }); const hooks: RuntimeHooks = { diff --git a/packages/coding-agent/src/tools/browser/run-cancellation.ts b/packages/coding-agent/src/tools/browser/run-cancellation.ts index 3d5057752..d2313af14 100644 --- a/packages/coding-agent/src/tools/browser/run-cancellation.ts +++ b/packages/coding-agent/src/tools/browser/run-cancellation.ts @@ -1,5 +1,5 @@ import { untilAborted } from "@oh-my-pi/pi-utils"; -import { throwIfAborted } from "../tool-errors"; +import { ToolError, throwIfAborted } from "../tool-errors"; /** * Marks a run-scoped promise as observed without changing its behavior for awaited callers. @@ -16,12 +16,72 @@ export function markHandled(promise: Promise): Promise { return promise; } -/** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */ -export function waitForBrowserRun(ms: number, signal: AbortSignal): Promise { - const promise = (async (): Promise => { - throwIfAborted(signal); - await untilAborted(signal, () => Bun.sleep(ms)); +/** Headroom subtracted from the cell budget so an in-run deadline fires before the opaque whole-cell timeout. */ +export const CELL_BUDGET_SLACK_MS = 1_000; + +/** Default poll deadline for `wait(predicate)` before clamping to the cell budget. */ +export const DEFAULT_PREDICATE_TIMEOUT_MS = 30_000; + +/** Options for the predicate form of the run-scoped `wait()` helper. */ +export interface WaitPredicateOptions { + /** Max time to poll before failing, in ms (default 30s, clamped to the cell budget). */ + timeout?: number; + /** Poll interval in ms (default 100, floor 10). */ + interval?: number; +} + +/** + * Effective `wait(predicate)` deadline for a given cell budget. Always strictly below + * the cell budget so the named `wait(predicate) timed out` error wins the race against + * the opaque whole-cell "Browser code execution timed out". `0`/`Infinity` ("disable") + * map to the largest bounded deadline; negative/NaN garbage falls back to the default. + */ +export function resolvePredicateTimeout(cellTimeoutMs: number, explicit?: number): number { + const budgetBound = Math.max(1, cellTimeoutMs - CELL_BUDGET_SLACK_MS); + if (explicit === 0 || explicit === Number.POSITIVE_INFINITY) return budgetBound; + if (explicit !== undefined && Number.isFinite(explicit) && explicit > 0) return Math.min(explicit, budgetBound); + return Math.min(DEFAULT_PREDICATE_TIMEOUT_MS, budgetBound); +} + +/** + * Run-scoped `wait()` helper for evaluated browser code, honoring the owning run's + * cancellation signal. + * + * - `wait(ms)` sleeps for `ms` milliseconds. + * - `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until it returns a + * truthy value and resolves with that value; throws a named `ToolError` on timeout + * instead of stalling into the whole-cell deadline. Predicate errors propagate. + */ +export function waitForBrowserRun( + msOrPredicate: number | (() => unknown), + signal: AbortSignal, + opts?: WaitPredicateOptions, +): Promise { + const promise = (async (): Promise => { throwIfAborted(signal); + if (typeof msOrPredicate === "number") { + await untilAborted(signal, async () => await Bun.sleep(msOrPredicate)); + throwIfAborted(signal); + return undefined; + } + if (typeof msOrPredicate !== "function") { + throw new ToolError("wait(...) expects milliseconds (number) or a predicate function to poll"); + } + const timeout = + opts?.timeout !== undefined && Number.isFinite(opts.timeout) && opts.timeout > 0 + ? opts.timeout + : DEFAULT_PREDICATE_TIMEOUT_MS; + const interval = Math.max(opts?.interval ?? 100, 10); + const deadline = Date.now() + timeout; + for (;;) { + const value = await untilAborted(signal, async () => await msOrPredicate()); + throwIfAborted(signal); + if (value) return value; + if (Date.now() + interval > deadline) { + throw new ToolError(`wait(predicate) timed out after ${timeout}ms — predicate never returned truthy`); + } + await untilAborted(signal, async () => await Bun.sleep(interval)); + } })(); return markHandled(promise); } diff --git a/packages/coding-agent/src/tools/browser/tab-protocol.ts b/packages/coding-agent/src/tools/browser/tab-protocol.ts index d2f5d51bd..96c946c4a 100644 --- a/packages/coding-agent/src/tools/browser/tab-protocol.ts +++ b/packages/coding-agent/src/tools/browser/tab-protocol.ts @@ -59,6 +59,12 @@ export type WorkerInitPayload = safeDir: string; targetId: string; dialogs?: "accept" | "dismiss"; + /** + * Post-timeout recycle: before adopting the page, dismiss any open JS dialog and + * stop a pending navigation so a blocked target cannot stall worker init (which + * previously force-killed the tab). Never set for first-time Electron attach. + */ + recover?: boolean; }; export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload }; diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts index 921fb23ff..0d0a7324a 100644 --- a/packages/coding-agent/src/tools/browser/tab-supervisor.ts +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -131,6 +131,10 @@ const tabs = new Map(); // awaits) cannot interleave and leak a worker + browser refCount. const acquireChains = new Map>(); const GRACE_MS = 750; +// Names of tabs the supervisor force-killed (timeout past grace, failed recycle), +// mapped to the kill reason. Lets the next `run` on that name explain WHY the tab +// vanished instead of a bare "not alive". Cleared when the name is opened again. +const killedTabs = new Map(); export function getTab(name: string): TabSession | undefined { return tabs.get(name); @@ -161,6 +165,7 @@ async function acquireTabImpl( if (opts.signal?.aborted) { throw new ToolAbortError("Browser tab open aborted"); } + killedTabs.delete(name); // Temporary refCount hold so releasing an existing tab on the SAME browser // below cannot drop it to refCount 0 and dispose the instance we are about // to reuse (e.g. reopening the sole tab with a different dialogs policy). @@ -386,7 +391,14 @@ async function runInTabWithSnapshot( snapshot: SessionSnapshot, ): Promise { const tab = tabs.get(name); - if (!tab || tab.state === "dead") throw new ToolError(`Tab ${JSON.stringify(name)} is not alive. Reopen it.`); + if (!tab || tab.state === "dead") { + const killed = killedTabs.get(name); + throw new ToolError( + killed + ? `Tab ${JSON.stringify(name)} was killed: ${killed}. Reopen it.` + : `Tab ${JSON.stringify(name)} is not alive. Open it first with action:"open".`, + ); + } if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`); const id = Snowflake.next(); const { promise, resolve, reject } = Promise.withResolvers(); @@ -712,6 +724,9 @@ async function recycleTimedOutWorkerTab(tab: WorkerTabSession, timeoutMs: number safeDir: getPuppeteerDir(), targetId: tab.targetId, dialogs: tab.dialogPolicy, + // Unblock a wedged page (open JS dialog, hung navigation) before adopting it — + // otherwise init stalls, times out, and the tab gets force-killed. + recover: true, }; let worker = await spawnTabWorker(); try { @@ -743,6 +758,7 @@ async function recycleTimedOutWorkerTab(tab: WorkerTabSession, timeoutMs: number async function forceKillTab(name: string, reason: string): Promise { const tab = tabs.get(name); if (!tab) return; + killedTabs.set(name, reason); tab.state = "dead"; const error = postmortem.markExpectedCleanupError(new ToolError(reason)); for (const pending of tab.pending.values()) pending.reject(error); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index 546cc8e35..55bb31eb2 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -6,6 +6,7 @@ import { postmortem, Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; import type { HTMLElement } from "linkedom"; import type { Browser, + CDPSession, Dialog, ElementHandle, ElementScreenshotOptions, @@ -35,7 +36,13 @@ import { loadPuppeteerInWorker, } from "./launch"; import { extractReadableFromHtml, type ReadableFormat } from "./readable"; -import { markHandled, waitForBrowserRun } from "./run-cancellation"; +import { + CELL_BUDGET_SLACK_MS, + markHandled, + resolvePredicateTimeout, + type WaitPredicateOptions, + waitForBrowserRun, +} from "./run-cancellation"; import { cloneSafe, RunOutput } from "./run-output"; import type { Observation, @@ -50,6 +57,13 @@ import type { WorkerInitPayload, } from "./tab-protocol"; +declare module "puppeteer-core" { + interface Frame { + /** Puppeteer's main JavaScript realm, retained by our pinned runtime patch. */ + mainRealm(): Realm; + } +} + declare global { interface Element extends HTMLElement {} function getComputedStyle(element: Element): Record; @@ -105,6 +119,11 @@ const PLAYWRIGHT_ONLY_SELECTOR_RE = type DialogPolicy = "accept" | "dismiss"; type DragTarget = string | { readonly x: number; readonly y: number }; type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string }; +/** Last JS dialog seen on the page; kept for timeout attribution until handled or navigation. */ +interface OpenDialogInfo { + type: string; + message: string; +} /** * Per-op fail-fast ceilings for `tab.*` helpers. All are kept strictly under the cell @@ -126,7 +145,7 @@ type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; rea const QUICK_OP_TIMEOUT_MS = 20_000; const ACTION_OP_TIMEOUT_MS = 8_000; /** Headroom subtracted from the cell budget so a per-op deadline fires before it. */ -const OP_DEADLINE_SLACK_MS = 1_000; +const OP_DEADLINE_SLACK_MS = CELL_BUDGET_SLACK_MS; /** * A selector op whose selector has matched nothing for this long fails fast with the * zero-match hint instead of burning the rest of its deadline: a wrong selector or a @@ -595,6 +614,7 @@ export class WorkerCore { #mode?: WorkerInitPayload["mode"]; #dialogPolicy?: DialogPolicy; #dialogHandler?: (dialog: Dialog) => void; + #openDialog?: OpenDialogInfo; constructor(transport: Transport) { this.#transport = transport; @@ -648,6 +668,7 @@ export class WorkerCore { }); if (payload.mode === "headless") { this.#page = await this.#browser.newPage(); + this.#observeDialogs(); await applyStealthPatches(this.#browser, this.#page, { browserSession: null, override: null }); await applyViewport(this.#page, payload.viewport); if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs); @@ -659,7 +680,15 @@ export class WorkerCore { }); } } else { - this.#page = await this.#findAttachedPage(payload.targetId); + const target = await this.#findAttachedTarget(payload.targetId); + // Post-timeout recycle: unblock the target BEFORE adopting the page — an open + // modal dialog or hung navigation can stall `target.page()` / ready info, and a + // stalled init used to time out and force-kill the tab. + if (payload.recover) await this.#recoverAttachedTarget(target); + const page = await target.page(); + if (!page) throw new ToolError(`Target ${payload.targetId} is no longer available on the attached browser`); + this.#page = page; + this.#observeDialogs(); if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs); } this.#targetId = await targetIdForPage(this.#page); @@ -669,17 +698,53 @@ export class WorkerCore { } } - async #findAttachedPage(targetId: string): Promise { + async #findAttachedTarget(targetId: string): Promise { if (!this.#browser) throw new ToolError("Browser is not connected"); for (const target of this.#browser.targets()) { if ((await targetIdForTarget(target).catch(() => "")) !== targetId) continue; - const page = await target.page(); - if (!page) break; - return page; + return target; } throw new ToolError(`Target ${targetId} is no longer available on the attached browser`); } + /** + * Best-effort unblocking of a wedged target during post-timeout recovery: dismiss any + * open JS dialog and stop a pending navigation over a raw CDP session (created on the + * target, not the page, so it works while the page itself is unresponsive). Every step + * tolerates "nothing to do". + */ + async #recoverAttachedTarget(target: Target): Promise { + let session: CDPSession | undefined; + try { + session = await target.createCDPSession(); + await session.send("Page.enable").catch(() => undefined); + await session.send("Page.handleJavaScriptDialog", { accept: false }).catch(() => undefined); + await session.send("Page.stopLoading").catch(() => undefined); + } catch (error) { + this.#log("debug", "Recovery CDP session failed; proceeding with attach", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + await session?.detach().catch(() => undefined); + } + } + + /** + * Record JS dialogs for timeout attribution without handling them (semantics of an + * unset `dialogs` policy are unchanged — the page stays blocked until user code or + * the policy handler acts). Cleared when the policy handler settles the dialog or a + * main-frame navigation proves the modal is gone. + */ + #observeDialogs(): void { + const page = this.#requirePage(); + page.on("dialog", dialog => { + this.#openDialog = { type: dialog.type(), message: dialog.message() }; + }); + page.on("framenavigated", frame => { + if (frame === page.mainFrame()) this.#openDialog = undefined; + }); + } + async #currentReadyInfo(): Promise { const page = this.#requirePage(); const targetId = this.#targetId ?? (await targetIdForPage(page)); @@ -698,11 +763,15 @@ export class WorkerCore { if (this.#dialogHandler) page.off("dialog", this.#dialogHandler); const handler = (dialog: Dialog): void => { const action = policy === "accept" ? dialog.accept() : dialog.dismiss(); - void action.catch(err => - this.#log("debug", "Dialog auto-handler failed", { - policy, - error: err instanceof Error ? err.message : String(err), - }), + void action.then( + () => { + this.#openDialog = undefined; + }, + err => + this.#log("debug", "Dialog auto-handler failed", { + policy, + error: err instanceof Error ? err.message : String(err), + }), ); }; page.on("dialog", handler); @@ -761,7 +830,20 @@ export class WorkerCore { assert: (cond: unknown, text?: string): void => { if (!cond) throw new ToolError(text ?? "Assertion failed"); }, - wait: (ms: number): Promise => waitForBrowserRun(ms, signal), + // Both wait forms register in the in-flight map so a cell that dies while + // sleeping/polling names the culprit instead of a bare whole-cell timeout. + wait: (msOrPredicate: number | (() => unknown), opts?: WaitPredicateOptions): Promise => { + const label = typeof msOrPredicate === "number" ? `wait(${msOrPredicate}ms)` : "wait(predicate)"; + const resolved = + typeof msOrPredicate === "number" + ? undefined + : { timeout: resolvePredicateTimeout(msg.timeoutMs, opts?.timeout), interval: opts?.interval }; + return markHandled( + this.#runOp(active, label, signal, Number.POSITIVE_INFINITY, sig => + waitForBrowserRun(msOrPredicate, sig, resolved), + ), + ); + }, }); const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers(); const onCancel = (): void => { @@ -771,9 +853,13 @@ export class WorkerCore { : new ToolAbortError(undefined, { cause: signal.reason }); if (timeoutSignal.aborted) { const stalled = describeInflight(active.inflight); + const dialog = this.#openDialog; + const dialogNote = dialog + ? `; a ${dialog.type}(${JSON.stringify(dialog.message.slice(0, 80))}) dialog opened during this run and may still block the page — reopen the tab with dialogs:"accept"|"dismiss" or handle page.on('dialog')` + : ""; rejectCancel( new ToolError( - `Browser code execution timed out after ${msg.timeoutMs}ms${stalled ? ` (stalled on ${stalled})` : ""}`, + `Browser code execution timed out after ${msg.timeoutMs}ms${stalled ? ` (stalled on ${stalled})` : ""}${dialogNote}`, ), ); } else { @@ -985,7 +1071,7 @@ export class WorkerCore { active: ActiveRun, ): TabApi { const page = this.#requirePage(); - const { quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs); + const { budgetBound, quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs); const waitMs = (explicit?: number): number => resolveWaitTimeout(timeoutMs, explicit); const INF = Number.POSITIVE_INFINITY; const op = ( @@ -1003,10 +1089,24 @@ export class WorkerCore { goto: (url, opts) => op(`tab.goto(${JSON.stringify(url)})`, INF, async sig => { this.#clearElementCache(); - // Default to "load" because dev servers with HMR/WS never reach networkidle. - await untilAborted(sig, () => - page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }), - ); + try { + // Default to "load" because dev servers with HMR/WS never reach networkidle. + // budgetBound (not the full cell) so a hung navigation fails named and + // catchable inside the run instead of dying with the whole cell. + await untilAborted(sig, () => + page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: budgetBound }), + ); + } catch (err) { + if (err instanceof Error && err.name === "TimeoutError") { + // Abandon the hung navigation NOW — a still-pending load stalls every + // later op on this page and cascades into more opaque timeouts. + await this.#stopLoading(); + throw new ToolError( + `tab.goto(${JSON.stringify(url)}) timed out after ${budgetBound}ms; pending navigation stopped — retry with a longer tool timeout or waitUntil:"domcontentloaded"`, + ); + } + throw err; + } }), observe: opts => op("tab.observe()", quickOpMs, sig => this.#collectObservation({ ...opts, signal: sig })), ariaSnapshot: (selector, opts) => @@ -1164,8 +1264,11 @@ export class WorkerCore { op("tab.evaluate()", INF, sig => untilAborted(sig, () => typeof fn === "string" - ? page.evaluate(fn) - : page.evaluate(fn as (...a: unknown[]) => unknown, ...args), + ? page.mainFrame().mainRealm().evaluate(fn) + : page + .mainFrame() + .mainRealm() + .evaluate(fn as (...a: unknown[]) => unknown, ...args), ), ) as never, scrollIntoView: selector => @@ -1553,6 +1656,22 @@ export class WorkerCore { for (const handle of handles) void handle.dispose().catch(() => undefined); } + /** Best-effort `Page.stopLoading` so an abandoned navigation cannot stall later ops. */ + async #stopLoading(): Promise { + try { + const session = await this.#requirePage().createCDPSession(); + try { + await session.send("Page.stopLoading"); + } finally { + await session.detach().catch(() => undefined); + } + } catch (error) { + this.#log("debug", "Page.stopLoading failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + async #close(): Promise { this.#unsub(); this.#clearElementCache(); diff --git a/packages/coding-agent/src/tools/builtin-names.ts b/packages/coding-agent/src/tools/builtin-names.ts index 253bd3345..fbd2ba98a 100644 --- a/packages/coding-agent/src/tools/builtin-names.ts +++ b/packages/coding-agent/src/tools/builtin-names.ts @@ -1,6 +1,7 @@ export const BUILTIN_TOOL_NAMES = [ "read", "bash", + "launch", "edit", "ast_grep", "ast_edit", diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index 5e7f73090..b42468c11 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -414,37 +414,39 @@ function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string return lines; } -/** Render status events as tree lines. */ +/** + * Render status events as tree lines. Shows a tail window (newest events are + * the live edge for `log()` progress loops) behind an "… N earlier" marker, + * matching the code/output tail-window convention. Collapsed keeps a small + * fixed window; expanded widens to the viewport-sized preview window. + */ function renderStatusEvents(events: EvalStatusEvent[], theme: Theme, expanded: boolean): string[] { if (events.length === 0) return []; - const maxCollapsed = 3; - const maxExpanded = 10; - const displayCount = expanded ? Math.min(events.length, maxExpanded) : Math.min(events.length, maxCollapsed); + const max = expanded ? Math.max(10, previewWindowRows()) : 3; + const hidden = Math.max(0, events.length - max); + const visible = hidden > 0 ? events.slice(hidden) : events; const lines: string[] = []; - for (let i = 0; i < displayCount; i++) { - const isLast = i === displayCount - 1 && (expanded || events.length <= maxCollapsed); + if (hidden > 0) { + lines.push(`${theme.fg("dim", theme.tree.branch)} ${theme.fg("dim", `… ${hidden} earlier`)}`); + } + for (let i = 0; i < visible.length; i++) { + const isLast = i === visible.length - 1; const branch = isLast ? theme.tree.last : theme.tree.branch; if (expanded) { - const eventLines = formatStatusEventExpanded(events[i], theme); + const eventLines = formatStatusEventExpanded(visible[i], theme); lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`); const continueBranch = isLast ? " " : `${theme.tree.vertical} `; for (let j = 1; j < eventLines.length; j++) { lines.push(`${theme.fg("dim", continueBranch)}${eventLines[j]}`); } } else { - lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme)}`); + lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(visible[i], theme)}`); } } - if (!expanded && events.length > maxCollapsed) { - lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxCollapsed} more`)}`); - } else if (expanded && events.length > maxExpanded) { - lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxExpanded} more`)}`); - } - return lines; } diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 287c22ff6..75ee5f75f 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -50,6 +50,7 @@ import { GrepTool } from "./grep"; import { InspectImageTool } from "./inspect-image"; import { IrcTool, isIrcEnabled } from "./irc"; import { JobTool } from "./job"; +import { LaunchTool } from "./launch"; import { LearnTool } from "./learn"; import { ManageSkillTool } from "./manage-skill"; import { MemoryEditTool } from "./memory-edit"; @@ -89,6 +90,7 @@ export * from "./image-gen"; export * from "./inspect-image"; export * from "./irc"; export * from "./job"; +export * from "./launch"; export * from "./learn"; export * from "./manage-skill"; export * from "./memory-edit"; @@ -389,6 +391,7 @@ export type BuiltinToolLoadMode = "essential" | "discoverable"; export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [ "read", "bash", + "launch", "edit", "write", "glob", @@ -448,6 +451,7 @@ export function filterInitialToolsForDiscoveryAll( export const BUILTIN_TOOLS: Record = { read: s => new ReadTool(s), bash: s => new BashTool(s), + launch: s => new LaunchTool(s), edit: s => new EditTool(s), ast_grep: s => new AstGrepTool(s), ast_edit: s => new AstEditTool(s), @@ -605,6 +609,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P if (name === "goal") return goalEnabled && goalModeActive; if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled"); if (name === "bash") return session.settings.get("bash.enabled"); + if (name === "launch") return session.settings.get("launch.enabled"); if (name === "eval") return allowEval; if (name === "debug") return session.settings.get("debug.enabled"); if (name === "todo") return !includeYield && session.settings.get("todo.enabled"); diff --git a/packages/coding-agent/src/tools/launch.ts b/packages/coding-agent/src/tools/launch.ts new file mode 100644 index 000000000..879c1555e --- /dev/null +++ b/packages/coding-agent/src/tools/launch.ts @@ -0,0 +1,600 @@ +import type { + AgentTool, + AgentToolContext, + AgentToolResult, + AgentToolUpdateCallback, + ToolApprovalDecision, +} from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; +import type { Component } from "@oh-my-pi/pi-tui"; +import { Text } from "@oh-my-pi/pi-tui"; +import { prompt } from "@oh-my-pi/pi-utils"; +import { type } from "arktype"; +import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { daemonClientForProject } from "../launch/client"; +import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, DaemonState } from "../launch/protocol"; +import type { Theme, ThemeColor } from "../modes/theme/theme"; +import launchDescription from "../prompts/tools/launch.md" with { type: "text" }; +import { renderStatusLine } from "../tui"; +import type { ToolSession } from "."; +import { resolveToCwd } from "./path-utils"; +import { + capPreviewLines, + createCachedComponent, + formatDuration, + formatExpandHint, + formatMoreItems, + PREVIEW_LIMITS, + pluralize, + previewLine, + replaceTabs, + shortenPath, + TRUNCATE_LENGTHS, + truncateToWidth, +} from "./render-utils"; +import { ToolError } from "./tool-errors"; + +const launchSchema = type({ + op: type("'start' | 'list' | 'logs' | 'wait' | 'send' | 'stop' | 'restart' | 'describe'").describe( + "launch operation", + ), + "name?": type("string <= 48").describe("stable project-scoped launch name"), + "application?": type("string > 0").describe("start: executable or application path"), + "args?": type("string[]").describe("start: argv passed directly to the application"), + "env?": type({ "[string]": "string" }).describe("start: extra environment variables"), + "cwd?": type("string").describe("start: working directory; defaults to the session directory"), + "pty?": type("boolean").describe("start: allocate an interactive PTY; default true"), + "ready?": type({ + "log?": type("string > 0").describe("regex matched against output"), + "port?": type("number").describe("TCP port that must accept connections"), + "host?": type("string > 0").describe("TCP readiness host; default 127.0.0.1"), + "timeout?": type("number > 0").describe("seconds to wait; default 30"), + }).describe("start: readiness conditions; all supplied conditions must pass"), + "restart?": type("'no' | 'on-failure' | 'always'").describe("start: restart policy; default no"), + "persist?": type("boolean").describe("start: survive the last omp client exiting; default false"), + "detached?": type("boolean").describe( + "start: survive every omp and broker exit; implies persist and disables PTY input", + ), + "lines?": type("number > 0").describe("logs: output lines; default 100, max 1000"), + "head?": type("boolean").describe("logs: read from the beginning instead of the tail"), + "grep?": type("string > 0").describe("logs: regex filter"), + "follow?": type("boolean").describe("logs: wait for output newer than cursor"), + "cursor?": type("number >= 0").describe("logs: output cursor returned by an earlier call"), + "for?": type("'ready' | 'exit'").describe("wait: lifecycle condition; default exit"), + "pattern?": type("string > 0").describe("wait: output regex; takes precedence over for"), + "text?": type("string > 0").describe("send: stdin text"), + "enter?": type("boolean").describe("send: append Enter after text; default true"), + "keys?": type("string[]").describe("send: terminal keys after text"), + "signal?": type("'SIGINT' | 'SIGTERM' | 'SIGHUP' | 'SIGQUIT' | 'SIGKILL'").describe("send: process-tree signal"), + "timeout?": type("number > 0").describe("logs/wait/stop: max seconds; default 30 (stop: 5)"), +}); + +type LaunchParams = typeof launchSchema.infer; + +const KEY_INPUT: Record = { + ENTER: "\r", + TAB: "\t", + ESCAPE: "\u001b", + CTRL_C: "\u0003", + CTRL_D: "\u0004", + UP: "\u001b[A", + DOWN: "\u001b[B", + RIGHT: "\u001b[C", + LEFT: "\u001b[D", +}; + +/** Structured launch state retained for compact TUI rendering. */ +export interface LaunchToolDetails { + op: LaunchParams["op"]; + daemon?: DaemonSnapshot; + daemons?: DaemonSnapshot[]; + cursor?: number; + timedOut?: boolean; + /** logs: daemon lifecycle state at read time. */ + state?: DaemonState; + /** wait: output line that satisfied the pattern. */ + matched?: string; + /** describe: immutable launch spec backing the command/cwd detail lines. */ + spec?: DaemonSpec; +} + +function requiredName(params: LaunchParams): string { + if (!params.name) throw new ToolError(`${params.op} requires name`); + return params.name; +} + +function timeoutMs(value: number | undefined, fallbackSeconds: number): number { + const seconds = Math.max(0.05, Math.min(3_600, value ?? fallbackSeconds)); + return Math.round(seconds * 1_000); +} + +function commandSpec(params: LaunchParams, session: ToolSession): DaemonSpec { + const name = requiredName(params); + if (!params.application) throw new ToolError("start requires application"); + const ready = params.ready; + const detached = params.detached ?? false; + if (ready?.port !== undefined && (!Number.isInteger(ready.port) || ready.port < 1 || ready.port > 65_535)) { + throw new ToolError("ready.port must be an integer from 1 to 65535"); + } + if (ready && !ready.log && ready.port === undefined) throw new ToolError("ready requires log or port"); + return { + name, + application: params.application, + args: params.args ?? [], + env: params.env ?? {}, + cwd: resolveToCwd(params.cwd ?? session.cwd, session.cwd), + pty: detached ? false : (params.pty ?? true), + ready: ready + ? { + log: ready.log, + port: ready.port, + host: ready.host, + timeoutMs: timeoutMs(ready.timeout, 30), + } + : undefined, + restart: params.restart ?? "no", + persist: (params.persist ?? false) || detached, + detached, + }; +} + +function sendData(params: LaunchParams): string | undefined { + let data = params.text ?? ""; + if (params.text && (params.enter ?? true)) data += KEY_INPUT.ENTER; + for (const rawKey of params.keys ?? []) { + const key = rawKey.trim().toUpperCase(); + const input = KEY_INPUT[key]; + if (input === undefined) throw new ToolError(`Unsupported launch key ${rawKey}`); + data += input; + } + return data || undefined; +} + +function operationFor(params: LaunchParams, session: ToolSession): DaemonOperation { + switch (params.op) { + case "start": + return { op: "start", spec: commandSpec(params, session), owner: session.getSessionId?.() ?? undefined }; + case "list": + return { op: "list" }; + case "logs": + return { + op: "logs", + name: requiredName(params), + lines: Math.min(1_000, Math.floor(params.lines ?? 100)), + head: params.head ?? false, + grep: params.grep, + follow: params.follow ?? false, + cursor: params.cursor, + timeoutMs: timeoutMs(params.timeout, 30), + }; + case "wait": + return { + op: "wait", + name: requiredName(params), + for: params.for ?? "exit", + pattern: params.pattern, + timeoutMs: timeoutMs(params.timeout, 30), + }; + case "send": + return { + op: "send", + name: requiredName(params), + data: sendData(params), + signal: params.signal, + }; + case "stop": + return { op: "stop", name: requiredName(params), timeoutMs: timeoutMs(params.timeout, 5) }; + case "restart": + return { op: "restart", name: requiredName(params) }; + case "describe": + return { op: "describe", name: requiredName(params) }; + } +} + +function daemonLabel(daemon: DaemonSnapshot): string { + const pid = daemon.pid === undefined ? "" : ` pid=${daemon.pid}`; + const exit = daemon.exitCode === undefined ? "" : ` exit=${daemon.exitCode}`; + return `${daemon.name}: ${daemon.state}${pid}${exit} uptime=${formatDuration( + (daemon.exitedAt ?? Date.now()) - daemon.startedAt, + )} restarts=${daemon.restartCount}${daemon.detached ? " detached" : daemon.persist ? " persistent" : ""}`; +} + +/** + * Human sentences for the readiness conditions still unmet, e.g. + * `port 5173 on 127.0.0.1 never accepted connections`. `ready` (from the start + * params) adds the concrete pattern/port; absent it falls back to generic labels. + */ +function readyPendingSummary(daemon: DaemonSnapshot, ready?: LaunchParams["ready"]): string[] { + const parts: string[] = []; + for (const condition of daemon.readyPending ?? []) { + if (condition === "log") { + parts.push(ready?.log ? `log pattern /${ready.log}/ never matched` : "the log pattern never matched"); + } else { + parts.push( + ready?.port !== undefined + ? `port ${ready.port} on ${ready.host ?? "127.0.0.1"} never accepted connections` + : "the port never accepted connections", + ); + } + } + return parts; +} + +function toolContent(result: DaemonRpcResult, params: LaunchParams): string { + switch (result.op) { + case "ping": + case "shutdown": + throw new ToolError(`Internal daemon result ${result.op} is not tool-visible`); + case "start": { + const daemon = result.daemon; + const lines = [`${daemon.state === "failed" ? "Failed to launch" : "Started"} ${daemonLabel(daemon)}`]; + if (daemon.state === "failed" && daemon.exitReason) lines.push(`Reason: ${daemon.exitReason}`); + if (daemon.readyMatch) lines.push(`Ready log matched: ${daemon.readyMatch}`); + if (result.readyTimedOut) { + const pending = readyPendingSummary(daemon, params.ready); + const cause = pending.length > 0 ? `: ${pending.join("; ")}` : ""; + lines.push( + `NOT ready — readiness timed out after ${params.ready?.timeout ?? 30}s${cause}. The process is still running (state: ${daemon.state}); follow its logs or stop it.`, + ); + } + return lines.join("\n"); + } + case "list": + return result.daemons.length + ? result.daemons.map(daemon => `- ${daemonLabel(daemon)}`).join("\n") + : "No daemons."; + case "logs": + return `${result.text}${result.text && !result.text.endsWith("\n") ? "\n" : ""}[${result.name}: ${result.state}; cursor=${result.cursor}${result.timedOut ? "; follow timed out" : ""}]`; + case "wait": { + const lines = [daemonLabel(result.daemon)]; + if (result.matched) lines.push(`Matched: ${result.matched}`); + if (result.timedOut) { + const pending = readyPendingSummary(result.daemon); + lines.push(`Wait timed out${pending.length > 0 ? ` (still waiting on: ${pending.join("; ")})` : ""}.`); + } + return lines.join("\n"); + } + case "send": + return `Sent input to ${daemonLabel(result.daemon)}`; + case "stop": + return `Stopped ${daemonLabel(result.daemon)}`; + case "restart": + return `Restarted ${daemonLabel(result.daemon)}`; + case "describe": + return [ + daemonLabel(result.daemon), + `Command: ${[result.spec.application, ...result.spec.args].join(" ")}`, + `Cwd: ${shortenPath(result.spec.cwd)}`, + `PTY: ${result.spec.pty}; restart=${result.spec.restart}; persist=${result.spec.persist}; detached=${result.spec.detached}`, + ].join("\n"); + } +} + +function toolDetails(result: DaemonRpcResult): LaunchToolDetails { + switch (result.op) { + case "start": + return { op: "start", daemon: result.daemon, timedOut: result.readyTimedOut }; + case "list": + return { op: "list", daemons: result.daemons }; + case "logs": + return { op: "logs", cursor: result.cursor, timedOut: result.timedOut, state: result.state }; + case "wait": + return { op: "wait", daemon: result.daemon, timedOut: result.timedOut, matched: result.matched }; + case "send": + return { op: "send", daemon: result.daemon }; + case "stop": + return { op: "stop", daemon: result.daemon }; + case "restart": + return { op: "restart", daemon: result.daemon }; + case "describe": + return { op: "describe", daemon: result.daemon, spec: result.spec }; + case "ping": + case "shutdown": + throw new ToolError(`Internal daemon result ${result.op} is not tool-visible`); + } +} +function approvalFor(params: unknown): ToolApprovalDecision { + if (typeof params !== "object" || params === null || !("op" in params)) return "exec"; + switch (params.op) { + case "list": + case "logs": + case "wait": + case "describe": + return "read"; + default: + return "exec"; + } +} + +/** Project-scoped launch tool for supervising processes in every coding-agent session. */ +export class LaunchTool implements AgentTool { + readonly name = "launch"; + readonly label = "Launch"; + readonly loadMode = "essential"; + readonly summary = "Launch and control shared long-running project processes"; + readonly description = prompt.render(launchDescription); + readonly parameters = launchSchema; + readonly strict = true; + readonly examples: readonly ToolExample[] = [ + { + caption: "Start a dev server and wait for its log banner and port", + call: { + op: "start", + name: "web", + application: "bun", + args: ["run", "dev"], + ready: { log: "Local:.*http", port: 5173, timeout: 30 }, + }, + }, + { + caption: "Run a noninteractive service beyond broker lifetime", + call: { + op: "start", + name: "worker", + application: "worker", + args: ["serve"], + detached: true, + }, + }, + { + caption: "Inspect recent output", + call: { op: "logs", name: "web", lines: 100 }, + }, + { + caption: "Follow output after a cursor", + call: { op: "logs", name: "web", follow: true, cursor: 1842, timeout: 30 }, + }, + { + caption: "Set a debugger breakpoint", + call: { op: "send", name: "debugger", text: "breakpoint set --name main" }, + }, + { + caption: "Run a debugger command", + call: { op: "send", name: "debugger", text: "run" }, + }, + { + caption: "Interrupt a debugger", + call: { op: "send", name: "debugger", keys: ["CTRL_C"] }, + }, + ]; + readonly approval = approvalFor; + + constructor(private readonly session: ToolSession) {} + + async execute( + _toolCallId: string, + params: LaunchParams, + signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + _context?: AgentToolContext, + ): Promise> { + const client = await daemonClientForProject(this.session.cwd); + const result = await client.request(operationFor(params, this.session), signal); + return { + content: [{ type: "text", text: replaceTabs(toolContent(result, params)) }], + details: toolDetails(result), + }; + } +} + +/** Args shape visible to the renderer, possibly mid-stream (every field optional). */ +type LaunchRenderArgs = Partial; + +function stateColor(state: DaemonState): ThemeColor { + switch (state) { + case "running": + case "ready": + return "success"; + case "failed": + return "error"; + case "exited": + return "muted"; + default: + return "warning"; + } +} + +/** Compact `state · pid · uptime` fragments for the status-line meta slot. */ +function daemonMeta(daemon: DaemonSnapshot, theme: Theme): string[] { + const meta = [theme.fg(stateColor(daemon.state), daemon.state)]; + if (daemon.readyPending?.length) meta.push(theme.fg("warning", `waiting on ${daemon.readyPending.join("+")}`)); + if (daemon.exitCode !== undefined) { + meta.push(theme.fg(daemon.exitCode === 0 ? "muted" : "error", `exit ${daemon.exitCode}`)); + } else if (daemon.pid !== undefined) { + meta.push(`pid ${daemon.pid}`); + } + const lifespan = formatDuration((daemon.exitedAt ?? Date.now()) - daemon.startedAt); + meta.push(daemon.exitedAt === undefined ? `up ${lifespan}` : `ran ${lifespan}`); + if (daemon.restartCount > 0) meta.push(`restarts ${daemon.restartCount}`); + if (daemon.detached) meta.push("detached"); + else if (daemon.persist) meta.push("persistent"); + return meta; +} + +/** Op-specific call context (command line, log filters, wait condition, send payload). */ +function callMeta(args: LaunchRenderArgs): string[] { + const meta: string[] = []; + switch (args.op) { + case "start": + if (args.application) meta.push([args.application, ...(args.args ?? [])].join(" ")); + break; + case "logs": + if (args.follow) meta.push("follow"); + if (args.grep) meta.push(`grep /${args.grep}/`); + break; + case "wait": + meta.push(args.pattern ? `for /${args.pattern}/` : `for ${args.for ?? "exit"}`); + break; + case "send": + if (args.signal) meta.push(args.signal); + else if (args.text) meta.push(args.text); + if (args.keys?.length) meta.push(args.keys.join(" ")); + break; + } + return meta.map(entry => previewLine(replaceTabs(entry), TRUNCATE_LENGTHS.SHORT)); +} + +/** TUI renderer: one status header per op, meta from structured details, capped body lines. */ +export const launchToolRenderer = { + inline: true, + mergeCallAndResult: true, + animatedPendingPreview: true, + + renderCall(args: LaunchRenderArgs, options: RenderResultOptions, theme: Theme): Component { + const target = args.name ?? args.application; + const header = renderStatusLine( + { + icon: options.spinnerFrame !== undefined ? "running" : "pending", + spinnerFrame: options.spinnerFrame, + title: `Launch ${args.op ?? "…"}`, + description: target ? replaceTabs(target) : undefined, + meta: callMeta(args), + }, + theme, + ); + return new Text(header, 0, 0); + }, + + renderResult( + result: { content: Array<{ type: string; text?: string }>; details?: LaunchToolDetails; isError?: boolean }, + options: RenderResultOptions, + theme: Theme, + args?: LaunchRenderArgs, + ): Component { + const details = result.details; + const params = args ?? {}; + const op = details?.op ?? params.op; + const isError = result.isError === true; + const daemon = details?.daemon; + const failed = isError || daemon?.state === "failed"; + const text = + result.content + ?.filter(item => item.type === "text") + .map(item => item.text ?? "") + .join("\n") ?? ""; + + const meta: string[] = []; + const body: string[] = []; + let description = params.name ?? daemon?.name; + + if (isError) { + for (const line of replaceTabs(text.trimEnd()).split("\n")) body.push(theme.fg("error", line)); + } else { + switch (op) { + case "start": { + meta.push(...callMeta(params)); + if (daemon) meta.push(...daemonMeta(daemon, theme)); + if (daemon?.readyMatch) body.push(theme.fg("dim", `log matched: ${replaceTabs(daemon.readyMatch)}`)); + if (daemon?.state === "failed" && daemon.exitReason) + body.push(theme.fg("error", replaceTabs(daemon.exitReason))); + if (details?.timedOut) { + const pending = daemon ? readyPendingSummary(daemon, params.ready) : []; + body.push( + theme.fg( + "warning", + pending.length > 0 + ? `Not ready — ${pending.join("; ")}. Still running.` + : "Readiness timed out; the process is still running.", + ), + ); + } + break; + } + case "send": + meta.push(...callMeta(params)); + if (daemon) meta.push(...daemonMeta(daemon, theme)); + break; + case "stop": + case "restart": + if (daemon) meta.push(...daemonMeta(daemon, theme)); + break; + case "wait": { + meta.push(...callMeta(params)); + if (daemon) meta.push(...daemonMeta(daemon, theme)); + if (details?.matched) body.push(theme.fg("dim", `matched: ${replaceTabs(details.matched)}`)); + if (details?.timedOut) { + const pending = daemon ? readyPendingSummary(daemon) : []; + body.push( + theme.fg( + "warning", + pending.length > 0 + ? `Wait timed out — still waiting on ${pending.join("; ")}.` + : "Wait timed out.", + ), + ); + } + break; + } + case "list": { + const daemons = details?.daemons ?? []; + description = `${daemons.length || "no"} ${pluralize("process", daemons.length)}`; + for (const item of daemons) { + body.push( + `${theme.fg("accent", replaceTabs(item.name))} ${theme.fg("dim", daemonMeta(item, theme).join(theme.sep.dot))}`, + ); + } + break; + } + case "logs": { + if (details?.state) meta.push(theme.fg(stateColor(details.state), details.state)); + if (details?.cursor !== undefined) meta.push(`cursor ${details.cursor}`); + if (details?.timedOut) meta.push(theme.fg("warning", "follow timed out")); + // Strip the trailing `[name: state; cursor=N]` status suffix `toolContent` appends. + const logText = text.replace(/\n?\[[^\n]*\]$/, "").trimEnd(); + if (logText) { + for (const line of logText.split("\n")) body.push(theme.fg("toolOutput", replaceTabs(line))); + } + break; + } + case "describe": { + if (daemon) meta.push(...daemonMeta(daemon, theme)); + const spec = details?.spec; + if (spec) { + body.push(theme.fg("toolOutput", replaceTabs([spec.application, ...spec.args].join(" ")))); + body.push(theme.fg("dim", `cwd ${shortenPath(spec.cwd)}`)); + const flags = [`pty ${spec.pty}`, `restart ${spec.restart}`]; + if (spec.detached) flags.push("detached"); + else if (spec.persist) flags.push("persistent"); + body.push(theme.fg("dim", flags.join(theme.sep.dot))); + } + break; + } + default: + if (text.trim()) { + for (const line of replaceTabs(text.trimEnd()).split("\n")) body.push(theme.fg("toolOutput", line)); + } + } + } + + const header = renderStatusLine( + { + ...(failed + ? { icon: "error" as const } + : options.isPartial + ? { icon: "pending" as const } + : { iconOverride: theme.styledSymbol("tool.launch", "accent") }), + title: `Launch ${op ?? ""}`.trimEnd(), + description: description ? replaceTabs(description) : undefined, + meta, + }, + theme, + ); + + return createCachedComponent( + () => options.expanded, + (width, expanded) => { + let visible = body; + if (op === "logs") { + visible = capPreviewLines(body, theme, { expanded }); + } else if (!expanded && op === "list" && body.length > PREVIEW_LIMITS.COLLAPSED_ITEMS) { + const remaining = body.length - PREVIEW_LIMITS.COLLAPSED_ITEMS; + visible = [ + ...body.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS), + theme.fg("dim", `${formatMoreItems(remaining, "process")} ${formatExpandHint(theme, false, true)}`), + ]; + } + return [header, ...visible].map(line => truncateToWidth(line, width)); + }, + ); + }, +}; diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index d76bd1e9c..5e8850b42 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -24,6 +24,7 @@ import { grepToolRenderer } from "./grep"; import { inspectImageToolRenderer } from "./inspect-image-renderer"; import { ircToolRenderer } from "./irc"; import { jobToolRenderer } from "./job"; +import { launchToolRenderer } from "./launch"; import { recallToolRenderer, reflectToolRenderer, retainToolRenderer } from "./memory-render"; import { readToolRenderer } from "./read"; import { resolveToolRenderer } from "./resolve"; @@ -93,6 +94,7 @@ export const toolRenderers: Record = { lsp: lspToolRenderer as ToolRenderer, inspect_image: inspectImageToolRenderer as ToolRenderer, irc: ircToolRenderer as ToolRenderer, + launch: launchToolRenderer as ToolRenderer, read: readToolRenderer as ToolRenderer, job: jobToolRenderer as ToolRenderer, resolve: resolveToolRenderer as ToolRenderer, diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index 4c2ee6f28..3a4a33c2a 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -119,16 +119,6 @@ const PROVIDER_META: Record = { label: SEARCH_PROVIDER_LABELS.google, load: async () => new (await import("./providers/google")).GoogleProvider(), }, - bing: { - id: "bing", - label: SEARCH_PROVIDER_LABELS.bing, - load: async () => new (await import("./providers/bing")).BingProvider(), - }, - yahoo: { - id: "yahoo", - label: SEARCH_PROVIDER_LABELS.yahoo, - load: async () => new (await import("./providers/yahoo")).YahooProvider(), - }, ecosia: { id: "ecosia", label: SEARCH_PROVIDER_LABELS.ecosia, diff --git a/packages/coding-agent/src/web/search/providers/bing.ts b/packages/coding-agent/src/web/search/providers/bing.ts deleted file mode 100644 index aaad2d427..000000000 --- a/packages/coding-agent/src/web/search/providers/bing.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { AuthStorage } from "@oh-my-pi/pi-ai"; -import { parseHTML } from "linkedom"; -import type { SearchResponse, SearchSource } from "../../../web/search/types"; -import { SearchProviderError } from "../../../web/search/types"; -import { clampNumResults } from "../utils"; -import type { SearchParams } from "./base"; -import { SearchProvider } from "./base"; -import { browserFetch } from "./browser-page"; -import { classifyProviderHttpError, withHardTimeout } from "./utils"; - -/** - * Bing's HTML search frontend. A plain GET with browser navigation headers - * returns a fully server-rendered results page — no JavaScript challenge on - * the organic path — so we parse it directly without a real browser. - */ -const BING_HOME_URL = "https://www.bing.com/"; -const BING_SEARCH_URL = "https://www.bing.com/search"; -const DEFAULT_NUM_RESULTS = 10; -const MAX_NUM_RESULTS = 20; -const MS_PER_DAY = 86_400_000; - -/** - * Recency → Bing `filters=ex1:"…"` freshness codes, as emitted by Bing's own - * "Any time" dropdown. `year` has no fixed code; the dropdown emits a custom - * epoch-day range (`ez5__`, days since 1970-01-01) which - * {@link recencyToFilters} computes. Bing parses the parameter (the SERP - * filter UI reflects it) but enforcement is server-side and vantage-dependent. - */ -const RECENCY_TO_BING_EZ: Record, "year">, string> = { - day: "ez1", - week: "ez2", - month: "ez3", -}; - -/** Snippet containers observed on Bing result blocks, in preference order. */ -const BING_SNIPPET_SELECTORS: readonly string[] = [".b_caption p", "p[class*='b_lineclamp']", ".b_algoSlug"]; - -interface ParsedResult { - title: string; - url: string; - snippet?: string; -} - -/** Build the `filters` value for a recency window, mirroring Bing's dropdown URLs. */ -function recencyToFilters(recency: NonNullable): string { - if (recency === "year") { - const epochDay = Math.floor(Date.now() / MS_PER_DAY); - return `ex1:"ez5_${epochDay - 365}_${epochDay}"`; - } - return `ex1:"${RECENCY_TO_BING_EZ[recency]}"`; -} - -/** - * Resolve a Bing result href to the underlying target URL. - * - * Organic hrefs are usually wrapped as `https://www.bing.com/ck/a?…&u=a1` - * where the payload after the literal `a1` prefix is the unpadded base64url - * encoding of the target URL. Direct external hrefs also occur; Bing-internal - * links (vertical tabs, ads plumbing) and non-http(s) schemes are rejected. - */ -function unwrapResultUrl(href: string): string | undefined { - let url: URL; - try { - url = new URL(href, BING_HOME_URL); - } catch { - return undefined; - } - - if (url.hostname === "bing.com" || url.hostname.endsWith(".bing.com")) { - if (url.pathname !== "/ck/a") return undefined; - const wrapped = url.searchParams.get("u"); - if (!wrapped?.startsWith("a1")) return undefined; - try { - url = new URL(Buffer.from(wrapped.slice(2), "base64url").toString("utf-8")); - } catch { - return undefined; - } - } - - if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; - return url.href; -} - -function findSnippet(item: Element): string | undefined { - for (const selector of BING_SNIPPET_SELECTORS) { - const text = (item.querySelector(selector)?.textContent ?? "").replace(/\s+/g, " ").trim(); - if (text) return text; - } - return undefined; -} - -/** - * Pull organic result blocks out of the page in document order. - * - * Each organic hit is an `
  • ` with the title link in - * `h2 > a[href]` (sitelink/attribution anchors live outside the `h2`) and the - * preview text in one of {@link BING_SNIPPET_SELECTORS}. Ads, answer cards, - * and the "no results" row use other classes and fall out naturally. - */ -function parseHtmlResults(html: string): ParsedResult[] { - const { document } = parseHTML(html); - const results: ParsedResult[] = []; - for (const item of document.querySelectorAll("li.b_algo")) { - const anchor = item.querySelector("h2 a[href]"); - const href = anchor?.getAttribute("href"); - if (!href) continue; - const url = unwrapResultUrl(href); - if (!url) continue; - const title = (anchor?.textContent ?? "").replace(/\s+/g, " ").trim(); - if (!title) continue; - results.push({ title, url, snippet: findSnippet(item) }); - } - return results; -} - -/** - * `true` when Bing answered with its CAPTCHA/consent interstitial instead of - * a results page. The challenge redirects to `/turing/captcha/…`; body - * markers are only trusted when no organic result block is present so a - * search *about* CAPTCHAs never trips the detector. - */ -function isChallengeResponse(html: string, finalUrl: string): boolean { - if (finalUrl.includes("/turing/captcha")) return true; - if (html.includes('class="b_algo"')) return false; - return /turing\/captcha|b_captcha|px-captcha|verify (?:that )?you are (?:a )?human/i.test(html); -} - -function buildSearchUrl(params: SearchParams, numResults: number): string { - const url = new URL(BING_SEARCH_URL); - url.searchParams.set("q", params.query); - url.searchParams.set("count", String(numResults)); - url.searchParams.set("mkt", "en-US"); - url.searchParams.set("setlang", "en"); - if (params.recency) url.searchParams.set("filters", recencyToFilters(params.recency)); - return url.href; -} - -async function callBingHtml(params: SearchParams, numResults: number): Promise { - const url = buildSearchUrl(params, numResults); - const page = await browserFetch(url, { - fetch: params.fetch ?? fetch, - signal: withHardTimeout(params.signal), - referer: BING_HOME_URL, - }); - - const body = page.html; - if (isChallengeResponse(body, page.url)) { - throw new SearchProviderError( - "bing", - "Bing blocked the request with a CAPTCHA challenge. Bing throttles automated searches from datacenter/shared-egress IPs; try the duckduckgo or mojeek provider, or configure a credentialed provider such as Brave, Tavily, Exa, or Kagi.", - 429, - ); - } - if (page.status < 200 || page.status >= 300) { - const classified = classifyProviderHttpError("bing", page.status, body); - if (classified) throw classified; - throw new SearchProviderError("bing", `Bing HTML error (${page.status})`, page.status); - } - - return body; -} - -/** Execute a Bing web search via the server-rendered HTML results page. */ -export async function searchBing(params: SearchParams): Promise { - const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); - const html = await callBingHtml(params, numResults); - const parsed = parseHtmlResults(html); - - const sources: SearchSource[] = []; - const seen = new Set(); - for (const result of parsed) { - if (seen.has(result.url)) continue; - seen.add(result.url); - sources.push({ title: result.title, url: result.url, snippet: result.snippet }); - if (sources.length >= numResults) break; - } - - return { provider: "bing", sources }; -} - -/** Search provider for Bing (no API key required). */ -export class BingProvider extends SearchProvider { - readonly id = "bing"; - readonly label = "Bing"; - - isAvailable(_authStorage: AuthStorage): boolean { - return true; - } - - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { - return true; - } - - search(params: SearchParams): Promise { - return searchBing(params); - } -} diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index 1aa789978..757d49a8b 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -581,11 +581,27 @@ async function callPerplexityAsk( search_recency_filter: params.search_recency_filter ?? null, is_incognito: true, use_schematized_api: true, - skip_search_enabled: true, + // `true` (the native app's default) lets the backend classifier skip + // retrieval for queries it deems answerable from memory — the model then + // runs ungrounded and refuses with "I don't currently have live access". + // We are a search tool; always retrieve. + skip_search_enabled: false, + // Belt and braces with `skip_search_enabled: false`: the web client sets + // this to force retrieval even when the skip classifier fires. + always_search_override: true, + prompt_source: "user", + source: "default", + local_search_enabled: false, + // Declare no tool-approval UI and no local (Comet) browser agent, so the + // stream never stalls waiting for a confirmation we cannot render. + should_ask_for_mcp_tool_confirmation: false, + supports_tool_approval_modal: false, + force_enable_browser_agent: false, + is_local_browser_available: false, + is_local_browser_allowed: false, }; if (auth.type === "anonymous") { requestParams.send_back_text_in_streaming_api = true; - requestParams.source = "default"; } const response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, { diff --git a/packages/coding-agent/src/web/search/providers/public.ts b/packages/coding-agent/src/web/search/providers/public.ts index 4e0fb7ce8..34c5a5617 100644 --- a/packages/coding-agent/src/web/search/providers/public.ts +++ b/packages/coding-agent/src/web/search/providers/public.ts @@ -11,15 +11,13 @@ import { withHardTimeout } from "./utils"; * Credential-free engines the Public Web aggregate fans out to. Order is the * tiebreak for merged ranking (earlier engines win equal consensus/rank), so * engines with the best ranking quality when they answer come first: - * Google-index engines (startpage, google) lead, Bing-backed scrapers follow, - * and Mojeek's independent index breaks remaining ties (measured 2026-07). + * Google-index engines (startpage, google) lead, and Mojeek's independent + * index breaks remaining ties (measured 2026-07). */ const PUBLIC_ENGINE_IDS = [ "startpage", "google", "duckduckgo", - "bing", - "yahoo", "ecosia", "mojeek", ] as const satisfies readonly SearchProviderId[]; diff --git a/packages/coding-agent/src/web/search/providers/yahoo.ts b/packages/coding-agent/src/web/search/providers/yahoo.ts deleted file mode 100644 index bdc1e312f..000000000 --- a/packages/coding-agent/src/web/search/providers/yahoo.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { AuthStorage } from "@oh-my-pi/pi-ai"; -import { parseHTML } from "linkedom"; -import type { SearchResponse, SearchSource } from "../../../web/search/types"; -import { SearchProviderError } from "../../../web/search/types"; -import { clampNumResults } from "../utils"; -import type { SearchParams } from "./base"; -import { SearchProvider } from "./base"; -import { browserFetch } from "./browser-page"; -import { classifyProviderHttpError, withHardTimeout } from "./utils"; - -/** - * Yahoo Search's server-rendered results page. A plain GET with browser - * navigation headers returns the full SERP without any JavaScript challenge, - * so no headless-browser fallback is needed (verified live 2026-07). - */ -const YAHOO_HOME_URL = "https://search.yahoo.com/"; -const YAHOO_SEARCH_URL = "https://search.yahoo.com/search"; -const DEFAULT_NUM_RESULTS = 10; -const MAX_NUM_RESULTS = 20; - -/** - * Recency → Yahoo `btf` query param. Yahoo's time filter only offers - * day/week/month; `year` has no equivalent and is silently dropped per the - * {@link SearchParams.recency} contract. - */ -const RECENCY_TO_YAHOO_BTF: Partial, string>> = { - day: "d", - week: "w", - month: "m", -}; - -interface ParsedResult { - title: string; - url: string; - snippet?: string; -} - -/** - * Resolve a Yahoo result href back to the underlying target URL. - * - * Organic hrefs are wrapped through the click tracker - * `https://r.search.yahoo.com/_ylt=…/RU=/RK=…/RS=…`; - * the `/RU=` path segment carries the destination. Older layouts emit plain - * absolute hrefs, so both shapes are handled. Tracker links without a - * recoverable target and Yahoo-internal navigation are rejected. - */ -function unwrapResultUrl(href: string): string | undefined { - let url: URL; - try { - url = new URL(href, YAHOO_HOME_URL); - } catch { - return undefined; - } - if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; - - const wrapped = /\/RU=([^/]+)/.exec(url.pathname); - if (wrapped) { - let target: string; - try { - target = decodeURIComponent(wrapped[1]); - } catch { - return undefined; - } - return target.startsWith("http://") || target.startsWith("https://") ? target : undefined; - } - // A tracker link without an RU segment has no recoverable destination. - if (url.hostname === "r.search.yahoo.com") return undefined; - // Relative hrefs resolve against the search host: internal navigation. - if (url.hostname === "search.yahoo.com") return undefined; - return url.href; -} - -/** - * Walk the SERP and pull organic result blocks in document order. - * - * Organics render as `
    ` blocks (inside `#web`'s - * `
      `): the title `

      ` sits inside the tracker `` in the current - * layout, while legacy layouts nested the `` inside `

      ` - * — both are handled. The preview text lives in a sibling - * `
      `. Module headers ("Videos", "People also ask") - * carry `

      `s outside `.algo` blocks and are excluded by construction. - */ -function parseHtmlResults(html: string): ParsedResult[] { - const { document } = parseHTML(html); - const results: ParsedResult[] = []; - for (const block of document.querySelectorAll("div.algo")) { - const heading = block.querySelector("h3"); - if (!heading) continue; - const anchor = heading.querySelector("a") ?? heading.closest("a"); - const href = anchor?.getAttribute("href"); - if (!href) continue; - const url = unwrapResultUrl(href); - if (!url) continue; - const title = (heading.textContent ?? "").replace(/\s+/g, " ").trim(); - if (!title) continue; - const snippet = (block.querySelector(".compText")?.textContent ?? "").replace(/\s+/g, " ").trim() || undefined; - results.push({ title, url, snippet }); - } - return results; -} - -/** - * `true` when Yahoo answered with its EU consent interstitial instead of - * results: either the request was redirected to consent.yahoo.com / - * guce.yahoo.com, or the body carries the consent form. The normal SERP - * mentions guce.yahoo.com only in a meta tag, so detection keys on the - * consent-host redirect and the `collectConsent` form action. - */ -function isConsentInterstitial(finalUrl: string, html: string): boolean { - if (/^https?:\/\/(?:[^/]*\.)?(?:consent|guce)\.yahoo\.com\//i.test(finalUrl)) return true; - return html.includes("consent.yahoo.com") || html.includes("collectConsent"); -} - -async function callYahooHtml(params: SearchParams, numResults: number): Promise { - const url = new URL(YAHOO_SEARCH_URL); - url.searchParams.set("p", params.query); - url.searchParams.set("n", String(numResults)); - const btf = params.recency ? RECENCY_TO_YAHOO_BTF[params.recency] : undefined; - if (btf) url.searchParams.set("btf", btf); - - const page = await browserFetch(url.href, { - fetch: params.fetch ?? fetch, - signal: withHardTimeout(params.signal), - referer: YAHOO_HOME_URL, - }); - - const body = page.html; - if (page.status < 200 || page.status >= 300) { - const classified = classifyProviderHttpError("yahoo", page.status, body); - if (classified) throw classified; - throw new SearchProviderError("yahoo", `Yahoo HTML error (${page.status})`, page.status); - } - - if (isConsentInterstitial(page.url, body)) { - throw new SearchProviderError( - "yahoo", - "Yahoo served its GDPR consent interstitial instead of search results. This typically affects EU egress IPs; use another web search provider such as DuckDuckGo, Brave, or Mojeek.", - 429, - ); - } - - return body; -} - -/** Execute a Yahoo web search via the server-rendered HTML results page. */ -export async function searchYahoo(params: SearchParams): Promise { - const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); - const html = await callYahooHtml(params, numResults); - const parsed = parseHtmlResults(html); - - const sources: SearchSource[] = []; - const seen = new Set(); - for (const result of parsed) { - if (seen.has(result.url)) continue; - seen.add(result.url); - sources.push({ title: result.title, url: result.url, snippet: result.snippet }); - if (sources.length >= numResults) break; - } - - return { provider: "yahoo", sources }; -} - -/** Search provider for Yahoo (no API key required). */ -export class YahooProvider extends SearchProvider { - readonly id = "yahoo"; - readonly label = "Yahoo"; - - isAvailable(_authStorage: AuthStorage): boolean { - return true; - } - - isExplicitlyAvailable(_authStorage: AuthStorage): boolean { - return true; - } - - search(params: SearchParams): Promise { - return searchYahoo(params); - } -} diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 477525ea1..86ac098fa 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -53,16 +53,6 @@ export const SEARCH_PROVIDER_OPTIONS = [ label: "DuckDuckGo", description: "Credential-free best-effort fallback; may be bot-challenged on datacenter/shared-egress IPs", }, - { - value: "bing", - label: "Bing", - description: "Credential-free HTML scrape of Bing results; may be bot-challenged", - }, - { - value: "yahoo", - label: "Yahoo", - description: "Credential-free HTML scrape of Yahoo (Bing-backed) results", - }, { value: "ecosia", label: "Ecosia", diff --git a/packages/coding-agent/test/agent-hub-ordering.test.ts b/packages/coding-agent/test/agent-hub-ordering.test.ts index 1c34634ad..c246af4c0 100644 --- a/packages/coding-agent/test/agent-hub-ordering.test.ts +++ b/packages/coding-agent/test/agent-hub-ordering.test.ts @@ -53,15 +53,14 @@ function makeHub(agents: AgentRegistry) { } function renderedAgentIds(hub: AgentHubOverlayComponent): string[] { - return hub - .render(120) - .map(line => Bun.stripANSI(line)) - .map(line => line.split(" · ")) - .filter( - parts => - parts.length >= 4 && ["running", "idle", "parked", "aborted"].some(status => parts[0].endsWith(status)), - ) - .map(parts => parts[1]!); + // Entry first lines are ` …`; task lines are + // indented deeper and chrome lines never carry the cursor slot. + const ids: string[] = []; + for (const raw of hub.render(120)) { + const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw)); + if (match) ids.push(match[2]!); + } + return ids; } describe("Agent hub row ordering", () => { diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index e537902df..f39cec861 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -11,6 +11,7 @@ import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensio import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { CompactionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; @@ -416,6 +417,75 @@ describe("AgentSession auto-compaction progress guard", () => { const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); expect(noProgress.length).toBe(0); }); + + it("rebases the in-flight prompt snapshot so mid-run compaction is not misread as a dead-end", async () => { + // Regression: the pending context snapshot is set once per prompt and + // lives for the whole run. A fresh compaction entry hides every earlier + // usage anchor from getContextBreakdown, which then fell back to the + // stale run-start figure until the next provider response — a run + // submitted above the recovery band (0.8 × 170k = 136k here) tripped the + // "freed too little context" warning even though compaction had + // genuinely shrunk the context (observed live: 312k → 86k real tokens, + // warning still emitted). + const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); + // Hold the initial prompt in flight so the pending snapshot stays alive + // through the compaction, exactly like a live tool-loop run. The second + // agent.prompt call is the scheduled auto-continue — the "headroom was + // seen" signal the test awaits. + const gate = Promise.withResolvers(); + const firstPromptCall = Promise.withResolvers(); + const secondPromptCall = Promise.withResolvers(); + let promptCalls = 0; + const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(() => { + promptCalls++; + if (promptCalls === 1) firstPromptCall.resolve(); + if (promptCalls === 2) secondPromptCall.resolve(); + return gate.promise as never; + }); + + const notices = collectNotices(); + // The dead-end warning is the "no headroom was seen" signal: the headroom + // tail runs AFTER auto_compaction_end is emitted, so the test awaits one + // of the tail's two observable outcomes instead of the end event. + const noProgressSeen = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "notice" && event.message.includes(NO_PROGRESS_FRAGMENT)) noProgressSeen.resolve(); + }); + + // ~150k-token prompt: above the recovery band, below the 170k threshold, + // so the pre-prompt maintenance pass stays quiet and the snapshot records + // the run-start size. agent.prompt is mocked, so the text never reaches + // the branch — it exists only in the in-flight snapshot. + const inFlightPrompt = session.prompt("x".repeat(600_000)); + // The snapshot is written immediately before agent.prompt; awaiting the + // first (gated) call guarantees it is in place before the threshold turn + // lands — emitting earlier would race the submission pipeline and let + // compaction run against an unset snapshot. + await firstPromptCall.promise; + + // Mid-run, the billed context crosses the threshold and compaction fires; + // the rewritten context (summary only) is tiny. + const assistantMsg = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + // Wait for the headroom verdict while the prompt is still gated — + // releasing the gate earlier would clear the snapshot and mask the + // regression. Fixed behavior schedules the auto-continue (second prompt + // call); the regression emits the dead-end warning instead. + await Promise.race([secondPromptCall.promise, noProgressSeen.promise]); + + gate.resolve(); + await inFlightPrompt; + await session.waitForIdle(); + + // The stale 150k run-start snapshot must not be measured as residual + // context: no dead-end warning, and the auto-continue prompt ran + // (initial call + continuation). + expect(promptSpy).toHaveBeenCalledTimes(2); + expect(continueSpy).not.toHaveBeenCalled(); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(0); + }); /** * Seed several large prior turns into the session branch so `prepareCompaction` * returns a real preparation after the overflow recovery drops the failed @@ -1161,5 +1231,68 @@ describe("AgentSession auto-compaction progress guard", () => { expect(noProgress[0].level).toBe("warning"); const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); expect(recovery.length).toBe(0); + // The dead-end is also stamped on the compaction entry so the transcript + // divider badges the pause and carries the warning across rebuilds/resume. + const compactionEntry = sessionManager + .getEntries() + .filter((e): e is CompactionEntry => e.type === "compaction") + .at(-1); + expect(compactionEntry?.warning).toContain(NO_PROGRESS_FRAGMENT); + }); + + it("auto-continues (no warning) when the image-drop tier frees an image-only tail", async () => { + // Elide cannot touch image content (collectShakeRegions skips image-only + // tool results and user-message images), so the rescue's second tier drops + // attached images — the automated `/shake images` remedy — and re-tests + // the recovery band before the guard is allowed to pause. + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + let imagesDropped = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + imagesDropped + ? { tokens: 1000, contextWindow: 200000, percent: 0.5 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + // Nothing elide-eligible in the oversized tail. + vi.spyOn(session, "shake").mockResolvedValue({ + mode: "elide", + toolResultsDropped: 0, + blocksDropped: 0, + tokensFreed: 0, + }); + const dropSpy = vi.spyOn(session, "dropImages").mockImplementation(async () => { + imagesDropped = true; + return { removed: 2 }; + }); + + const notices = collectNotices(); + + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + + const assistantMsg = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + await compactionDone; + await session.waitForIdle(); + + expect(dropSpy).toHaveBeenCalledTimes(1); + expect(promptSpy).toHaveBeenCalledTimes(1); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(0); + const recovery = notices.filter( + n => n.source === NOTICE_SOURCE && n.message.includes("dropped 2 attached images"), + ); + expect(recovery.length).toBe(1); + expect(recovery[0].level).toBe("info"); + // A rescued pass must not stamp the dead-end warning on the entry. + const compactionEntry = sessionManager + .getEntries() + .filter((e): e is CompactionEntry => e.type === "compaction") + .at(-1); + expect(compactionEntry?.warning).toBeUndefined(); }); }); diff --git a/packages/coding-agent/test/agent-session-downshift.test.ts b/packages/coding-agent/test/agent-session-downshift.test.ts new file mode 100644 index 000000000..9b697ba26 --- /dev/null +++ b/packages/coding-agent/test/agent-session-downshift.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import { type Api, Effort, type Model, z } from "@oh-my-pi/pi-ai"; +import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +/** + * Downshift: one-way switch from the starting model to a fast/cheap target + * at the first completed turn that starts execution — an edit/write tool, + * or the todo-list init the plan nudge asks for — with a hidden plan nudge + * before the switch and a hidden verify-before-finishing checklist after + * it. This is the single mechanism that won out over fixed-turn and + * ungated variants in benchmark testing — see the plan nudge / checklist / + * continuation-safety-net prompts under `src/prompts/system/downshift-*.md`. + */ +describe("AgentSession downshift", () => { + let tempDir: TempDir; + let authStorage: AuthStorage; + let session: AgentSession | undefined; + + beforeEach(async () => { + tempDir = TempDir.createSync("@pi-downshift-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + }); + + afterEach(async () => { + if (session) await session.dispose(); + authStorage.close(); + tempDir.removeSync(); + }); + + function modelOrThrow(id: string): Model { + const model = getBundledModel("anthropic", id); + if (!model) throw new Error(`Expected bundled model ${id}`); + return model; + } + + const recordToolSchema = z.object({}); + const recordTool: AgentTool = { + name: "record", + label: "Record", + description: "Read-only step", + parameters: recordToolSchema, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: undefined }; + }, + }; + const bashToolSchema = z.object({}); + const bashTool: AgentTool = { + name: "bash", + label: "Bash", + description: "Run a command", + parameters: bashToolSchema, + async execute() { + return { content: [{ type: "text", text: "ran" }], details: undefined }; + }, + }; + const writeToolSchema = z.object({}); + const writeTool: AgentTool = { + name: "write", + label: "Write", + description: "Write a file", + parameters: writeToolSchema, + async execute() { + return { content: [{ type: "text", text: "wrote" }], details: undefined }; + }, + }; + const todoToolSchema = z.object({}); + const todoTool: AgentTool = { + name: "todo", + label: "Todo", + description: "Track tasks", + parameters: todoToolSchema, + async execute() { + return { content: [{ type: "text", text: "listed" }], details: undefined }; + }, + }; + const toolRegistry = new Map([ + [recordTool.name, recordTool as AgentTool], + [bashTool.name, bashTool as AgentTool], + [writeTool.name, writeTool as AgentTool], + [todoTool.name, todoTool as AgentTool], + ]); + + function toolCall(id: string, name: string): MockResponse { + return { content: [{ type: "toolCall", id, name, arguments: {} }], stopReason: "toolUse" }; + } + + function contextMessagesHaveMarker(contextMessages: ReadonlyArray<{ role: string }>, marker: string): boolean { + return contextMessages.some(message => { + if (message.role !== "user" && message.role !== "developer") return false; + if (!("content" in message)) return false; + const content: unknown = message.content; + if (typeof content === "string") return content.includes(marker); + if (!Array.isArray(content)) return false; + return content.some(block => { + if (typeof block !== "object" || block === null) return false; + if (!("type" in block) || block.type !== "text") return false; + return "text" in block && typeof block.text === "string" && block.text.includes(marker); + }); + }); + } + + it("downshifts at the first edit/write after the todo gate opens; bash and todo don't trigger", async () => { + const primary = modelOrThrow("claude-sonnet-4-5"); + const target = modelOrThrow("claude-sonnet-4-6"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const planMarker = "complete plan in your NEXT reply"; + const checklistMarker = "grep for every other call site"; + + // Turn 1: read-only (nudge injected after). Turn 2: bash — excluded. + // Turn 3: todo — opens the gate, must NOT itself switch. Turn 4: write — + // first post-todo edit/write, switch. + const mock = createMockModel({ + responses: [ + toolCall("t1", "record"), + toolCall("t2", "bash"), + toolCall("t3", "todo"), + toolCall("t4", "write"), + { content: ["done"] }, + ], + }); + const calls: Array<{ model: string; hasNudge: boolean; hasChecklist: boolean }> = []; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model: primary, + systemPrompt: ["Test"], + tools: [recordTool as AgentTool, bashTool as AgentTool, writeTool as AgentTool, todoTool as AgentTool], + messages: [], + thinkingLevel: Effort.Medium, + }, + convertToLlm, + streamFn: (model, context, options) => { + calls.push({ + model: `${model.provider}/${model.id}`, + hasNudge: contextMessagesHaveMarker(context.messages, planMarker), + hasChecklist: contextMessagesHaveMarker(context.messages, checklistMarker), + }); + return mock.stream(model, context, options); + }, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry, + toolRegistry, + downshift: { target }, + }); + + await session.prompt("do the task"); + + expect(calls.map(call => call.model)).toEqual([ + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${target.provider}/${target.id}`, + ]); + // Nudge absent on turn 1 (not yet injected), present turns 2-4, scrubbed after the switch. + expect(calls.map(call => call.hasNudge)).toEqual([false, true, true, true, false]); + // Checklist present only once the target model is running. + expect(calls.map(call => call.hasChecklist)).toEqual([false, false, false, false, true]); + expect(session.model?.id).toBe(target.id); + }); + + it("an edit before any todo call does not switch while a todo tool exists; the next edit after todo does", async () => { + const primary = modelOrThrow("claude-sonnet-4-5"); + const target = modelOrThrow("claude-sonnet-4-6"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const planMarker = "complete plan in your NEXT reply"; + + // Turn 1: exploration (nudge after). Turn 2: write with the gate still + // closed — no switch; the fast model must not inherit a todo-less run. + // Turn 3: todo — gate opens. Turn 4: write — switch. + const mock = createMockModel({ + responses: [ + toolCall("t1", "record"), + toolCall("t2", "write"), + toolCall("t3", "todo"), + toolCall("t4", "write"), + { content: ["done"] }, + ], + }); + const calls: Array<{ model: string; hasNudge: boolean }> = []; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model: primary, + systemPrompt: ["Test"], + tools: [recordTool as AgentTool, writeTool as AgentTool, todoTool as AgentTool], + messages: [], + thinkingLevel: Effort.Medium, + }, + convertToLlm, + streamFn: (model, context, options) => { + calls.push({ + model: `${model.provider}/${model.id}`, + hasNudge: contextMessagesHaveMarker(context.messages, planMarker), + }); + return mock.stream(model, context, options); + }, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry, + toolRegistry, + downshift: { target }, + }); + + await session.prompt("do the task"); + + expect(calls.map(call => call.model)).toEqual([ + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${target.provider}/${target.id}`, + ]); + // The turn-2 write landed while the gate was closed — still primary on turn 3. + expect(calls.map(call => call.hasNudge)).toEqual([false, true, true, true, false]); + expect(session.model?.id).toBe(target.id); + }); + + it("forces a continuation when the plan nudge gets a text-only reply, instead of silently ending the run", async () => { + // Regression: the agent loop treats a turn with zero tool calls as a + // natural stop boundary and ends the session with no further prompting. + // The plan nudge explicitly asks for a prose reply, making this common + // right after it — observed killing production runs before any code + // was written. The safety net must force one more turn. + const primary = modelOrThrow("claude-sonnet-4-5"); + const target = modelOrThrow("claude-sonnet-4-6"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + + const mock = createMockModel({ + responses: [ + toolCall("t1", "record"), + { content: [{ type: "text", text: "Let me think about this for a moment." }], stopReason: "stop" }, + toolCall("t3", "write"), + { content: ["done"] }, + ], + }); + const requested: string[] = []; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model: primary, + systemPrompt: ["Test"], + tools: [recordTool as AgentTool, writeTool as AgentTool], + messages: [], + thinkingLevel: Effort.Medium, + }, + convertToLlm, + streamFn: (model, context, options) => { + requested.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry, + toolRegistry: new Map([ + [recordTool.name, recordTool as AgentTool], + [writeTool.name, writeTool as AgentTool], + ]), + downshift: { target }, + }); + + await session.prompt("do the task"); + + // All 4 turns must run — the text-only turn 2 must not end the session early. + expect(requested).toEqual([ + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${target.provider}/${target.id}`, + ]); + expect(session.model?.id).toBe(target.id); + }); + + it("armDownshift (the /downshift slash command) pre-arms the switch for the very next edit/write", async () => { + const primary = modelOrThrow("claude-sonnet-4-5"); + const target = modelOrThrow("claude-sonnet-4-6"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + + // No `downshift` in the session config — this simulates a session that + // was NOT started with --downshift, forced on via the slash command. + const mock = createMockModel({ responses: [toolCall("t1", "write"), { content: ["done"] }] }); + const requested: string[] = []; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model: primary, + systemPrompt: ["Test"], + tools: [writeTool as AgentTool], + messages: [], + thinkingLevel: Effort.Medium, + }, + convertToLlm, + streamFn: (model, context, options) => { + requested.push(`${model.provider}/${model.id}`); + return mock.stream(model, context, options); + }, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry, + toolRegistry: new Map([[writeTool.name, writeTool as AgentTool]]), + }); + + // Arming twice back-to-back must stay a single, idempotent arm. + session.armDownshift(target); + session.armDownshift(target); + + await session.prompt("do the task"); + + // Pre-armed before the first turn: the very first write call switches + // immediately — no second primary-model turn needed. + expect(requested).toEqual([`${primary.provider}/${primary.id}`, `${target.provider}/${target.id}`]); + expect(session.model?.id).toBe(target.id); + }); +}); diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 5c47c74b4..f849e0aa0 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -233,7 +233,7 @@ describe("AgentSession message pipeline", () => { expect(requestOnPayload).toHaveBeenCalledWith({ original: true, session: true }, undefined); expect(result).toEqual({ original: true, session: true }); }); - it("keeps ephemeral side-channel cache key separate from provider routing", async () => { + it("keeps ephemeral side-channel cache key separate from provider routing while preserving websocket state", async () => { const api = "test-ephemeral-side-channel"; let capturedOptions: SimpleStreamOptions | undefined; registerCustomApi(api, (_model, _context, options) => { @@ -271,6 +271,7 @@ describe("AgentSession message pipeline", () => { sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "compaction.enabled": false }), modelRegistry: createModelRegistryStub() as never, + preferWebsockets: true, }); sessions.push(session); const cacheSessionId = session.sessionId; @@ -281,7 +282,8 @@ describe("AgentSession message pipeline", () => { expect(capturedOptions?.promptCacheKey).toBe(cacheSessionId); expect(capturedOptions?.sessionId).toStartWith(`${cacheSessionId}:side:`); expect(capturedOptions?.sessionId).not.toBe(cacheSessionId); - expect(capturedOptions?.preferWebsockets).toBe(false); + expect(capturedOptions?.preferWebsockets).toBe(true); + expect(capturedOptions?.providerSessionState).toBe(session.providerSessionState); }); it("runs ephemeral side-channel requests through the configured side stream function", async () => { diff --git a/packages/coding-agent/test/agent-session-prune-persistence.test.ts b/packages/coding-agent/test/agent-session-prune-persistence.test.ts new file mode 100644 index 000000000..3b78c3657 --- /dev/null +++ b/packages/coding-agent/test/agent-session-prune-persistence.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import { USELESS_NOTICE } from "@oh-my-pi/pi-agent-core/compaction/pruning"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +/** + * Regression: the per-turn supersede/useless prune pass rewrote the LIVE agent + * context but never persisted the rewrite, so the session file kept the + * original (un-pruned) history. Anything that rebuilds from the file — `/tan` + * and `/fork` clones, session resume — then produced a divergent, larger + * prefix and cold-missed the provider prompt cache the parent had populated. + * + * Contract: after the prune fires, rebuilding the session from disk yields the + * same message content as the live agent state. + */ +describe("AgentSession per-turn prune persistence", () => { + let tempDir: TempDir; + let session: AgentSession; + let sessionManager: SessionManager; + let authStorage: AuthStorage; + + const BIG_CALL_ID = "call-big-useless"; + + beforeEach(async () => { + tempDir = TempDir.createSync("@pi-prune-persistence-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + + const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!bundled) throw new Error("Expected built-in anthropic model to exist"); + const model = { ...bundled, contextWindow: 200_000, maxTokens: 64_000 }; + + const now = Date.now(); + const usageZero = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + sessionManager.appendMessage({ + role: "user", + content: "Investigate every module of the project.", + timestamp: now - 200, + }); + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: BIG_CALL_ID, name: "grep", arguments: { pattern: "TODO" } }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "toolUse", + usage: usageZero, + timestamp: now - 180, + }); + // The only prune candidate: a big result flagged useless whose suffix + // stays inside the cache-warm window, so the pass rewrites it. + sessionManager.appendMessage({ + role: "toolResult", + toolCallId: BIG_CALL_ID, + toolName: "grep", + content: [{ type: "text", text: "match line\n".repeat(20000) }], + isError: false, + useless: true, + timestamp: now - 170, + }); + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "Nothing relevant found; moving on." }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + usage: usageZero, + timestamp: now - 160, + }); + + const agent = new Agent({ + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + session = new AgentSession({ + agent, + sessionManager, + settings: Settings.isolated({ + "compaction.enabled": false, + "compaction.dropUseless": true, + "compaction.supersedeReads": true, + }), + modelRegistry, + }); + session.agent.replaceMessages(session.buildDisplaySessionContext().messages); + }); + + afterEach(async () => { + try { + await session?.dispose(); + } finally { + authStorage?.close(); + await tempDir?.remove(); + } + }); + + function liveResultText(): string { + const message = session.agent.state.messages.find( + candidate => candidate.role === "toolResult" && candidate.toolCallId === BIG_CALL_ID, + ); + if (!message || message.role !== "toolResult" || !Array.isArray(message.content)) { + throw new Error("Expected the seeded tool result in live agent state"); + } + const text = message.content.find(block => block.type === "text"); + if (text?.type !== "text") throw new Error("Expected text content on the seeded tool result"); + return text.text; + } + + it("persists the pruned rewrite so a from-disk rebuild matches the live context", async () => { + const finalAssistant = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Continuing." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 100, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 110, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + session.agent.emitExternalEvent({ type: "message_end", message: finalAssistant }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [finalAssistant] }); + await session.waitForIdle(); + + // The per-turn pass rewrote the live context… + expect(liveResultText()).toBe(USELESS_NOTICE); + + // …and the persisted file must rebuild to the SAME content (fork/resume + // read this file; a divergent prefix cold-misses the provider cache). + await sessionManager.flush(); + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file"); + const reloaded = await SessionManager.open(sessionFile, tempDir.path()); + const rebuilt = reloaded + .buildSessionContext() + .messages.find(candidate => candidate.role === "toolResult" && candidate.toolCallId === BIG_CALL_ID); + if (!rebuilt || rebuilt.role !== "toolResult" || !Array.isArray(rebuilt.content)) { + throw new Error("Expected the seeded tool result in the from-disk rebuild"); + } + const rebuiltText = rebuilt.content.find(block => block.type === "text"); + expect(rebuiltText?.type === "text" ? rebuiltText.text : undefined).toBe(USELESS_NOTICE); + }); +}); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 25c8c6791..d9713b1fa 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -219,6 +219,418 @@ describe("AgentSession retry fallback", () => { ]); }); + it("activates a model-keyed fallback chain without any role assignment", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + [`${primaryModel.provider}/${primaryModel.id}`]: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Recover via model-keyed chain"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe(fallbackModel.provider); + expect(session.model?.id).toBe(fallbackModel.id); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + role: `${primaryModel.provider}/${primaryModel.id}`, + }, + ]); + }); + + it("prefers a model-keyed chain over the matching role chain", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const modelKeyFallback = getBundledModel("openai", "gpt-4o-mini"); + const roleChainFallback = getBundledModel("openai", "gpt-4o"); + if (!primaryModel || !modelKeyFallback || !roleChainFallback) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + default: [`${roleChainFallback.provider}/${roleChainFallback.id}`], + [`${primaryModel.provider}/${primaryModel.id}`]: [`${modelKeyFallback.provider}/${modelKeyFallback.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Model-keyed chain wins"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${modelKeyFallback.provider}/${modelKeyFallback.id}`, + ]); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${modelKeyFallback.provider}/${modelKeyFallback.id}`, + role: `${primaryModel.provider}/${primaryModel.id}`, + }, + ]); + }); + + it("falls back to the chain when credential rotation exhausts the retry budget", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const mock = createMockModel(); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model: primaryModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + if (model.provider === primaryModel.provider && model.id === primaryModel.id) { + mock.push({ throw: "429 usage_limit_reached" }); + } else { + mock.push({ content: [`ok:${model.provider}/${model.id}`] }); + } + return mock.stream(model, context, options); + }, + }); + + // Rotation always claims a sibling credential is available — the shape + // of a multi-account pool where the sibling check passes but every + // subsequent request keeps failing on the same capped account. + vi.spyOn(modelRegistry.authStorage, "markUsageLimitReached").mockResolvedValue({ switched: true }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxRetries": 2, + "retry.fallbackChains": { + [`${primaryModel.provider}/${primaryModel.id}`]: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + + await session.prompt("Exhaust rotation, then fail over"); + await session.waitForIdle(); + + // Two rotation retries burn the budget on the primary; the exhausted + // attempt consults the chain instead of giving up. + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${primaryModel.provider}/${primaryModel.id}`, + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe(fallbackModel.provider); + expect(session.model?.id).toBe(fallbackModel.id); + // The fallback model gets a fresh retry budget (attempt resets to 1). + expect(retryStartEvents.map(event => event.attempt)).toEqual([1, 2, 1]); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ success: true }); + }); + + it("applies a provider-wildcard chain to any model of that provider", async () => { + const primaryModel = getBundledModel("anthropic", "claude-opus-4-1"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + // No exact key for this model and no role assignment: only the + // `anthropic/*` wildcard can match, proving provider-level coverage. + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + "anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Recover via provider wildcard"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe(fallbackModel.provider); + expect(session.model?.id).toBe(fallbackModel.id); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + role: "anthropic/*", + }, + ]); + }); + + it("consults the fallback chain on a non-retryable hard error instead of failing the turn", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const mock = createMockModel(); + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model: primaryModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + if (model.provider === primaryModel.provider) { + // Classifies as neither transient, usage-limit, nor auth: + // the generic retry classifier rejects it outright. + mock.push({ throw: "unrecoverable model quirk" }); + } else { + mock.push({ content: ["Recovered on fallback"] }); + } + return mock.stream(model, context, options); + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.fallbackChains": { + "anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Survive a hard error"); + await session.waitForIdle(); + + // Exactly one attempt on the failing model: a hard error switches models + // immediately, it never backoff-retries the same model. + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + role: "anthropic/*", + }, + ]); + expect(session.model?.provider).toBe(fallbackModel.provider); + expect(getLastAssistantMessage(session).stopReason).toBe("stop"); + }); + + it("surfaces a non-retryable error without same-model retries when no fallback candidate has a credential", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const originalGetApiKey = modelRegistry.getApiKey.bind(modelRegistry); + vi.spyOn(modelRegistry, "getApiKey").mockImplementation((model, sessionId) => + model.provider === fallbackModel.provider ? Promise.resolve(undefined) : originalGetApiKey(model, sessionId), + ); + + const mock = createMockModel(); + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model: primaryModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (model, context, options) => { + requestedModels.push(`${model.provider}/${model.id}`); + mock.push({ throw: "unrecoverable model quirk" }); + return mock.stream(model, context, options); + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.fallbackChains": { + "anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Fail hard with no fallback credential"); + await session.waitForIdle(); + + // The switch could not happen and the error is non-retryable: surface it + // after a single attempt instead of backoff-retrying the failing model. + expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); + expect(fallbackAppliedEvents).toEqual([]); + expect(getLastAssistantMessage(session).stopReason).toBe("error"); + }); + + it("substitutes the failing model id into provider-wildcard chain entries", async () => { + const primaryModel = getBundledModel("google", "gemini-2.5-flash"); + const fallbackModel = getBundledModel("google-vertex", "gemini-2.5-flash"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + // `google-vertex/*` is not a fixed target: it must adopt the failing + // model's id (google/gemini-2.5-flash -> google-vertex/gemini-2.5-flash). + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + "google/*": ["google-vertex/*"], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Recover via id-preserving wildcard entry"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe("google-vertex"); + expect(session.model?.id).toBe(primaryModel.id); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `google-vertex/${primaryModel.id}`, + role: "google/*", + }, + ]); + }); + it("uses the active initial model as the default fallback primary when other role fallback chains are configured", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); @@ -1109,13 +1521,16 @@ describe("AgentSession retry fallback", () => { expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after Anthropic envelope retry" }); }); - it("does not auto-retry Anthropic stream-envelope failures before terminal stop signal", async () => { + it("falls back on mid-stream Anthropic envelope failures without same-model retries", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); if (!primaryModel || !fallbackModel) { throw new Error("Expected bundled test models to exist"); } + // Mid-stream envelope corruption is not auto-retried on the same model + // (partial content may have been delivered), but a configured fallback + // chain is still consulted: a different model is a fresh chance. const envelopeError = "Anthropic stream envelope error: received content_block_delta before terminal stop signal"; const requestedModels: string[] = []; const fallbackAppliedEvents: Array> = []; @@ -1152,7 +1567,7 @@ describe("AgentSession retry fallback", () => { settings, modelRegistry, }); - const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + const { retryStartEvents } = trackRetryEvents(session); session.subscribe(event => { if (event.type === "retry_fallback_applied") { fallbackAppliedEvents.push(event); @@ -1165,11 +1580,23 @@ describe("AgentSession retry fallback", () => { await session.prompt("Do not retry Anthropic envelope failure before terminal stop signal"); await session.waitForIdle(); - expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]); - expect(retryStartEvents).toHaveLength(0); - expect(retryEndEvents).toHaveLength(0); - expect(fallbackAppliedEvents).toHaveLength(0); + // One attempt per model: chain advances, never a same-model backoff retry. + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + role: "default", + }, + ]); + // The fallback fails with the same hard error and the chain is exhausted: + // the failure surfaces instead of looping. expect(fallbackSucceededEvents).toHaveLength(0); + expect(retryStartEvents).toHaveLength(1); const lastAssistant = getLastAssistantMessage(session); expect(lastAssistant.stopReason).toBe("error"); expect(lastAssistant.errorMessage).toBe(envelopeError); @@ -1596,6 +2023,39 @@ describe("AgentSession retry fallback", () => { ); }); + it("warns on unknown or malformed model-selector chain keys at startup", () => { + const primaryModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel) { + throw new Error("Expected bundled OpenAI test model to exist"); + } + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.fallbackChains": { + "nonexistent-provider/nonexistent-model": [`${primaryModel.provider}/${primaryModel.id}`], + [`${primaryModel.provider}/${primaryModel.id}`]: ["openai/gpt-4o"], + }, + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: () => { + throw new Error("Not exercised"); + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + expect(session.configWarnings).toContain( + "retry.fallbackChains key references unknown model: nonexistent-provider/nonexistent-model", + ); + expect(session.configWarnings.filter(w => w.includes(`${primaryModel.provider}/${primaryModel.id}`))).toEqual([]); + }); + it("normalizes suppression by base selector and clears it on model refresh", async () => { const future = Date.now() + 60_000; modelRegistry.suppressSelector("openai/gpt-4o:high", future); diff --git a/packages/coding-agent/test/agent-storage-model-perf.test.ts b/packages/coding-agent/test/agent-storage-model-perf.test.ts new file mode 100644 index 000000000..c1ab335e6 --- /dev/null +++ b/packages/coding-agent/test/agent-storage-model-perf.test.ts @@ -0,0 +1,164 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +describe("AgentStorage model perf aggregates", () => { + let tempDir: TempDir; + + afterEach(async () => { + AgentStorage.resetInstance(); + if (tempDir) { + try { + await tempDir.remove(); + } catch {} + tempDir = undefined as unknown as TempDir; + } + }); + + async function openStorage(): Promise { + tempDir = TempDir.createSync("@omp-agent-storage-perf-"); + return AgentStorage.open(path.join(tempDir.path(), "agent.db")); + } + + it("averages TPS over total request duration and TTFT over reporting samples", async () => { + const storage = await openStorage(); + + // 1000 tokens over 6000ms + 500 tokens over 3000ms → 1500 tokens / 9s → 166.67 t/s + // Back-to-back samples join one deferred batch; awaiting the shared flush + // promise makes both visible. + storage.recordModelPerf("openai/gpt-5", { outputTokens: 1000, durationMs: 6000, ttftMs: 1000 }); + await storage.recordModelPerf("openai/gpt-5", { outputTokens: 500, durationMs: 3000, ttftMs: 500 }); + + const stats = storage.getModelPerf().get("openai/gpt-5"); + expect(stats).toBeDefined(); + expect(stats?.samples).toBe(2); + expect(stats?.tps).toBeCloseTo(1500000 / 9000, 5); + expect(stats?.ttftMs).toBeCloseTo(750, 5); + }); + + it("keeps TTFT null when no sample reported one and uses full duration for TPS", async () => { + const storage = await openStorage(); + + // No ttft → 1000 tokens / 4s → 250 t/s + await storage.recordModelPerf("zai/glm-5", { outputTokens: 1000, durationMs: 4000 }); + + const stats = storage.getModelPerf().get("zai/glm-5"); + expect(stats?.tps).toBeCloseTo(250, 5); + expect(stats?.ttftMs).toBeNull(); + }); + + it("reports identical TPS regardless of TTFT (hidden-reasoning regression)", async () => { + const storage = await openStorage(); + + // Same duration and token count, wildly different TTFT: a provider that + // hides reasoning until late (ttft ~ duration) must not report inflated + // throughput vs one that streams from the start. + storage.recordModelPerf("google/gemini", { outputTokens: 1020, durationMs: 7000, ttftMs: 5700 }); + await storage.recordModelPerf("google-vertex/gemini", { outputTokens: 1020, durationMs: 7000, ttftMs: 1700 }); + + const hidden = storage.getModelPerf().get("google/gemini"); + const streamed = storage.getModelPerf().get("google-vertex/gemini"); + expect(hidden?.tps).toBeCloseTo(1020000 / 7000, 5); + expect(streamed?.tps).toBeCloseTo(1020000 / 7000, 5); + }); + + it("drops unmeasurable samples instead of polluting the aggregates", async () => { + const storage = await openStorage(); + + await storage.recordModelPerf("openai/gpt-5", { outputTokens: 0, durationMs: 4000 }); + await storage.recordModelPerf("openai/gpt-5", { outputTokens: 100, durationMs: 0 }); + await storage.recordModelPerf("openai/gpt-5", { outputTokens: Number.NaN, durationMs: 4000 }); + + expect(storage.getModelPerf().has("openai/gpt-5")).toBe(false); + }); + + it("ignores out-of-range TTFT but keeps the throughput sample", async () => { + const storage = await openStorage(); + + // ttft >= duration is bogus latency data; the sample still measures TPS. + await storage.recordModelPerf("openai/gpt-5", { outputTokens: 1000, durationMs: 4000, ttftMs: 5000 }); + + const stats = storage.getModelPerf().get("openai/gpt-5"); + expect(stats?.tps).toBeCloseTo(250, 5); + expect(stats?.ttftMs).toBeNull(); + }); + + it("defers the write off the record path and lands it once the flush promise resolves", async () => { + const storage = await openStorage(); + + const flushed = storage.recordModelPerf("openai/gpt-5", { outputTokens: 1000, durationMs: 4000 }); + // Recording is deferred: nothing is visible before the batch flushes. + expect(storage.getModelPerf().has("openai/gpt-5")).toBe(false); + + await flushed; + expect(storage.getModelPerf().get("openai/gpt-5")?.tps).toBeCloseTo(250, 5); + }); + + it("backfills perf aggregates from an omp stats database, excluding errored and stale turns", async () => { + const storage = await openStorage(); + + // Minimal stats.db fixture: only the columns the backfill query reads. + const statsDbPath = path.join(tempDir.path(), "stats.db"); + const statsDb = new Database(statsDbPath); + statsDb.run(`CREATE TABLE messages ( + provider TEXT, model TEXT, output_tokens INTEGER, duration INTEGER, + ttft INTEGER, stop_reason TEXT, timestamp INTEGER + )`); + const insert = statsDb.prepare("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)"); + const now = Date.now(); + // Two valid turns totaling 1500 tokens over 8.5s, one with ttft missing. + insert.run("openai", "gpt-5", 1000, 6000, 1000, "stop", now - 5000); + insert.run("openai", "gpt-5", 500, 2500, null, "stop", now - 4000); + // Errored and empty turns must not pollute the averages. + insert.run("openai", "gpt-5", 9999, 1, null, "error", now - 3000); + insert.run("openai", "gpt-5", 0, 4000, null, "stop", now - 2000); + // Rows older than the recency window are stale provider speeds; skip them. + insert.run("openai", "gpt-5", 100_000, 1000, null, "stop", now - 120 * 86_400_000); + insert.run("zai", "glm-5", 300, 3000, 1000, "aborted", now - 1000); + statsDb.close(); + + const imported = await storage.backfillModelPerfFromStats(statsDbPath); + + expect(imported).toBe(3); + const gpt = storage.getModelPerf().get("openai/gpt-5"); + // 1500 tokens over 6000ms + 2500ms total durations → 176.47 t/s. + expect(gpt?.samples).toBe(2); + expect(gpt?.tps).toBeCloseTo(1500000 / 8500, 5); + expect(gpt?.ttftMs).toBeCloseTo(1000, 5); + // Aborted turns with reported usage are valid samples, like live capture. + const glm = storage.getModelPerf().get("zai/glm-5"); + expect(glm?.tps).toBeCloseTo(100, 5); + }); + + it("caps the backfill at the newest samples per model", async () => { + const storage = await openStorage(); + + const statsDbPath = path.join(tempDir.path(), "stats.db"); + const statsDb = new Database(statsDbPath); + statsDb.run(`CREATE TABLE messages ( + provider TEXT, model TEXT, output_tokens INTEGER, duration INTEGER, + ttft INTEGER, stop_reason TEXT, timestamp INTEGER + )`); + const insert = statsDb.prepare("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)"); + const now = Date.now(); + // 300 rows: the newest 256 run at 100 t/s, the older 44 at a wild + // 10000 t/s. Only the newest 256 may count. One transaction: per-row + // implicit transactions fsync 300 times and time out on slow CI disks. + statsDb.transaction(() => { + for (let i = 0; i < 300; i++) { + const fast = i < 44; // smallest timestamps = oldest rows + insert.run("openai", "gpt-5", fast ? 10_000 : 100, 1000, null, "stop", now - (300 - i) * 1000); + } + })(); + statsDb.close(); + + const imported = await storage.backfillModelPerfFromStats(statsDbPath); + + expect(imported).toBe(256); + const stats = storage.getModelPerf().get("openai/gpt-5"); + expect(stats?.samples).toBe(256); + expect(stats?.tps).toBeCloseTo(100, 5); + }); +}); diff --git a/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts b/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts index 522aac4ab..290da4024 100644 --- a/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts +++ b/packages/coding-agent/test/agent-storage-sqlite-compat.test.ts @@ -1,7 +1,7 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; -import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; +import { AgentStorage, SCHEMA_VERSION } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import { TempDir } from "@oh-my-pi/pi-utils"; import { readTableSql } from "./helpers/sqlite-inspect"; @@ -53,7 +53,7 @@ describe("AgentStorage SQLite compatibility", () => { storage.recordModelUsage("openai/gpt-5"); expect(storage.getModelUsageOrder()).toEqual(["openai/gpt-5"]); - expect(readSchemaVersion(dbPath)).toBe(5); + expect(readSchemaVersion(dbPath)).toBe(SCHEMA_VERSION); expect(readTableSql(dbPath, "settings")).not.toContain("unixepoch("); expect(readTableSql(dbPath, "settings")).toContain("strftime('%s','now')"); expect(readTableSql(dbPath, "model_usage")).not.toContain("unixepoch("); @@ -87,7 +87,7 @@ describe("AgentStorage SQLite compatibility", () => { const storage = await AgentStorage.open(dbPath); - expect(readSchemaVersion(dbPath)).toBe(5); + expect(readSchemaVersion(dbPath)).toBe(SCHEMA_VERSION); expect(readTableSql(dbPath, "settings")).not.toContain("unixepoch("); expect(readTableSql(dbPath, "settings")).toContain("strftime('%s','now')"); expect(readTableSql(dbPath, "model_usage")).not.toContain("unixepoch("); diff --git a/packages/coding-agent/test/compaction-lifecycle.test.ts b/packages/coding-agent/test/compaction-lifecycle.test.ts index b6275266d..8658a5e8d 100644 --- a/packages/coding-agent/test/compaction-lifecycle.test.ts +++ b/packages/coding-agent/test/compaction-lifecycle.test.ts @@ -48,6 +48,9 @@ function buildCtx(compact: InteractiveModeContext["session"]["compact"]) { updateEditorTopBorder: vi.fn(), showError, flushCompactionQueue: vi.fn(async () => undefined), + // executeCompaction consults display.collapseCompacted on the ok path to + // decide whether the rebuild replaces the terminal transcript. + settings: { get: vi.fn(() => true) }, } as unknown as InteractiveModeContext; return { diff --git a/packages/coding-agent/test/core/js-static-import-rewrite.test.ts b/packages/coding-agent/test/core/js-static-import-rewrite.test.ts index 90fff846e..3321a85f3 100644 --- a/packages/coding-agent/test/core/js-static-import-rewrite.test.ts +++ b/packages/coding-agent/test/core/js-static-import-rewrite.test.ts @@ -182,3 +182,39 @@ describe("rewriteImports", () => { expect(wrapped.source).not.toContain(`${IMPORT} type`); }); }); + +// Cross-cell persistence: cells run through indirect eval, so bindings survive only if +// they land on globalThis. Cells with top-level `await` run inside an async IIFE, where +// declarations are function-scoped — wrapCode must publish them back to the global object +// or the next cell sees ReferenceError. +describe("wrapCode cross-cell persistence", () => { + it("publishes top-level function declarations and their sibling consts from async-wrapped cells", async () => { + const globals = globalThis as Record; + const wrapped = await wrapCode( + "async function ompPersistedFn(n) { return (await Promise.resolve(n)) + 1; }\nconst ompPersistedTotal = await ompPersistedFn(41);", + ); + expect(wrapped.asyncWrapped).toBe(true); + try { + await indirectEval(wrapped.source); + const fn = globals.ompPersistedFn as (n: number) => Promise; + expect(typeof fn).toBe("function"); + expect(await fn(1)).toBe(2); + expect(globals.ompPersistedTotal).toBe(42); + } finally { + delete globals.ompPersistedFn; + delete globals.ompPersistedTotal; + } + }); + + it("publishes explicit top-level var declarations from async-wrapped cells", async () => { + const globals = globalThis as Record; + const wrapped = await wrapCode("await Promise.resolve();\nvar ompPersistedVar = 5;"); + expect(wrapped.asyncWrapped).toBe(true); + try { + await indirectEval(wrapped.source); + expect(globals.ompPersistedVar).toBe(5); + } finally { + delete globals.ompPersistedVar; + } + }); +}); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index d2828106d..c5fe89ce8 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -448,6 +448,35 @@ describe("InputController escape behavior", () => { expect(spies.abort).not.toHaveBeenCalled(); }); + it("dismisses an active /btw panel before aborting loop mode", () => { + const { ctx, editor, spies } = createContext(); + ctx.loopModeEnabled = true; + mutableSessionState(ctx).isStreaming = true; + spies.hasActiveBtw.mockReturnValue(true); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + + expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1); + expect(spies.abort).not.toHaveBeenCalled(); + expect(ctx.loopModeEnabled).toBe(true); + }); + + it("dismisses an active /btw panel before aborting maintenance", () => { + const { ctx, editor, spies } = createContext(); + abortViewSession(ctx).isGeneratingHandoff = true; + spies.hasActiveBtw.mockReturnValue(true); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + + expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1); + expect(spies.abortHandoff).not.toHaveBeenCalled(); + expect(spies.abort).not.toHaveBeenCalled(); + }); + it("aborts an active streaming turn on the first Esc without asking for confirmation", () => { const { ctx, editor, spies } = createContext(); mutableSessionState(ctx).isStreaming = true; diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index 6e4bfc07b..9c0beb7e9 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -698,8 +698,33 @@ describe("UiHelpers / InputController against derived queued custom display", () const uiHelpers = new UiHelpers(ctx); uiHelpers.updatePendingMessagesDisplay(); - const rendered = pendingMessagesContainer.render(120).join("\n"); - expect(rendered).toMatch(/Steer: \/skill:test-skill arg1 arg2/); + const rendered = Bun.stripANSI(pendingMessagesContainer.render(120).join("\n")); + expect(rendered).toContain("Steering · 1"); + expect(rendered).toContain("1. /skill:test-skill arg1 arg2"); + expect(rendered).not.toContain("Steer:"); + }); + + it("groups yield follow-ups under one heading", async () => { + fixture = await createRealSession(); + const { session } = fixture; + for (const text of ["inspect types", "run tests", "summarize"]) { + session.agent.followUp({ + role: "user", + content: text, + attribution: "user", + timestamp: Date.now(), + }); + } + + const { ctx, pendingMessagesContainer } = createStubInteractiveModeContextForUiHelpers(session); + new UiHelpers(ctx).updatePendingMessagesDisplay(); + + const rendered = Bun.stripANSI(pendingMessagesContainer.render(120).join("\n")); + expect(rendered).toContain("After yield · 3"); + expect(rendered).toContain("1. inspect types"); + expect(rendered).toContain("2. run tests"); + expect(rendered).toContain("3. summarize"); + expect(rendered).not.toContain("Follow-up:"); }); it("restores the compact slash form into the editor and clears the queue", async () => { diff --git a/packages/coding-agent/test/input-controller-slash-history.test.ts b/packages/coding-agent/test/input-controller-slash-history.test.ts index 82f63edb3..9217e5aac 100644 --- a/packages/coding-agent/test/input-controller-slash-history.test.ts +++ b/packages/coding-agent/test/input-controller-slash-history.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "bun:test"; +import type { ImageContent } from "@oh-my-pi/pi-ai"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; +import { isQueuedMessageList, splitQueuedMessages } from "@oh-my-pi/pi-coding-agent/modes/queue-input"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; // Drives the real editor submit handler through the builtin slash dispatch @@ -8,9 +10,12 @@ import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/typ // executeBuiltinSlashCommand and the controller returned before any // addToHistory call. The fix centralizes recording after dispatch, with a // secret filter (shouldSkipHistory) for credential-bearing commands. -function makeCtx() { +function makeCtx(isStreaming = false) { const addToHistory = vi.fn(); const handleMCPCommand = vi.fn(async () => {}); + const followUp = vi.fn(async (_text: string, _images?: ImageContent[]) => {}); + const steer = vi.fn(async (_text: string, _images?: ImageContent[]) => {}); + const onInputCallback = vi.fn(); let text = ""; const editor = { onSubmit: undefined as undefined | ((t: string) => Promise), @@ -19,34 +24,64 @@ function makeCtx() { text = t; }, addToHistory, - pendingImages: [] as unknown[], - pendingImageLinks: [] as unknown[], + pendingImages: [] as ImageContent[], + pendingImageLinks: [] as (string | undefined)[], + imageLinks: undefined as (string | undefined)[] | undefined, clearDraft(historyText?: string) { if (historyText !== undefined) addToHistory(historyText); text = ""; + this.imageLinks = undefined; + this.pendingImages = []; + this.pendingImageLinks = []; }, }; const ctx = { editor, session: { - isStreaming: false, + isStreaming, isCompacting: false, queuedMessageCount: 0, extensionRunner: undefined, + followUp, + steer, }, focusedAgentId: undefined, collabGuest: undefined, handleHotkeysCommand: vi.fn(), handleMCPCommand, showStatus: vi.fn(), + onInputCallback, + startPendingSubmission: (input: { + text: string; + images?: ImageContent[]; + imageLinks?: (string | undefined)[]; + customType?: string; + display?: boolean; + streamingBehavior?: "steer" | "followUp"; + }) => ({ ...input, cancelled: false, started: false }), ui: { requestRender: vi.fn() }, + compactionQueuedMessages: [], + withLocalSubmission: async (_text: string, fn: () => Promise) => fn(), + updatePendingMessagesDisplay: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), } as unknown as InteractiveModeContext; - return { ctx, editor, addToHistory, handleMCPCommand }; + return { + ctx, + editor, + addToHistory, + followUp, + steer, + onInputCallback, + handleMCPCommand, + showStatus: ctx.showStatus, + }; } function controllerFor(ctx: InteractiveModeContext) { const controller = new InputController(ctx); controller.setupEditorSubmitHandler(); + ctx.handleQueueCommand = message => controller.handleQueueCommand(message); return controller; } @@ -81,4 +116,74 @@ describe("input controller — slash command history (#3148)", () => { // ...but the secret-bearing text is kept out of recallable history. expect(addToHistory).not.toHaveBeenCalled(); }); + + it("routes /queue through the yield-only follow-up queue while streaming", async () => { + const { ctx, editor, addToHistory, followUp, showStatus } = makeCtx(true); + controllerFor(ctx); + editor.setText("/queue inspect the final result"); + + await editor.onSubmit?.("/queue inspect the final result"); + + expect(followUp).toHaveBeenCalledWith("inspect the final result", undefined); + expect(addToHistory).toHaveBeenCalledWith("/queue inspect the final result"); + expect(showStatus).toHaveBeenCalledWith("Queued message for when the agent yields"); + }); + + it("starts the first queued item immediately when the session is idle", async () => { + const { ctx, editor, followUp, steer, onInputCallback, showStatus } = makeCtx(); + controllerFor(ctx); + const input = "=>\n1. inspect types\n2. run focused tests\n3. summarize failures"; + editor.setText(input); + + await editor.onSubmit?.(input); + + expect(onInputCallback).toHaveBeenCalledWith( + expect.objectContaining({ text: "inspect types", streamingBehavior: "followUp" }), + ); + expect(steer).not.toHaveBeenCalled(); + expect(followUp.mock.calls.map(call => call[0])).toEqual(["run focused tests", "summarize failures"]); + expect(showStatus).toHaveBeenCalledWith("Sent first message; queued 2 for later yields"); + }); + + it("queues an enumerated shorthand prompt as separate ordered follow-ups", async () => { + const { ctx, editor, addToHistory, followUp, showStatus } = makeCtx(true); + controllerFor(ctx); + const input = "=>\n1. inspect types\n2. run focused tests\n3. summarize failures"; + editor.setText(input); + + await editor.onSubmit?.(input); + + expect(followUp.mock.calls.map(call => call[0])).toEqual([ + "inspect types", + "run focused tests", + "summarize failures", + ]); + expect(addToHistory).toHaveBeenCalledWith(input); + expect(showStatus).toHaveBeenCalledWith("Queued 3 messages for when the agent yields"); + }); +}); + +describe("yield queue list parsing", () => { + it("recognizes numeric, Roman, and alphabetic sequences", () => { + const expected = ["first", "second", "third"]; + for (const input of [ + "1. first\n2. second\n3. third", + "I. first\nII. second\nIII. third", + "i. first\nii. second\niii. third", + "A. first\nB. second\nC. third", + "a) first\nb) second\nc) third", + ]) { + expect(splitQueuedMessages(input)).toEqual(expected); + } + }); + + it("keeps continuation lines together and rejects non-sequential markers", () => { + expect(splitQueuedMessages("1. first line\n more detail\n2. second")).toEqual([ + "first line\n more detail", + "second", + ]); + expect(splitQueuedMessages("1. first\n3. third")).toEqual(["1. first\n3. third"]); + expect(isQueuedMessageList("1. first\n2. second\n3. third\n4.")).toBe(true); + expect(splitQueuedMessages("1. first\n2. second\n3. third\n4.")).toEqual(["first", "second", "third"]); + }); }); diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index 6ebf4a59a..7590f3a58 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -18,6 +18,7 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SILENT_ABORT_MARKER, USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking"; +import * as clipboard from "@oh-my-pi/pi-coding-agent/utils/clipboard"; import { setKeybindings, Text } from "@oh-my-pi/pi-tui"; import { formatNumber, TempDir } from "@oh-my-pi/pi-utils"; @@ -332,6 +333,39 @@ describe("InteractiveMode plan review rendering", () => { } }); + it("copies the overlay's current edited plan markdown from the real plan review overlay", async () => { + let capturedOverlay: PlanReviewOverlay | undefined; + const overlayHandle = { hide: vi.fn() }; + vi.spyOn(mode.ui, "showOverlay").mockImplementation(component => { + capturedOverlay = component as PlanReviewOverlay; + return overlayHandle as never; + }); + const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); + const statusSpy = vi.spyOn(mode, "showStatus"); + const constructorPlan = "# Plan\n\nOriginal constructor body.\n"; + const editedPlan = "# Plan\n\nEdited in overlay.\n\n## Verify\n\n- run focused test\n"; + + const choice = mode.showPlanReview(constructorPlan, "Plan mode - next step", [ + "Approve and execute", + "Refine plan", + ]); + + expect(capturedOverlay).toBeDefined(); + const overlay = capturedOverlay!; + overlay.setPlanContent(editedPlan); + overlay.handleInput("c"); + await Promise.resolve(); + + expect(copySpy).toHaveBeenCalledTimes(1); + expect(copySpy).toHaveBeenCalledWith(editedPlan); + expect(copySpy).not.toHaveBeenCalledWith(constructorPlan); + expect(statusSpy).toHaveBeenCalledWith("Copied plan to clipboard"); + + overlay.handleInput("\x1b"); + await expect(choice).resolves.toBeUndefined(); + expect(overlayHandle.hide).toHaveBeenCalled(); + }); + it("Refine with no annotations silently aborts approval and returns to the editor", async () => { const planFilePath = "local://PLAN.md"; const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, { diff --git a/packages/coding-agent/test/model-browser.test.ts b/packages/coding-agent/test/model-browser.test.ts new file mode 100644 index 000000000..2dc3a9968 --- /dev/null +++ b/packages/coding-agent/test/model-browser.test.ts @@ -0,0 +1,106 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + buildBrowserItems, + ModelBrowser, + sortModelItems, +} from "@oh-my-pi/pi-coding-agent/modes/components/model-browser"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +function makeModel(provider: string, id: string): Model { + return buildModel({ + id, + name: id, + api: "ollama-chat", + provider, + baseUrl: "https://example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 1024, + }); +} + +/** Browser preloaded with `models`, MRU-sorted like the hub does on sync. */ +function makeBrowser(models: Model[], mruOrder: string[]): ModelBrowser { + const browser = new ModelBrowser(Settings.isolated({})); + const items = buildBrowserItems(models); + sortModelItems(items, { mruOrder }); + browser.setMruOrder(mruOrder); + browser.setItems(items); + return browser; +} + +describe("ModelBrowser search ranking", () => { + test("an exact query match outranks the MRU model", () => { + // Regression: with gpt-5.6-sol as the active (MRU) model, typing + // "gpt-5.5" must select gpt-5.5, not keep the MRU pinned on top. + const browser = makeBrowser( + [ + makeModel("openai-codex", "gpt-5.6-sol"), + makeModel("openai-codex", "gpt-5.6-luna"), + makeModel("openai-codex", "gpt-5.5"), + makeModel("openai-codex", "gpt-5.4"), + ], + ["openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.6-luna"], + ); + + browser.setQuery("gpt-5.5"); + + expect(browser.getSelected()?.selector).toBe("openai-codex/gpt-5.5"); + }); + + test("MRU breaks ties between equally good matches", () => { + // Same model id under two providers: match quality is identical, so + // the recently used provider must win over alphabetical order. + const browser = makeBrowser([makeModel("g0i", "gpt-5.5"), makeModel("zenmux", "gpt-5.5")], ["zenmux/gpt-5.5"]); + + browser.setQuery("gpt-5.5"); + + expect(browser.getSelected()?.selector).toBe("zenmux/gpt-5.5"); + }); +}); + +describe("ModelBrowser perf display", () => { + beforeAll(async () => { + // render() reads the global theme singleton. + await initTheme(false); + }); + + function makePerfBrowser(): ModelBrowser { + const browser = new ModelBrowser(Settings.isolated({})); + browser.setItems(buildBrowserItems([makeModel("openai", "gpt-5")])); + browser.setPerfStats(new Map([["openai/gpt-5", { samples: 12, tps: 118.4, ttftMs: 930 }]])); + return browser; + } + + function renderPlain(browser: ModelBrowser, width: number): string[] { + return browser.render(width).map(line => Bun.stripANSI(line)); + } + + test("row perf column scales with width: off, TPS-only, TTFT+TPS", () => { + const browser = makePerfBrowser(); + + expect(renderPlain(browser, 70)[2]).not.toContain("t/s"); + expect(renderPlain(browser, 80)[2]).toContain("118t/s"); + const wideRow = renderPlain(browser, 120)[2]; + expect(wideRow).toContain("0.9s 118t/s"); + }); + + test("detail line shows measured perf regardless of width", () => { + const browser = makePerfBrowser(); + + const lines = renderPlain(browser, 70); + expect(lines[lines.length - 2]).toContain("~118t/s · 0.9s ttft"); + }); + + test("models without measurements render no perf cell", () => { + const browser = new ModelBrowser(Settings.isolated({})); + browser.setItems(buildBrowserItems([makeModel("openai", "gpt-5")])); + + expect(renderPlain(browser, 120)[2]).not.toContain("t/s"); + }); +}); diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 34991413b..553a14178 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; +import { afterEach, beforeAll, describe, expect, type Mock, test, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; @@ -75,9 +75,9 @@ interface HubHarness { hub: ModelHubComponent; onAssign: ReturnType; onUnassign: ReturnType; - onPick: ReturnType; onLoginRequest: ReturnType; onCancel: ReturnType; + onFallbackChainChange: Mock<(role: string, chain: string[]) => void>; } const openHubs: ModelHubComponent[] = []; @@ -97,9 +97,18 @@ function createHub(options: { const ui = { requestRender: vi.fn(), terminal: { rows: 40 } } as unknown as TUI; const onAssign = vi.fn(); const onUnassign = vi.fn(); - const onPick = vi.fn(); const onLoginRequest = vi.fn(); const onCancel = vi.fn(); + // Mirror the controller: persist chain edits so the hub's re-read sees them. + const onFallbackChainChange = vi.fn((role: string, chain: string[]) => { + const chains = { ...settings.get("retry.fallbackChains") }; + if (chain.length === 0) { + delete chains[role]; + } else { + chains[role] = chain; + } + settings.override("retry.fallbackChains", chains); + }); const hub = new ModelHubComponent( ui, settings, @@ -108,20 +117,21 @@ function createHub(options: { { onAssign: options.callbacks?.onAssign ?? onAssign, onUnassign: options.callbacks?.onUnassign ?? onUnassign, - onPick: options.callbacks?.onPick ?? onPick, onLoginRequest: options.callbacks?.onLoginRequest ?? onLoginRequest, onCycleOrderChange: options.callbacks?.onCycleOrderChange, + onFallbackChainChange: options.callbacks?.onFallbackChainChange ?? onFallbackChainChange, onCancel: options.callbacks?.onCancel ?? onCancel, }, options.hub, ); openHubs.push(hub); - return { hub, onAssign, onUnassign, onPick, onLoginRequest, onCancel }; + return { hub, onAssign, onUnassign, onLoginRequest, onCancel, onFallbackChainChange }; } const DOWN = "\x1b[B"; const UP = "\x1b[A"; const LEFT = "\x1b[D"; +const ESC = "\x1b"; describe("ModelHub", () => { beforeAll(async () => { @@ -139,7 +149,7 @@ describe("ModelHub", () => { }); describe("role chips and roles view", () => { - test("shows configured role chips with thinking glyphs, including custom roles", () => { + test("tags the selected model's roles in the detail line, including custom roles", () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); const settings = Settings.isolated({ @@ -161,7 +171,7 @@ describe("ModelHub", () => { expect(rendered).toContain("●smol"); }); - test("renders hollow chips for auto-selected role fallbacks", () => { + test("list rows carry no role chips; only the selected model's detail line is tagged", () => { const settings = Settings.isolated({}); const haiku = makeModel("test", "claude-haiku-4.5"); const codex = makeModel("test", "gpt-5.1-codex"); @@ -169,10 +179,11 @@ describe("ModelHub", () => { installTestTheme(); const rendered = normalize(hub.render(220)); - // No roles configured: auto-selection still tags the small/reasoning - // candidates (smol → haiku, slow → codex), rendered hollow. - expect(rendered).toContain("○smol"); - expect(rendered).toContain("○slow"); + // Auto-selection tags smol → haiku and slow → codex, but only the + // selected model's chips render (in the detail line). With row + // chips both would appear at once. + const hollow = ["○smol", "○slow"].filter(chip => rendered.includes(chip)); + expect(hollow).toHaveLength(1); expect(rendered).not.toContain("●smol"); }); @@ -304,7 +315,8 @@ describe("ModelHub", () => { hub.handleInput(UP); // All models → Roles (since Recent is removed) hub.handleInput("\n"); // dive into rows - hub.handleInput(UP); // wraps to the trailing "+ New role…" row + hub.handleInput(UP); // wraps to the trailing "+ New fallback…" row + hub.handleInput(UP); // skips the section divider up to "+ New role…" hub.handleInput("\n"); expect(footerLine(hub.render(220))).toContain("New role name:"); @@ -317,7 +329,6 @@ describe("ModelHub", () => { const call = onAssign.mock.calls[0]; expect(call?.[1]).toBe("reviewer"); expect(call?.[3]).toBe("test/reviewer-model"); - expect(call?.[4]).toBe("modelRole"); }); }); @@ -340,7 +351,6 @@ describe("ModelHub", () => { expect(call?.[1]).toBe("default"); expect(call?.[2]).toBe(ThinkingLevel.Inherit); expect(call?.[3]).toBe("openai/gpt-5.5"); - expect(call?.[4]).toBe("modelRole"); // The thinking strip follows immediately, scoped to the model's // real ladder: gpt-5.5 tops out at xhigh — no invented max tier. @@ -379,102 +389,308 @@ describe("ModelHub", () => { expect(footerLine(hub.render(220))).not.toContain("inherit"); }); - test("retry-fallback chip fires the retryFallback action without a thinking strip", () => { + test("retry-fallback chip appends the model to the default chain without a thinking strip", () => { const model = makeModel("test", "retry-fallback-model"); - const { hub, onAssign } = createHub({ models: [model], scoped: true }); + const { hub, onAssign, onFallbackChainChange } = createHub({ models: [model], scoped: true }); installTestTheme(); hub.handleInput("\n"); hub.handleInput(LEFT); // wraps to the trailing retry-fallback chip hub.handleInput("\n"); - expect(onAssign).toHaveBeenCalledTimes(1); - const call = onAssign.mock.calls[0]; - expect(call?.[1]).toBe("default"); - expect(call?.[4]).toBe("retryFallback"); + expect(onFallbackChainChange).toHaveBeenCalledWith("default", ["test/retry-fallback-model"]); + expect(onAssign).not.toHaveBeenCalled(); expect(footerLine(hub.render(220))).not.toContain("inherit"); + + // A second registration of the same model is a no-op, not a duplicate. + hub.handleInput("\n"); + hub.handleInput(LEFT); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenCalledTimes(1); + }); + + test("overflowing role strip scrolls left so the selected chip stays visible", () => { + const model = makeModel("test", "narrow-strip-model"); + const { hub } = createHub({ models: [model], scoped: true }); + installTestTheme(); + + hub.handleInput("\n"); // open the role strip + // At full width every chip fits and no left ellipsis appears. + expect(footerLine(hub.render(220))).not.toContain("…"); + + hub.handleInput(LEFT); // wrap to the trailing retry-fallback chip + const narrow = footerLine(hub.render(80)); + expect(narrow).toContain("[ retry-fallback ]"); + expect(narrow).toContain("…"); + + // Back on the first chip the window resets — no leading ellipsis. + hub.handleInput("\x1b[C"); // wrap right back to the first chip + const reset = footerLine(hub.render(80)); + expect(reset).toContain("[ default"); + expect(reset.trimStart().startsWith("…")).toBe(false); }); }); - describe("pick mode", () => { - test("disables models below the current context size and picks the first enabled one", () => { - const small = makeModel("test", "a-small", 4096); - const large = makeModel("test", "b-large", 128_000); - const { hub, onPick } = createHub({ - models: [small, large], - scoped: true, - hub: { mode: "pick", currentContextTokens: 6000 }, - }); - installTestTheme(); + describe("fallback chains in the roles view", () => { + /** Hop to the Roles sidebar entry and dive into its rows. */ + function enterRolesView(hub: ModelHubComponent): void { + hub.handleInput(UP); // All models → Roles + hub.handleInput("\n"); // dive into the rows + } + test("renders configured chain entries as indented rows under their role", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const settings = Settings.isolated({ + "retry.fallbackChains": { default: ["test/model-a", "test/model-b"] }, + }); + const { hub } = createHub({ models: [a, b], scoped: true, settings }); + + enterRolesView(hub); const rendered = normalize(hub.render(220)); - expect(rendered).toContain("a-small"); - expect(rendered).toContain("context>4.1k"); - expect(rendered).toContain("Session-only switch"); - - hub.handleInput("\n"); - expect(onPick).toHaveBeenCalledTimes(1); - expect(onPick.mock.calls[0]?.[0]).toBe(large); + expect(rendered).toContain("↳ test/model-a"); + expect(rendered).toContain("↳ test/model-b"); }); - test("uses cached models for Enter while the offline refresh is still pending", () => { - const cached = makeModel("test", "cached-fast"); - const refreshGate = Promise.withResolvers(); - const refresh = vi.fn(() => refreshGate.promise); - const { hub, onPick } = createHub({ - models: [cached], - registry: { refresh }, - hub: { mode: "pick" }, - }); - installTestTheme(); + test("f on a role opens fallback assignment and Enter appends the picked model", () => { + const a = makeModel("test", "model-a"); + const settings = Settings.isolated({}); + const { hub, onFallbackChainChange, onAssign } = createHub({ models: [a], scoped: true, settings }); - hub.handleInput("\n"); - expect(onPick).toHaveBeenCalledTimes(1); - expect(onPick.mock.calls[0]?.[0]).toBe(cached); - expect(refresh).toHaveBeenCalledTimes(1); - refreshGate.resolve(); + enterRolesView(hub); + hub.handleInput("f"); // add a fallback for the first role (default) + expect(normalize(hub.render(220))).toContain("Adding fallback for"); + + hub.handleInput("\n"); // pick the only model + expect(onFallbackChainChange).toHaveBeenCalledWith("default", ["test/model-a"]); + expect(onAssign).not.toHaveBeenCalled(); // no role assignment, no thinking strip + expect(normalize(hub.render(220))).toContain("↳ test/model-a"); }); - test("focuses list mode initially in pick mode", () => { - const model = makeModel("test", "test-model"); - const { hub } = createHub({ - models: [model], - hub: { mode: "pick" }, + test("x removes a chain entry and Enter on an entry replaces it", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const settings = Settings.isolated({ + "retry.fallbackChains": { default: ["test/model-a", "test/model-b"] }, }); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true, settings }); + + enterRolesView(hub); + hub.handleInput(DOWN); // default → its first chain entry (model-a) + hub.handleInput("\n"); // replace this entry + expect(normalize(hub.render(220))).toContain("Replacing fallback of"); + for (const ch of "model-b") hub.handleInput(ch); // search: arrows hop scopes in assign mode + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]); + + hub.handleInput("x"); // cursor landed on the replaced entry — remove it + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", []); + expect(normalize(hub.render(220))).not.toContain("↳"); + }); + + test("] moves a chain entry later and the cursor follows it", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const settings = Settings.isolated({ + "retry.fallbackChains": { default: ["test/model-a", "test/model-b"] }, + }); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true, settings }); + + enterRolesView(hub); + hub.handleInput(DOWN); // first chain entry (model-a) + hub.handleInput("]"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b", "test/model-a"]); + + // Cursor followed the moved entry: x removes model-a, not model-b. + hub.handleInput("x"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]); + }); + + test("clicking a roles row hits the row under the pointer", () => { + const a = makeModel("test", "model-a"); + const { hub } = createHub({ models: [a], scoped: true }); + + hub.handleInput(UP); // All models → Roles + // Derive the pointer row from the frame itself: the fullscreen + // overlay paints from screen row 0, so frame index == screen row. + const frame = hub.render(220).map(line => stripVTControlCharacters(line)); + const screenRow = frame.findIndex(line => line.includes("DEFAULT")); + expect(screenRow).toBeGreaterThan(0); + const sgr = `\x1b[<0;61;${screenRow + 1}M`; // SGR reports are 1-based + hub.handleInput(sgr); // select (dive into rows) + hub.handleInput(sgr); // click-again activates + expect(normalize(hub.render(220))).toContain("Assigning DEFAULT"); + }); + + test("fallbacks chip keys a new chain by the selected model", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true }); + + for (const ch of "model-a") hub.handleInput(ch); + hub.handleInput("\n"); // open the strip for model-a + hub.handleInput(LEFT); // retry-fallback + hub.handleInput(LEFT); // fallbacks:test/* + hub.handleInput(LEFT); // fallbacks:model-a + hub.handleInput("\n"); + expect(normalize(hub.render(220))).toContain("Adding fallback for test/model-a"); + + for (const ch of "model-b") hub.handleInput(ch); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/model-a", ["test/model-b"]); const rendered = normalize(hub.render(220)); - expect(rendered).toContain("↑/↓ models · ← providers"); + expect(rendered).toContain("test/model-a"); + expect(rendered).toContain("↳ test/model-b"); }); - test("focuses scope mode initially in roles mode", () => { - const model = makeModel("test", "test-model"); - const { hub } = createHub({ - models: [model], - hub: { mode: "roles" }, - }); - const rendered = normalize(hub.render(220)); - expect(rendered).toContain("↑/↓ providers · → models"); - }); - - test("keeps the highlighted model when a background refresh reorders the list", async () => { - const modelBb = makeModel("test", "bb-model"); - const modelCc = makeModel("test", "cc-model"); - const modelAa = makeModel("test", "aa-model"); - let available = [modelBb, modelCc]; - const refreshGate = Promise.withResolvers(); - const { hub, onPick } = createHub({ - models: () => available, - registry: { refresh: () => refreshGate.promise }, - hub: { mode: "pick" }, - }); - installTestTheme(); - - hub.handleInput(DOWN); // highlight cc-model - available = [modelAa, modelBb, modelCc]; - refreshGate.resolve(); - await Bun.sleep(0); + test("provider chip keys the chain by provider/*", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true }); + for (const ch of "model-a") hub.handleInput(ch); hub.handleInput("\n"); - expect(onPick.mock.calls[0]?.[0]?.id).toBe("cc-model"); + hub.handleInput(LEFT); // retry-fallback + hub.handleInput(LEFT); // fallbacks:test/* + hub.handleInput("\n"); + expect(normalize(hub.render(220))).toContain("Adding fallback for test/*"); + + for (const ch of "model-b") hub.handleInput(ch); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/*", ["test/model-b"]); + }); + + test("+ New fallback… picks the protected model, then keys the chain via the strip", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true }); + + enterRolesView(hub); + hub.handleInput(UP); // wrap to the trailing "+ New fallback…" + hub.handleInput("\n"); + expect(normalize(hub.render(220))).toContain("New fallback chain"); + + for (const ch of "model-a") hub.handleInput(ch); + hub.handleInput("\n"); // pick the protected model + const strip = footerLine(hub.render(220)); + expect(strip).toContain("for test/model-a"); + expect(strip).toContain("for test/*"); + + hub.handleInput("\n"); // key by the exact model + expect(normalize(hub.render(220))).toContain("Adding fallback for test/model-a"); + for (const ch of "model-b") hub.handleInput(ch); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/model-a", ["test/model-b"]); + }); + + test("model-keyed chains render below the separator and x clears the whole chain", () => { + const a = makeModel("test", "model-a"); + const settings = Settings.isolated({ + "retry.fallbackChains": { "test/*": ["test/model-a"] }, + }); + const { hub, onFallbackChainChange } = createHub({ models: [a], scoped: true, settings }); + + enterRolesView(hub); + const rendered = normalize(hub.render(220)); + expect(rendered).toContain("test/*"); + expect(rendered).toContain("↳ test/model-a"); + expect(rendered).toContain("+ New fallback…"); + expect(rendered).toMatch(/─{10,}/); // the roles/fallbacks divider + + hub.handleInput(UP); // + New fallback… + hub.handleInput(UP); // ↳ test/model-a + hub.handleInput(UP); // test/* header (separator is skipped) + hub.handleInput("x"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/*", []); + expect(normalize(hub.render(220))).not.toContain("↳ test/model-a"); + }); + }); + + test("focuses the scope pane initially", () => { + const { hub } = createHub({ models: [makeModel("test", "test-model")] }); + const rendered = normalize(hub.render(220)); + expect(rendered).toContain("↑/↓ providers · → models"); + }); + + describe("mouse wheel", () => { + // SGR wheel reports: button 64 = up, 65 = down. Column 100 lands in the + // body pane, column 3 in the sidebar; row 10 is inside the content rows. + const WHEEL_UP_BODY = "\x1b[<64;100;10M"; + const WHEEL_DOWN_BODY = "\x1b[<65;100;10M"; + const WHEEL_UP_SIDEBAR = "\x1b[<64;3;10M"; + const WHEEL_DOWN_SIDEBAR = "\x1b[<65;3;10M"; + + test("wheel pans the model list without moving the selection and clamps at the ends", () => { + const models = Array.from({ length: 40 }, (_, i) => makeModel("test", `model-${String(i).padStart(2, "0")}`)); + const { hub } = createHub({ models, scoped: true }); + + const before = normalize(hub.render(220)); // establishes mouse geometry + // Enter opens the role strip for the selected model — its footer + // (` → …`) identifies the selection. + hub.handleInput("\n"); + const initialStrip = footerLine(hub.render(220)); + expect(initialStrip).toContain("→"); + hub.handleInput(ESC); // close the strip + + // Panning reveals rows that were below the fold... + for (let i = 0; i < 8; i++) hub.handleInput(WHEEL_DOWN_BODY); + const panned = normalize(hub.render(220)); + const modelIdsIn = (frame: string) => new Set(Array.from(frame.matchAll(/model-\d\d/g), match => match[0])); + const beforeIds = modelIdsIn(before); + const revealed = [...modelIdsIn(panned)].filter(id => !beforeIds.has(id)); + expect(revealed.length).toBeGreaterThan(0); + + // ...but never moves the selection: Enter still opens the same model's strip. + hub.handleInput("\n"); + expect(footerLine(hub.render(220))).toBe(initialStrip); + hub.handleInput(ESC); + + // The window clamps at the bottom instead of wrapping back to the top... + for (let i = 0; i < 500; i++) hub.handleInput(WHEEL_DOWN_BODY); + const saturated = normalize(hub.render(220)); + hub.handleInput(WHEEL_DOWN_BODY); + expect(normalize(hub.render(220))).toBe(saturated); + + // ...and scrolling back up restores the original window exactly. + for (let i = 0; i < 500; i++) hub.handleInput(WHEEL_UP_BODY); + expect(normalize(hub.render(220))).toBe(before); + }); + + test("wheel over the sidebar never changes the active scope or schedules refreshes", () => { + vi.useFakeTimers(); + try { + const refreshProvider = vi.fn(async () => {}); + const { hub } = createHub({ + models: [makeModel("prov-a", "model-a"), makeModel("prov-b", "model-b")], + registry: { refreshProvider }, + }); + + expect(normalize(hub.render(220))).toContain("All available models"); + + // Two hops under the old wheel-selects behavior would land on a + // provider scope; the viewport pan must leave the scope alone. + for (let i = 0; i < 2; i++) hub.handleInput(WHEEL_DOWN_SIDEBAR); + expect(normalize(hub.render(220))).toContain("All available models"); + for (let i = 0; i < 2; i++) hub.handleInput(WHEEL_UP_SIDEBAR); + expect(normalize(hub.render(220))).toContain("All available models"); + + // No scope change means no provider auto-refresh either. + vi.advanceTimersByTime(200); // past the 120ms provider-refresh debounce + expect(refreshProvider).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("wheel in the roles view clamps at the top instead of wrapping to the bottom rows", () => { + const { hub } = createHub({ models: [makeModel("test", "model-a")], scoped: true }); + + hub.handleInput(UP); // All models → Roles + hub.render(220); // establish mouse geometry + for (let i = 0; i < 4; i++) hub.handleInput(WHEEL_UP_BODY); // cursor stays on the first role + hub.handleInput("\n"); // dive into the rows + hub.handleInput("\n"); // activate the cursor row + expect(normalize(hub.render(220))).toContain("Assigning DEFAULT"); }); }); @@ -482,14 +698,9 @@ describe("ModelHub", () => { test("search inside a provider scope keeps that provider's model (#4522)", () => { const openrouterGlm = makeModel("openrouter", "z-ai/glm-5.2"); const customGlm = makeModel("custom-provider", "glm-5.2"); - const { hub, onPick } = createHub({ - models: [openrouterGlm, customGlm], - hub: { mode: "pick" }, - }); + const { hub } = createHub({ models: [openrouterGlm, customGlm] }); installTestTheme(); - // Focus scope first to allow scope-hopping - hub.handleInput("\t"); // Scope-hop: All models → custom-provider → openrouter. hub.handleInput(DOWN); hub.handleInput(DOWN); @@ -498,9 +709,9 @@ describe("ModelHub", () => { for (const ch of "glm-5.2") hub.handleInput(ch); hub.handleInput("\n"); - expect(onPick).toHaveBeenCalledTimes(1); - expect(onPick.mock.calls[0]?.[0]?.provider).toBe("openrouter"); - expect(onPick.mock.calls[0]?.[0]?.id).toBe("z-ai/glm-5.2"); + // The role strip opened for the provider-scoped match, not the + // identically named custom-provider model. + expect(footerLine(hub.render(220))).toContain("z-ai/glm-5.2 →"); }); test("search on All models spans every provider", () => { diff --git a/packages/coding-agent/test/model-picker.test.ts b/packages/coding-agent/test/model-picker.test.ts new file mode 100644 index 000000000..b29348ddb --- /dev/null +++ b/packages/coding-agent/test/model-picker.test.ts @@ -0,0 +1,207 @@ +import { beforeAll, describe, expect, type Mock, test, vi } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { ModelPickerComponent, type ModelPickerOptions } from "@oh-my-pi/pi-coding-agent/modes/components/model-picker"; +import { resolveSegmentPalette } from "@oh-my-pi/pi-coding-agent/modes/components/segment-track"; +import { getThemeByName, setThemeInstance, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { ResolvedRoleModel } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +function normalize(lines: readonly string[]): string { + return stripVTControlCharacters(lines.join("\n")).replace(/\s+/g, " ").trim(); +} + +function makeModel(provider: string, id: string, contextWindow = 128_000): Model { + return buildModel({ + id, + name: id, + api: "ollama-chat", + provider, + baseUrl: "https://example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: 1024, + }); +} + +let testTheme = await getThemeByName("dark"); + +function installTestTheme(): void { + if (!testTheme) { + throw new Error("Failed to load dark theme for ModelPicker tests"); + } + setThemeInstance(testTheme); +} + +interface RegistryOverrides { + refresh?: (mode: string) => Promise; +} + +interface PickerHarness { + picker: ModelPickerComponent; + onPick: Mock<(model: Model, selector: string) => void>; + onPickRole: Mock<(entry: ResolvedRoleModel) => void>; + onCancel: Mock<() => void>; +} + +function createPicker(options: { + models: Model[] | (() => Model[]); + scoped?: boolean; + settings?: Settings; + registry?: RegistryOverrides; + picker?: ModelPickerOptions; +}): PickerHarness { + installTestTheme(); + const modelsFn = typeof options.models === "function" ? options.models : () => options.models as Model[]; + const settings = options.settings ?? Settings.isolated({}); + const registry = { + refresh: options.registry?.refresh ?? (async () => {}), + getError: () => undefined, + getAvailable: modelsFn, + getAll: modelsFn, + } as unknown as ModelRegistry; + const ui = { requestRender: vi.fn(), terminal: { rows: 40 } } as unknown as TUI; + const onPick = vi.fn(); + const onPickRole = vi.fn(); + const onCancel = vi.fn(); + const picker = new ModelPickerComponent( + ui, + settings, + registry, + options.scoped ? modelsFn().map(model => ({ model })) : [], + { onPick, onPickRole, onCancel }, + options.picker ?? {}, + ); + return { picker, onPick, onPickRole, onCancel }; +} + +const DOWN = "\x1b[B"; +const ESC = "\x1b"; + +describe("ModelPicker", () => { + beforeAll(async () => { + testTheme = await getThemeByName("dark"); + if (!testTheme) { + throw new Error("Failed to load dark theme for ModelPicker tests"); + } + }); + + test("disables models below the current context size and picks the first enabled one", () => { + const small = makeModel("test", "a-small", 4096); + const large = makeModel("test", "b-large", 128_000); + const { picker, onPick } = createPicker({ + models: [small, large], + scoped: true, + picker: { currentContextTokens: 6000 }, + }); + + const rendered = normalize(picker.render(220)); + expect(rendered).toContain("a-small"); + expect(rendered).toContain("context>4.1k"); + expect(rendered).toContain("Session-only switch"); + + picker.handleInput("\n"); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick.mock.calls[0]?.[0]).toBe(large); + }); + + test("uses cached models for Enter while the offline refresh is still pending", () => { + const cached = makeModel("test", "cached-fast"); + const refreshGate = Promise.withResolvers(); + const refresh = vi.fn(() => refreshGate.promise); + const { picker, onPick } = createPicker({ + models: [cached], + registry: { refresh }, + }); + + picker.handleInput("\n"); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick.mock.calls[0]?.[0]).toBe(cached); + expect(refresh).toHaveBeenCalledTimes(1); + refreshGate.resolve(); + }); + + test("keeps the highlighted model when a background refresh reorders the list", async () => { + const modelBb = makeModel("test", "bb-model"); + const modelCc = makeModel("test", "cc-model"); + const modelAa = makeModel("test", "aa-model"); + let available = [modelBb, modelCc]; + const refreshGate = Promise.withResolvers(); + const { picker, onPick } = createPicker({ + models: () => available, + registry: { refresh: () => refreshGate.promise }, + }); + + picker.handleInput(DOWN); // highlight cc-model + available = [modelAa, modelBb, modelCc]; + refreshGate.resolve(); + // Not a tuned delay: one zero-length tick drains the component's + // refresh().then(...) continuation chain deterministically. + await Bun.sleep(0); + picker.handleInput("\n"); + expect(onPick.mock.calls[0]?.[0]?.id).toBe("cc-model"); + }); + + test("highlights and preselects the session's current model", () => { + const models = [makeModel("test", "aa-model"), makeModel("test", "bb-model"), makeModel("test", "cc-model")]; + const { picker, onPick } = createPicker({ + models, + scoped: true, + picker: { currentSelector: "test/bb-model" }, + }); + + // The detail block tags the selected (= current) model. + expect(normalize(picker.render(220))).toContain("current"); + + // Enter without navigation picks the preselected current model. + picker.handleInput("\n"); + expect(onPick.mock.calls[0]?.[0]?.id).toBe("bb-model"); + }); + + test("shows and applies ctrl+p quick roles when search starts with @", () => { + const smol = makeModel("test", "smol-model"); + const slow = makeModel("test", "slow-model"); + const quickRoles: ResolvedRoleModel[] = [ + { role: "smol", model: smol, explicitThinkingLevel: false }, + { role: "slow", model: slow, explicitThinkingLevel: false }, + ]; + const { picker, onPick, onPickRole } = createPicker({ + models: [smol, slow], + scoped: true, + picker: { + quickRoles, + quickRoleOrder: ["smol", "slow"], + currentQuickRole: "slow", + }, + }); + + picker.handleInput("@"); + const rendered = picker.render(220); + const frame = rendered.join("\n"); + expect(normalize(rendered)).toContain("@smol"); + expect(normalize(rendered)).toContain("@slow"); + const palette = resolveSegmentPalette(2); + expect(frame).toContain(`${theme.getFgAnsi(palette[0])}@smol`); + expect(frame).toContain(`${theme.getFgAnsi(palette[1])}@slow`); + + picker.handleInput("\n"); + expect(onPickRole).toHaveBeenCalledWith(quickRoles[1]); + expect(onPick).not.toHaveBeenCalled(); + }); + + test("Esc clears an active query first, then cancels", () => { + const { picker, onCancel } = createPicker({ models: [makeModel("test", "test-model")], scoped: true }); + + picker.handleInput("q"); + picker.handleInput(ESC); + expect(onCancel).not.toHaveBeenCalled(); + + picker.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts index ef9841fd4..37733fee2 100644 --- a/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts +++ b/packages/coding-agent/test/modes/components/plan-review-overlay.test.ts @@ -195,6 +195,22 @@ describe("PlanReviewOverlay", () => { expect(out).not.toContain("para 199"); }); + it("copies the current plan content on c and advertises the hotkey when available", () => { + const onCopyPlan = vi.fn(); + const overlay = new PlanReviewOverlay( + "# Original plan\n\nold body", + { promptTitle: "next", options: APPROVAL_OPTIONS }, + { onPick: vi.fn(), onCancel: vi.fn(), onCopyPlan }, + ); + + overlay.setPlanContent("# Edited plan\n\nnew body"); + expect(render(overlay)).toContain("c copy"); + overlay.handleInput("c"); + + expect(onCopyPlan).toHaveBeenCalledTimes(1); + expect(onCopyPlan).toHaveBeenCalledWith("# Edited plan\n\nnew body\n"); + }); + // Plan with ≥2 headings + nesting, wide enough for the sidebar at width 80. const SECTION_PLAN = "# Overview\n\nintro body\n\n## Goal\n\ngoal body\n\n## Steps\n\nstep body\n\n# Risks\n\nrisk body\n"; @@ -322,6 +338,25 @@ describe("PlanReviewOverlay", () => { expect(restored).toContain("goal body"); }); + it("copies the edited plan after deleting a section in the overlay", () => { + const onCopyPlan = vi.fn(); + const overlay = new PlanReviewOverlay( + SECTION_PLAN, + { promptTitle: "next", options: APPROVAL_OPTIONS }, + { onPick: vi.fn(), onCancel: vi.fn(), onCopyPlan }, + ); + render(overlay); + overlay.handleInput(TAB); // -> toc (Overview) + overlay.handleInput(DOWN); // -> Goal + overlay.handleInput("d"); + overlay.handleInput("c"); + + expect(onCopyPlan).toHaveBeenCalledTimes(1); + expect(onCopyPlan).toHaveBeenCalledWith( + "# Overview\n\nintro body\n\n## Steps\n\nstep body\n\n# Risks\n\nrisk body\n", + ); + }); + it("annotates a section and emits feedback for the Refine loop", () => { const onFeedbackChange = vi.fn(); const overlay = new PlanReviewOverlay( diff --git a/packages/coding-agent/test/modes/components/welcome.test.ts b/packages/coding-agent/test/modes/components/welcome.test.ts index 901c184e2..f1d5997d1 100644 --- a/packages/coding-agent/test/modes/components/welcome.test.ts +++ b/packages/coding-agent/test/modes/components/welcome.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { WelcomeComponent } from "@oh-my-pi/pi-coding-agent/modes/components/welcome"; +import { pickWeightedTip, WelcomeComponent } from "@oh-my-pi/pi-coding-agent/modes/components/welcome"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; describe("WelcomeComponent tips", () => { @@ -37,17 +37,15 @@ describe("WelcomeComponent tips", () => { }); it("weights [NEW] tips above ordinary tips in selection", () => { - // Skip the nerdfont gate so the only Math.random() call is the weighted pick. - vi.spyOn(theme, "getSymbolPreset").mockReturnValue("nerd"); - let r = 0; - vi.spyOn(Math, "random").mockImplementation(() => r); + // Data-independent: tips.txt may legitimately carry zero "[NEW]" tips, so + // exercise the weighting contract on a synthetic list. + const tips = ["plain one", "shiny thing [NEW]", "plain two"] as const; const counts = new Map(); const samples = 10_000; for (let i = 0; i < samples; i++) { - r = (i + 0.5) / samples; // sweep the selection domain uniformly - const tip = new WelcomeComponent("1.0.0", "model", "provider").tip; - if (tip) counts.set(tip, (counts.get(tip) ?? 0) + 1); + const tip = pickWeightedTip(tips, (i + 0.5) / samples); // sweep the selection domain uniformly + counts.set(tip, (counts.get(tip) ?? 0) + 1); } let newMax = 0; @@ -61,5 +59,6 @@ describe("WelcomeComponent tips", () => { // uniform selection domain than any single ordinary tip. expect(newMax).toBeGreaterThan(0); expect(newMax).toBeGreaterThan(ordinaryMax); + expect(pickWeightedTip([], 0.5)).toBe(""); }); }); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts index 185831e4d..2a342e3ea 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts @@ -21,6 +21,7 @@ import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/eve import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import type { TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types"; +import type { BashToolDetails } from "@oh-my-pi/pi-coding-agent/tools/bash"; function taskResult(asyncState: "running" | "completed" | "failed" | undefined, text: string) { const details: TaskToolDetails = { @@ -32,7 +33,14 @@ function taskResult(asyncState: "running" | "completed" | "failed" | undefined, return { content: [{ type: "text" as const, text }], details }; } -describe("EventController task async update finalization", () => { +function bashResult(text: string) { + const details: BashToolDetails = { + async: { state: "running", jobId: "bash-1", type: "bash" }, + }; + return { content: [{ type: "text" as const, text }], details }; +} + +describe("EventController async update finalization", () => { const sealed: ToolExecutionComponent[] = []; beforeEach(async () => { @@ -132,4 +140,27 @@ describe("EventController task async update finalization", () => { expect(pendingTools.has("tc-task")).toBe(false); expect(component.isTranscriptBlockFinalized()).toBe(true); }); + + it("finalizes a backgrounded Bash block without tracking later job updates", async () => { + const { controller, pendingTools } = createFixture(); + await controller.handleEvent({ + type: "tool_execution_start", + toolCallId: "tc-bash", + toolName: "bash", + args: { command: "sleep 30" }, + }); + const component = pendingTools.get("tc-bash")!; + sealed.push(component); + + await controller.handleEvent({ + type: "tool_execution_end", + toolCallId: "tc-bash", + toolName: "bash", + result: bashResult("Backgrounded as job bash-1; result will be delivered automatically."), + isError: false, + }); + + expect(pendingTools.has("tc-bash")).toBe(false); + expect(component.isTranscriptBlockFinalized()).toBe(true); + }); }); diff --git a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts index 5cf608994..d8a120c43 100644 --- a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts @@ -41,10 +41,51 @@ function assistantText(text: string): AssistantMessage { }; } +interface TanSessionEvent { + type: string; + result?: unknown; + aborted?: boolean; +} + +/** Minimal tan clone session stub covering the surface `TanCommandController` drives. */ +function createCloneStub(overrides?: { + prompt?: () => Promise; + abort?: () => void; + sessionManager?: { appendSessionInit: (init: unknown) => void }; + lastAssistantText?: string; +}) { + const appendMessage = vi.fn(); + let listener: ((event: TanSessionEvent) => void) | undefined; + const clone = { + agent: { appendMessage }, + sessionManager: overrides?.sessionManager, + setTodoPhases: vi.fn(), + subscribe: vi.fn((l: (event: TanSessionEvent) => void) => { + listener = l; + return () => { + listener = undefined; + }; + }), + prompt: vi.fn(overrides?.prompt ?? (async () => {})), + waitForIdle: vi.fn(async () => {}), + getLastAssistantMessage: vi.fn(() => assistantText(overrides?.lastAssistantText ?? "done")), + abort: vi.fn(overrides?.abort ?? (() => {})), + dispose: vi.fn(async () => {}), + }; + return { + clone, + appendMessage, + get compactionListener() { + return listener; + }, + }; +} + function createContext(overrides?: { isStreaming?: boolean; model?: Model; agentId?: string; + parentPromptCacheKey?: string; register?: (run: CapturedJobRun, options?: AsyncJobRegisterOptions) => string; }) { const tempDir = TempDir.createSync("@omp-tan-controller-"); @@ -64,6 +105,7 @@ function createContext(overrides?: { ); const session = { isStreaming: overrides?.isStreaming ?? false, + agent: { promptCacheKey: overrides?.parentPromptCacheKey }, model: overrides?.model ?? model, asyncJobManager: { register }, sessionId: "parent-session", @@ -85,6 +127,7 @@ function createContext(overrides?: { } as unknown as InteractiveModeContext["sessionManager"]; const cloneManager = { getSessionFile: vi.fn(() => cloneFile), + appendCustomEntry: vi.fn(), } as unknown as SessionManager; const ctx = { session, @@ -187,18 +230,16 @@ describe("TanCommandController", () => { vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); const promptStarted = Promise.withResolvers(); const abortObserved = Promise.withResolvers(); - const clone = { - prompt: vi.fn(async () => { + const { clone } = createCloneStub({ + prompt: async () => { promptStarted.resolve(); await abortObserved.promise; - }), - waitForIdle: vi.fn(async () => {}), - getLastAssistantMessage: vi.fn(() => assistantText("finished")), - abort: vi.fn(() => { + }, + abort: () => { abortObserved.resolve(); - }), - dispose: vi.fn(async () => {}), - }; + }, + lastAssistantText: "finished", + }); const createAgentSessionSpy = vi .spyOn(sdkModule, "createAgentSession") .mockResolvedValue({ session: clone } as unknown as CreateAgentSessionResult); @@ -233,13 +274,7 @@ describe("TanCommandController", () => { it("parents the tan clone to the spawning agent, not to the clone itself", async () => { const harness = createContext({ agentId: "FocusedParent" }); vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); - const clone = { - prompt: vi.fn(async () => {}), - waitForIdle: vi.fn(async () => {}), - getLastAssistantMessage: vi.fn(() => assistantText("done")), - abort: vi.fn(), - dispose: vi.fn(async () => {}), - }; + const { clone } = createCloneStub(); const createAgentSessionSpy = vi .spyOn(sdkModule, "createAgentSession") .mockResolvedValue({ session: clone } as unknown as CreateAgentSessionResult); @@ -258,16 +293,32 @@ describe("TanCommandController", () => { expect(opts?.parentTaskPrefix).not.toBe("FocusedParent"); }); + it("pins the parent's effective cache key when the parent itself carries a pinned promptCacheKey", async () => { + // A parent that is itself a fork/tan caches under `agent.promptCacheKey`, + // not its own session id — the clone must read that exact shard. + const harness = createContext({ parentPromptCacheKey: "grandparent-cache-key" }); + vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); + const { clone } = createCloneStub(); + const createAgentSessionSpy = vi + .spyOn(sdkModule, "createAgentSession") + .mockResolvedValue({ session: clone } as unknown as CreateAgentSessionResult); + const controller = new TanCommandController(harness.ctx); + + await controller.start("follow the tangent"); + const run = harness.capturedRun; + if (!run) throw new Error("run function was not captured"); + await run({ jobId: "job-1", signal: new AbortController().signal, reportProgress: async () => {} }); + + const opts = createAgentSessionSpy.mock.calls[0]?.[0]; + expect(opts?.providerPromptCacheKey).toBe("grandparent-cache-key"); + expect(opts?.providerSessionId).toMatch(/^parent-session:tan:/); + }); + it("parks the finished tan in the registry so it stays visible in the Agent Hub", async () => { const harness = createContext(); vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); - const clone = { - prompt: vi.fn(async () => {}), - waitForIdle: vi.fn(async () => {}), - getLastAssistantMessage: vi.fn(() => assistantText("done")), - abort: vi.fn(), - dispose: vi.fn(async () => {}), - }; + const appendSessionInit = vi.fn(); + const { clone } = createCloneStub({ sessionManager: { appendSessionInit } }); vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({ session: clone, } as unknown as CreateAgentSessionResult); @@ -287,6 +338,11 @@ describe("TanCommandController", () => { }); expect(result).toBe("done"); + expect(appendSessionInit).toHaveBeenCalledWith({ + systemPrompt: "system prompt", + task: "park me", + tools: ["read", "bash"], + }); // Parked (not unregistered) before dispose, then the disposed session is nulled // out — the hub keeps the ref and reads its transcript from the session file. expect(setStatus).toHaveBeenCalledWith(expect.stringMatching(/^Tan-/), "parked"); @@ -294,4 +350,46 @@ describe("TanCommandController", () => { expect(clone.dispose).toHaveBeenCalled(); expect(unregister).not.toHaveBeenCalled(); }); + + it("isolates the fork: clears inherited todos, injects the fork notice, and re-injects after compaction", async () => { + const harness = createContext(); + vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); + const compacted = Promise.withResolvers(); + const stub = createCloneStub({ + prompt: async () => { + // Simulate the clone's history compacting mid-run: the summarizer + // erases the fork notice, so the controller must append it again. + stub.compactionListener?.({ type: "auto_compaction_end", result: {}, aborted: false }); + compacted.resolve(); + }, + }); + vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({ + session: stub.clone, + } as unknown as CreateAgentSessionResult); + const controller = new TanCommandController(harness.ctx); + + await controller.start("follow the tangent"); + const run = harness.capturedRun; + if (!run) throw new Error("run function was not captured"); + await run({ jobId: "job-123", signal: new AbortController().signal, reportProgress: async () => {} }); + await compacted.promise; + + // Inherited parent todos are wiped both in-memory and in the persisted + // session so reloads agree; otherwise todo reminders drag the tan back + // onto the parent's task. + expect(stub.clone.setTodoPhases).toHaveBeenCalledWith([]); + expect(harness.cloneManager.appendCustomEntry).toHaveBeenCalledWith("user_todo_edit", { phases: [] }); + // Fork notice injected before the prompt and again after compaction. + expect(stub.appendMessage).toHaveBeenCalledTimes(2); + for (const call of stub.appendMessage.mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ + role: "developer", + content: expect.stringContaining(''), + }), + ); + } + // The compaction listener is released once the tan finishes. + expect(stub.compactionListener).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index 547b46623..15c9f3531 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -5,6 +5,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { ResolvedRoleModel } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state"; let settingsState: SettingsTestState | undefined; @@ -128,9 +129,94 @@ describe("selector setting side effects", () => { expect(showError).not.toHaveBeenCalled(); expect(settings.get("retry.fallbackChains")).toEqual({ default: ["test/retry-fallback-model"] }); - expect(showStatus).toHaveBeenCalledWith("Default fallback model: test/retry-fallback-model"); + expect(showStatus).toHaveBeenCalledWith("Default fallbacks: test/retry-fallback-model"); } finally { hub.dispose(); } }); + + it("applies an @ quick role through the role-switch session API", async () => { + const testTheme = await getThemeByName("dark"); + if (!testTheme) throw new Error("Failed to load dark theme for quick-role picker test"); + setThemeInstance(testTheme); + + const smol = buildModel({ + id: "smol-model", + name: "smol-model", + api: "ollama-chat", + baseUrl: "https://example.com", + reasoning: false, + provider: "test", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 1024, + }); + const slow = buildModel({ + id: "slow-model", + name: "slow-model", + api: "ollama-chat", + baseUrl: "https://example.com", + reasoning: false, + provider: "test", + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 1024, + }); + const quickRoles: ResolvedRoleModel[] = [ + { role: "smol", model: smol, explicitThinkingLevel: false }, + { role: "slow", model: slow, explicitThinkingLevel: false }, + ]; + const applyRoleModel = vi.fn(async () => {}); + const setModelTemporary = vi.fn(async () => {}); + const showModelCycleTrack = vi.fn(); + const showError = vi.fn(); + let picker: { handleInput(data: string): void } | undefined; + const settings = Settings.isolated({ cycleOrder: ["smol", "slow"] }); + const controller = new SelectorController({ + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + showOverlay: vi.fn((component: unknown) => { + picker = component as { handleInput(data: string): void }; + return { hide: vi.fn() }; + }), + terminal: { rows: 40 }, + }, + editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] }, + editor: {}, + settings, + session: { + model: slow, + modelRegistry: { + getAll: () => [smol, slow], + getAvailable: () => [smol, slow], + getError: () => undefined, + refresh: async () => {}, + }, + scopedModels: [{ model: smol }, { model: slow }], + getContextUsage: () => undefined, + getRoleModelCycle: () => ({ models: quickRoles, currentIndex: 1 }), + applyRoleModel, + setModelTemporary, + }, + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + keybindings: { getKeys: () => [] }, + showModelCycleTrack, + showError, + } as unknown as InteractiveModeContext); + + controller.showModelSelector({ temporaryOnly: true }); + if (!picker) throw new Error("Expected temporary model picker overlay"); + picker.handleInput("@"); + picker.handleInput("\n"); + await Promise.resolve(); + + expect(applyRoleModel).toHaveBeenCalledWith(quickRoles[1]); + expect(setModelTemporary).not.toHaveBeenCalled(); + expect(showModelCycleTrack).toHaveBeenCalledTimes(1); + expect(showError).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts index 303023cc4..0d2e7881b 100644 --- a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts +++ b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts @@ -63,6 +63,11 @@ describe("BUILTIN_TOOLS public factory map", () => { const missing = Object.keys(BUILTIN_TOOLS).filter(name => metadata.get(name)?.loadMode === undefined); expect(missing).toEqual([]); }); + it("exposes launch instead of daemon", async () => { + const launch = await BUILTIN_TOOLS.launch(toolSession); + expect(launch?.name).toBe("launch"); + expect(Object.hasOwn(BUILTIN_TOOLS, "daemon")).toBeFalse(); + }); }); describe("built-in tool loadMode annotations", () => { diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 90c001c02..1bff803e7 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1414,6 +1414,7 @@ function b() { it("should auto-background long-running commands when enabled", async () => { const deliveries: Array<{ jobId: string; text: string }> = []; + const updates: string[] = []; const asyncJobManager = new AsyncJobManager({ onJobComplete: async (jobId, text) => { deliveries.push({ jobId, text }); @@ -1435,13 +1436,20 @@ function b() { ), ); - const result = await autoBackgroundBashTool.execute("test-call-9-auto-running", { - command: "printf 'start\\n'; sleep 0.03; printf 'done\\n'", - }); + const result = await autoBackgroundBashTool.execute( + "test-call-9-auto-running", + { + command: "printf 'start\\n'; sleep 0.03; printf 'done\\n'", + }, + undefined, + update => { + updates.push(update.content?.find(block => block.type === "text")?.text ?? ""); + }, + ); expect(result.details?.async?.state).toBe("running"); expect(result.details?.async?.type).toBe("bash"); - expect(getTextOutput(result)).toContain("Background job"); + expect(getTextOutput(result)).toContain("Backgrounded as job"); expect(getTextOutput(result)).toContain("start"); const jobId = result.details?.async?.jobId; @@ -1450,11 +1458,13 @@ function b() { } const runningJob = asyncJobManager.getJob(jobId); expect(runningJob?.status).toBe("running"); + const updatesAtBackground = updates.slice(); await runningJob?.promise; await asyncJobManager.drainDeliveries({ timeoutMs: 1 }); expect(deliveries).toHaveLength(1); expect(deliveries[0]?.jobId).toBe(jobId); expect(deliveries[0]?.text).toContain("done"); + expect(updates).toEqual(updatesAtBackground); await asyncJobManager.dispose(); }); @@ -1495,7 +1505,7 @@ function b() { expect(result.details?.timeoutSeconds).toBe(0.05); expect(result.details?.async?.state).toBe("running"); - expect(getTextOutput(result)).toContain("Background job"); + expect(getTextOutput(result)).toContain("Backgrounded as job"); const jobId = result.details?.async?.jobId; if (!jobId) { throw new Error("expected an auto-backgrounded job id"); diff --git a/packages/coding-agent/test/tools/bash-interceptor.test.ts b/packages/coding-agent/test/tools/bash-interceptor.test.ts index 5b067aff8..649b4184e 100644 --- a/packages/coding-agent/test/tools/bash-interceptor.test.ts +++ b/packages/coding-agent/test/tools/bash-interceptor.test.ts @@ -111,6 +111,32 @@ describe("default echo/printf redirect rule", () => { }); }); +describe("default launch rules", () => { + const tools = ["launch"]; + + it.each([ + "bun run dev", + "vite --host 0.0.0.0", + "lldb ./app", + "bun test --watch", + "nohup server", + "server &", + ])("routes %s to launch", command => { + const result = checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES); + expect(result.block).toBe(true); + expect(result.suggestedTool).toBe("launch"); + }); + + it.each([ + "git diff -w", + "docker compose up -d", + "bun test", + "printf 'server &'", + ])("does not misclassify finite command %s", command => { + expect(checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false); + }); +}); + describe("BashTool argument validation", () => { it("preserves async requests so disabled async mode returns the explicit error", async () => { const tool = createBashTool([]); diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index 45cb05568..e6d2d23e9 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -128,6 +128,34 @@ describe("bashToolRenderer", () => { expect(rendered).not.toContain("Wall time: 1.23 seconds"); }); + it("renders a backgrounded job as a static footer notice", async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const uiTheme = theme!; + const component = bashToolRenderer.renderResult( + { + content: [ + { + type: "text", + text: "started\n\nBackgrounded as job bash-42; result will be delivered automatically.", + }, + ], + details: { + timeoutSeconds: 300, + async: { state: "running", jobId: "bash-42", type: "bash" }, + }, + isError: false, + }, + { expanded: false, isPartial: false }, + uiTheme, + { command: "sleep 30" }, + ); + const rendered = sanitizeText(component.render(120).join("\n")); + expect(rendered).toContain("started"); + expect(rendered).toContain("Backgrounded: bash-42"); + expect(rendered).not.toContain("result will be delivered automatically"); + }); + it("folds raw output artifact notices into the status footer", async () => { const theme = await getThemeByName("dark"); expect(theme).toBeDefined(); diff --git a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts index 4d451fa49..876b56817 100644 --- a/packages/coding-agent/test/tools/browser-run-cancellation.test.ts +++ b/packages/coding-agent/test/tools/browser-run-cancellation.test.ts @@ -57,6 +57,44 @@ describe("browser run cancellation", () => { await expect(wait).rejects.toThrow("browser run ended"); }); + it("resolves wait(predicate) with the first truthy value", async () => { + vi.useRealTimers(); + const controller = new AbortController(); + let calls = 0; + + const wait = waitForBrowserRun(() => (++calls >= 3 ? "ready" : null), controller.signal, { interval: 10 }); + + await expect(wait).resolves.toBe("ready"); + expect(calls).toBe(3); + }); + + it("fails wait(predicate) with a named timeout error instead of stalling", async () => { + vi.useRealTimers(); + const controller = new AbortController(); + + const wait = waitForBrowserRun(() => false, controller.signal, { timeout: 50, interval: 10 }); + + await expect(wait).rejects.toThrow("wait(predicate) timed out after 50ms"); + }); + + it("rejects wait(predicate) when the run aborts mid-poll", async () => { + vi.useRealTimers(); + const controller = new AbortController(); + + const wait = waitForBrowserRun(() => false, controller.signal, { timeout: 5000 }); + controller.abort(new Error("browser run ended")); + + await expect(wait).rejects.toThrow("browser run ended"); + }); + + it("rejects wait() input that is neither milliseconds nor a predicate", async () => { + const controller = new AbortController(); + + await expect(waitForBrowserRun("soon" as never, controller.signal)).rejects.toThrow( + "wait(...) expects milliseconds (number) or a predicate function to poll", + ); + }); + it("does not emit unhandledRejection for an unawaited wait aborted by run teardown", async () => { const controller = new AbortController(); @@ -124,7 +162,7 @@ describe("browser run cancellation", () => { once: true, }); runtime.setRunScope({ - wait: (ms: number): Promise => waitForBrowserRun(ms, signal), + wait: (ms: number): Promise => waitForBrowserRun(ms, signal), tab: bindBrowserRunFacade( { goto: async (url: string): Promise => { diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts new file mode 100644 index 000000000..63f8daf39 --- /dev/null +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { BrowserTool } from "@oh-my-pi/pi-coding-agent/tools/browser"; + +function makeSession(): ToolSession { + return { + cwd: "/tmp/test", + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated({ "browser.headless": true }), + }; +} + +describe("browser tab evaluation", () => { + it("runs tab.evaluate in the page's main JavaScript world", async () => { + const tool = new BrowserTool(makeSession()); + const name = `main-world-${process.pid}`; + + try { + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,", + }); + const result = await tool.execute("run", { + action: "run", + name, + code: "return await tab.evaluate(() => globalThis.__ompMainWorld);", + }); + + expect(result.content).toEqual([{ type: "text", text: "42" }]); + } finally { + await tool.execute("close", { action: "close", name, kill: true }); + } + }); +}); diff --git a/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts b/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts index 57468b411..ed66b8fd9 100644 --- a/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { resolvePredicateTimeout } from "@oh-my-pi/pi-coding-agent/tools/browser/run-cancellation"; import { normalizeSelector, resolveOpTimeouts, @@ -77,6 +78,31 @@ describe("browser wait-helper timeout resolution", () => { }); }); +describe("browser wait(predicate) deadline resolution", () => { + it("keeps the default deadline strictly under the cell budget so the named error wins", () => { + // Default cell (30s): the old 30s predicate default tied the cell timer and lost the + // race, surfacing the opaque whole-cell timeout instead of the named wait error. + for (const cell of [5_000, 30_000, 120_000]) { + expect(resolvePredicateTimeout(cell)).toBeLessThan(cell); + } + expect(resolvePredicateTimeout(120_000)).toBe(30_000); + expect(resolvePredicateTimeout(5_000)).toBe(4_000); + }); + + it("honors an explicit deadline but clamps it under the cell budget", () => { + expect(resolvePredicateTimeout(30_000, 5_000)).toBe(5_000); + expect(resolvePredicateTimeout(30_000, 90_000)).toBe(29_000); + expect(resolvePredicateTimeout(120_000, 90_000)).toBe(90_000); + }); + + it("maps disable sentinels to the largest bounded deadline and garbage to the default", () => { + expect(resolvePredicateTimeout(30_000, 0)).toBe(29_000); + expect(resolvePredicateTimeout(30_000, Number.POSITIVE_INFINITY)).toBe(29_000); + expect(resolvePredicateTimeout(30_000, -5)).toBe(29_000); + expect(resolvePredicateTimeout(30_000, Number.NaN)).toBe(29_000); + }); +}); + describe("browser selector guard", () => { it("rejects Playwright-only selector engines with an actionable message", () => { expect(() => normalizeSelector('button:has-text("Allow all")')).toThrow(/Playwright-only/); diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index b1c76116e..9bcf370bd 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -237,6 +237,7 @@ describe("createTools", () => { "astGrep.enabled": false, "astEdit.enabled": false, "bash.enabled": false, + "launch.enabled": false, "web_search.enabled": false, "browser.enabled": false, "inspect_image.enabled": false, @@ -246,6 +247,7 @@ describe("createTools", () => { const names = tools.map(t => t.name); expect(names).not.toContain("bash"); + expect(names).not.toContain("launch"); expect(names).not.toContain("glob"); expect(names).not.toContain("grep"); expect(names).not.toContain("ast_grep"); diff --git a/packages/coding-agent/test/tools/launch-renderer.test.ts b/packages/coding-agent/test/tools/launch-renderer.test.ts new file mode 100644 index 000000000..e9c2723b3 --- /dev/null +++ b/packages/coding-agent/test/tools/launch-renderer.test.ts @@ -0,0 +1,159 @@ +/** + * Launch renderer contract: one merged status header per op carrying the op, + * target name, and daemon state — replacing the old stacked "pending header + + * bare `✓ Launch` + raw text" render — plus per-op body rules (logs strip the + * LLM-facing `[name: state; cursor=N]` suffix, list caps collapsed rows). + */ +import { describe, expect, it } from "bun:test"; +import type { DaemonSnapshot } from "@oh-my-pi/pi-coding-agent/launch/protocol"; +import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { type LaunchToolDetails, launchToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/launch"; +import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers"; +import { sanitizeText } from "@oh-my-pi/pi-utils"; + +async function theme() { + const t = await getThemeByName("dark"); + expect(t).toBeDefined(); + return t!; +} + +const lines = (component: { render: (w: number) => readonly string[] }, width = 200) => + sanitizeText(component.render(width).join("\n")).split("\n"); + +const daemon = (overrides: Partial): DaemonSnapshot => ({ + name: "web", + id: "d-1", + state: "running", + pid: 51234, + createdAt: 0, + startedAt: Date.now() - 22_600, + restartCount: 0, + outputBytes: 0, + persist: false, + detached: false, + ...overrides, +}); + +describe("launchToolRenderer", () => { + it("is registered with merged call/result so the pending header is replaced, not stacked", () => { + expect(Object.is(toolRenderers.launch.renderResult, launchToolRenderer.renderResult)).toBe(true); + expect(toolRenderers.launch.mergeCallAndResult).toBe(true); + }); + + it("folds a stop result into one header with op, name, and exit state", async () => { + const uiTheme = await theme(); + const rendered = lines( + launchToolRenderer.renderResult( + { + content: [{ type: "text", text: "Stopped pyc-profile-run: exited exit=0 uptime=22.6s restarts=0" }], + details: { + op: "stop", + daemon: daemon({ name: "pyc-profile-run", state: "exited", exitedAt: Date.now(), exitCode: 0 }), + } satisfies LaunchToolDetails, + }, + { expanded: false, isPartial: false }, + uiTheme, + { op: "stop", name: "pyc-profile-run" }, + ), + ); + expect(rendered).toHaveLength(1); + expect(rendered[0]).toContain("Launch stop"); + expect(rendered[0]).toContain("pyc-profile-run"); + expect(rendered[0]).toContain("exited"); + expect(rendered[0]).toContain("exit 0"); + }); + + it("renders log lines without the trailing cursor-status suffix, surfacing it as header meta", async () => { + const uiTheme = await theme(); + const rendered = lines( + launchToolRenderer.renderResult( + { + content: [{ type: "text", text: "line one\nline two\n[web: running; cursor=2210]" }], + details: { op: "logs", cursor: 2210, timedOut: false, state: "running" } satisfies LaunchToolDetails, + }, + { expanded: false, isPartial: false }, + uiTheme, + { op: "logs", name: "web" }, + ), + ); + expect(rendered[0]).toContain("Launch logs"); + expect(rendered[0]).toContain("cursor 2210"); + expect(rendered).toContain("line one"); + expect(rendered).toContain("line two"); + expect(rendered.some(line => line.includes("[web: running"))).toBe(false); + }); + + it("caps a collapsed list to the preview item limit with a more-items row", async () => { + const uiTheme = await theme(); + const daemons = Array.from({ length: 11 }, (_, i) => daemon({ name: `svc-${i}`, id: `d-${i}` })); + const rendered = lines( + launchToolRenderer.renderResult( + { + content: [{ type: "text", text: "" }], + details: { op: "list", daemons } satisfies LaunchToolDetails, + }, + { expanded: false, isPartial: false }, + uiTheme, + { op: "list" }, + ), + ); + expect(rendered[0]).toContain("11 processes"); + expect(rendered.some(line => line.includes("svc-0"))).toBe(true); + expect(rendered.some(line => line.includes("svc-10"))).toBe(false); + expect(rendered.some(line => line.includes("3 more processes"))).toBe(true); + }); + + it("marks a failed start with the daemon's exit reason even though the result is not an error", async () => { + const uiTheme = await theme(); + const rendered = lines( + launchToolRenderer.renderResult( + { + content: [{ type: "text", text: "Failed to launch web: failed exit=127" }], + details: { + op: "start", + daemon: daemon({ + state: "failed", + exitedAt: Date.now(), + exitCode: 127, + exitReason: "spawn bun ENOENT", + }), + } satisfies LaunchToolDetails, + }, + { expanded: false, isPartial: false }, + uiTheme, + { op: "start", name: "web", application: "bun", args: ["run", "dev"] }, + ), + ); + expect(rendered[0]).toContain("Launch start"); + expect(rendered[0]).toContain("failed"); + expect(rendered.some(line => line.includes("spawn bun ENOENT"))).toBe(true); + }); + + it("names the unmet readiness condition instead of a contradictory Ready + timed-out pair", async () => { + const uiTheme = await theme(); + const rendered = lines( + launchToolRenderer.renderResult( + { + content: [{ type: "text", text: "" }], + details: { + op: "start", + timedOut: true, + daemon: daemon({ + state: "starting", + readyMatch: "Local: http://localhost:3100", + readyPending: ["port"], + }), + } satisfies LaunchToolDetails, + }, + { expanded: false, isPartial: false }, + uiTheme, + { op: "start", name: "web", application: "bunx", args: ["vite"], ready: { log: "Local:", port: 3100 } }, + ), + ); + expect(rendered[0]).toContain("waiting on port"); + expect(rendered.some(line => line.includes("log matched: Local: http://localhost:3100"))).toBe(true); + expect(rendered.some(line => line.includes("port 3100 on 127.0.0.1 never accepted connections"))).toBe(true); + // The old render labeled the log match a bare "ready:" while also saying readiness timed out. + expect(rendered.some(line => line.includes("ready: Local:"))).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/tools/launch.test.ts b/packages/coding-agent/test/tools/launch.test.ts new file mode 100644 index 000000000..eb5c95f3e --- /dev/null +++ b/packages/coding-agent/test/tools/launch.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createDaemonBrokerClient, type DaemonBrokerClient } from "../../src/launch/client"; +import { registerDaemonProjectPresence } from "../../src/launch/presence"; +import type { DaemonSpec } from "../../src/launch/protocol"; + +const cleanupDirs: string[] = []; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cleanupDirs.push(dir); + return dir; +} + +// Cross-process integration: fake timers cannot advance a detached broker or OS process table. +async function waitUntil(condition: () => boolean | Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return true; + await Bun.sleep(50); + } + return condition(); +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function shutdown(client: DaemonBrokerClient): Promise { + try { + await client.request({ op: "shutdown" }); + } catch { + // A last-client shutdown may already have closed the broker. + } + client.close(); +} + +afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (dir) await fs.rm(dir, { recursive: true, force: true }); + } +}); + +describe("daemon broker", () => { + it("shares PTY output and input across project clients", async () => { + const projectDir = await tempDir("omp-daemon-project-"); + const runtimeDir = await tempDir("omp-daemon-runtime-"); + const scriptPath = path.join(projectDir, "service.ts"); + await Bun.write( + scriptPath, + `process.stdin.setRawMode?.(true); +process.stdin.setEncoding("utf8"); +process.stdin.resume(); +process.stdout.write("READY\\n"); +process.stdin.on("data", chunk => process.stdout.write("INPUT:" + JSON.stringify(chunk) + "\\n")); +setInterval(() => {}, 1000); +`, + ); + const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + try { + const spec: DaemonSpec = { + name: "debugger", + application: process.execPath, + args: [scriptPath], + env: {}, + cwd: projectDir, + pty: true, + ready: { log: "READY", timeoutMs: 5_000 }, + restart: "no", + persist: false, + detached: false, + }; + const started = await first.request({ op: "start", spec, owner: "first-client" }); + expect(started.op).toBe("start"); + if (started.op !== "start") throw new Error("unexpected start result"); + expect(started.readyTimedOut).toBeFalse(); + expect(started.daemon.state).toBe("ready"); + + const listed = await second.request({ op: "list" }); + expect(listed.op).toBe("list"); + if (listed.op !== "list") throw new Error("unexpected list result"); + expect(listed.daemons.map(daemon => daemon.name)).toEqual(["debugger"]); + + await second.request({ op: "send", name: "debugger", data: "run\r" }); + const waited = await first.request({ + op: "wait", + name: "debugger", + for: "exit", + pattern: "INPUT", + timeoutMs: 3_000, + }); + expect(waited.op).toBe("wait"); + if (waited.op !== "wait") throw new Error("unexpected wait result"); + expect(waited.timedOut).toBeFalse(); + expect(waited.matched).toBe("INPUT"); + + const logs = await second.request({ + op: "logs", + name: "debugger", + lines: 20, + head: false, + follow: false, + timeoutMs: 1_000, + }); + expect(logs.op).toBe("logs"); + if (logs.op !== "logs") throw new Error("unexpected logs result"); + expect(logs.text).toContain("READY"); + expect(logs.text).toContain('INPUT:"run\\r"'); + + const stopped = await first.request({ op: "stop", name: "debugger", timeoutMs: 2_000 }); + expect(stopped.op).toBe("stop"); + if (stopped.op !== "stop") throw new Error("unexpected stop result"); + expect(stopped.daemon.state).toBe("exited"); + } finally { + await shutdown(first); + second.close(); + } + }, 20_000); + + it("stops non-persistent daemons after the last project omp exits", async () => { + const projectDir = await tempDir("omp-daemon-exit-project-"); + const runtimeDir = await tempDir("omp-daemon-exit-runtime-"); + const scriptPath = path.join(projectDir, "service.ts"); + await Bun.write(scriptPath, `process.stdout.write("READY\\n"); setInterval(() => {}, 1000);\n`); + const presence = await registerDaemonProjectPresence(projectDir, runtimeDir); + const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); + const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); + let pid: number | undefined; + try { + const started = await first.request({ + op: "start", + spec: { + name: "server", + application: process.execPath, + args: [scriptPath], + env: {}, + cwd: projectDir, + pty: false, + ready: { log: "READY", timeoutMs: 5_000 }, + restart: "no", + persist: false, + detached: false, + }, + }); + if (started.op !== "start" || started.daemon.pid === undefined) throw new Error("daemon did not start"); + const daemonPid = started.daemon.pid; + pid = daemonPid; + await second.request({ op: "list" }); + + first.close(); + second.close(); + // Cross-process integration: the real broker grace clock cannot be advanced with test fake timers. + await Bun.sleep(500); + expect(processExists(daemonPid)).toBeTrue(); + + await presence.close(); + const stopped = await waitUntil(() => !processExists(daemonPid), 5_000); + const socketRemoved = await waitUntil( + () => + Bun.file(path.join(runtimeDir, "broker.sock")) + .exists() + .then(exists => !exists), + 5_000, + ); + expect(stopped).toBeTrue(); + expect(socketRemoved).toBeTrue(); + } finally { + first.close(); + second.close(); + await presence.close(); + if (pid !== undefined && processExists(pid)) { + const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 1_000 }); + await shutdown(rescue); + } + } + }, 20_000); + + it("keeps detached daemons alive through broker replacement", async () => { + const projectDir = await tempDir("omp-daemon-detached-project-"); + const runtimeDir = await tempDir("omp-daemon-detached-runtime-"); + const scriptPath = path.join(projectDir, "service.ts"); + await Bun.write(scriptPath, `process.stdout.write("READY\\n"); setInterval(() => {}, 1000);\n`); + const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + let recovered: DaemonBrokerClient | undefined; + let pid: number | undefined; + try { + const started = await first.request({ + op: "start", + spec: { + name: "detached", + application: process.execPath, + args: [scriptPath], + env: {}, + cwd: projectDir, + pty: false, + ready: { log: "READY", timeoutMs: 5_000 }, + restart: "no", + persist: false, + detached: true, + }, + }); + if (started.op !== "start" || started.daemon.pid === undefined) + throw new Error("detached daemon did not start"); + pid = started.daemon.pid; + expect(started.daemon.persist).toBeTrue(); + expect(started.daemon.detached).toBeTrue(); + + await first.request({ op: "shutdown" }); + first.close(); + // Broker shutdown happens in another process, so fake timers cannot observe its lease release. + const brokerStopped = await waitUntil( + () => + Bun.file(path.join(runtimeDir, "broker.pid")) + .exists() + .then(exists => !exists), + 5_000, + ); + expect(brokerStopped).toBeTrue(); + expect(processExists(pid)).toBeTrue(); + + recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + const described = await recovered.request({ op: "describe", name: "detached" }); + if (described.op !== "describe") throw new Error("detached daemon did not recover"); + expect(described.daemon.pid).toBe(pid); + expect(described.daemon.detached).toBeTrue(); + expect(described.spec.persist).toBeTrue(); + + const stopped = await recovered.request({ op: "stop", name: "detached", timeoutMs: 2_000 }); + if (stopped.op !== "stop") throw new Error("detached daemon did not stop"); + expect(stopped.daemon.state).toBe("exited"); + await shutdown(recovered); + recovered = undefined; + } finally { + first.close(); + recovered?.close(); + if (pid !== undefined && processExists(pid)) { + const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 1_000 }); + try { + await rescue.request({ op: "stop", name: "detached", timeoutMs: 2_000 }); + } finally { + await shutdown(rescue); + } + } + } + }, 20_000); + + // Regression: a start whose log pattern matched but whose port never accepted + // used to report "Ready: " AND "Readiness timed out" with no hint of + // which condition failed. The snapshot now names the unmet condition(s). + it("names the unmet readiness condition when start times out", async () => { + const projectDir = await tempDir("omp-daemon-ready-project-"); + const runtimeDir = await tempDir("omp-daemon-ready-runtime-"); + const scriptPath = path.join(projectDir, "service.ts"); + await Bun.write(scriptPath, `process.stdout.write("LISTENING\\n"); setInterval(() => {}, 1000);\n`); + // Reserve an ephemeral port and release it so nothing accepts connections there. + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const deadPort = probe.port; + probe.stop(true); + const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + try { + const spec: DaemonSpec = { + name: "never-ready", + application: process.execPath, + args: [scriptPath], + env: {}, + cwd: projectDir, + pty: false, + ready: { log: "LISTENING", port: deadPort, timeoutMs: 3_000 }, + restart: "no", + persist: false, + detached: false, + }; + const started = await client.request({ op: "start", spec }); + expect(started.op).toBe("start"); + if (started.op !== "start") throw new Error("unexpected start result"); + expect(started.readyTimedOut).toBeTrue(); + expect(started.daemon.state).toBe("starting"); + expect(started.daemon.readyMatch).toBe("LISTENING"); + expect(started.daemon.readyPending).toEqual(["port"]); + + const stopped = await client.request({ op: "stop", name: "never-ready", timeoutMs: 2_000 }); + if (stopped.op !== "stop") throw new Error("unexpected stop result"); + // Terminal states carry no stale readiness noise. + expect(stopped.daemon.readyPending).toBeUndefined(); + } finally { + await shutdown(client); + } + }, 20_000); +}); diff --git a/packages/coding-agent/test/tools/web-search-public.test.ts b/packages/coding-agent/test/tools/web-search-public.test.ts index a3c58b53f..681f5318a 100644 --- a/packages/coding-agent/test/tools/web-search-public.test.ts +++ b/packages/coding-agent/test/tools/web-search-public.test.ts @@ -18,7 +18,7 @@ const fakeAuthStorage = { } as unknown as AuthStorage; /** Restrict the fan-out to the two engines these tests provide fixtures for. */ -const NON_TEST_ENGINES: readonly SearchProviderId[] = ["bing", "yahoo", "ecosia", "startpage", "mojeek"]; +const NON_TEST_ENGINES: readonly SearchProviderId[] = ["ecosia", "startpage", "mojeek"]; function makeParams(query: string, fetch: FetchImpl): SearchParams { return { diff --git a/packages/harbor-manager/README.md b/packages/harbor-manager/README.md new file mode 100644 index 000000000..f83b3c54d --- /dev/null +++ b/packages/harbor-manager/README.md @@ -0,0 +1,108 @@ +# @oh-my-pi/harbor-manager + +One manager for repository benchmarks. Harbor, TypeScript edit, and SnapCompact +runs use the same experiment → run → trace model, SQLite store, REST/SSE API, +and dashboard. Benchmark-native artifacts remain on disk; adapters normalize +their live progress, scores, token usage, costs, and traces. + +```bash +# Dashboard + API on :4700; launch every benchmark from the same “new run” form +bun run serve --port 4700 +``` + +## How Harbor runs execute + +1. **Local omp, not npm.** By default the runner bind-mounts the repo + read-only into each task container (`--install source`) and runs omp + straight from `packages/coding-agent/src/cli.ts` — TS edits apply to the + next trial with no rebuild. A cached linux `node_modules` tree (built once + per lockfile change inside `oven/bun`, stored in `/_bench/_deps/`) + shadows the host's darwin one, and a linux `bun` binary is mounted at + `/opt/omp/bin` — so trial setup needs zero outbound network. Alternatives: + `--install local` (pack a tarball per run) or `--binary` (prebuilt + `dist/omp-linux-*` self-contained binaries). +2. **Auth never enters containers.** A generated `models.yml` routes provider + `baseUrl`s at the host pm2 auth-gateway; the gateway resolves credentials + host-side. +3. **Harbor owns trials.** The runner/serve layer polls each trial's + `result.json` for progress, spend, and outcomes. + +## Server + +- `GET /` — experiments, runs, normalized traces, and a launch form for every benchmark. +- `GET /api/experiments` — experiment summaries across all benchmark types. +- `GET /api/runs` — uniform run rows with benchmark, score, progress, spend, and tokens. +- `POST /api/runs` — launch through a benchmark adapter. Body: + + ```json + { + "benchmark": "edit", + "model": "anthropic/claude-opus-4-8", + "tasks": 20, + "concurrency": 4, + "attempts": 2, + "jobName": "edit-baseline", + "role": "baseline", + "goal": "compare edit strategies" + } + ``` + + `benchmark` is `harbor`, `edit`, or `snapcompact`. Harbor uses `dataset`, + `include`, `timeoutMultiplier`, and `downshift`; edit uses `include` as task IDs; + SnapCompact uses `conditions` and treats `tasks` as the passage limit. +- `GET /api/runs/:name` — `{ run, traces }` (syncs native artifacts on read). +- `DELETE /api/runs/:name` — cancel a manager-launched run. +- `GET /api/runs/:name/traces/:trace[?raw=1]` — normalized or native trace. +- `GET /api/events` — SSE stream of run-list snapshots (sent on change). + +State lives in `/_manager/harbor-manager.sqlite`; the filesystem +stays the source of truth and historical CLI runs are auto-discovered. + +## Harbor runner options (excerpt) + +| Option | Default | Notes | +|---|---|---| +| `-m, --model ` | `anthropic/claude-sonnet-4-6` | Repeatable | +| `-l, --tasks ` | `20` | Max tasks | +| `-n, --concurrency ` | `4` | Concurrent trials | +| `-k, --attempts ` | `1` | Attempts per task (pass@k) | +| `-d, --dataset ` | `terminal-bench@2.0` | Any Harbor dataset id | +| `-i/-x, --include/--exclude ` | — | Task filters (repeatable) | +| `--timeout-multiplier ` | — | Scales task agent/verifier timeouts | +| `--agent-arg ` | — | Extra arg forwarded verbatim to the in-container omp CLI (repeatable) | +| `--env ` | — | Forward env into the omp container (repeatable); `KEY` alone forwards the host value | +| `--binary ` | — | Prebuilt omp binary (repeat for arm64+x64) | +| `--install ` | `source` | `source` = repo bind-mount, `local` = tarball pack, `published` = npm `@oh-my-pi/pi-coding-agent` | +| `--environment ` | `docker` | `apple-container` runs trials via Apple's `container` CLI (no Docker); source/deps mounts go through `harbor --mounts` and the gateway is auto-forwarded from `192.168.64.1:4000` to the loopback-bound gateway | +| `--gateway-url ` | `http://host.docker.internal:4000` | `http://192.168.64.1:4000` under `--environment apple-container` | +| `--no-gateway` | off | Pass host provider keys into containers instead | +| `-o, --jobs-dir ` | `/runs/harbor` | Shared with the server | +| `--dry-run` | off | Print the harbor command + models.yml and exit | + +## Outputs + +- `//` — Harbor trial dirs (`result.json` per trial). +- `/_bench//report.md` — markdown summary table. +- `/_bench//harbor.log` — full Harbor output. +- `/_manager/logs/.log` — runner output for API-launched runs. + +## Caveats + +- **Network policy.** On Harbor's local Docker backend only **public** + registries work; task containers reach models via the host gateway. +- **`--install source` reflects local TS changes** with no rebuild, but Rust + natives load from the in-tree `packages/natives/native/pi_natives.linux-*.node` + prebuilds — rebuild those when Rust changes (the loader skips the version + sentinel for workspace loads, so a stale `.node` runs silently). +- **Source mode is single-arch.** The deps tree matches the docker daemon's + native arch; trials on emulated images (e.g. x64 tasks on an arm64 host) + fail setup with an arch-mismatch error — use `--binary` for those. +- **The repo is visible (read-only) inside task containers** in source mode; + fine for curated benchmarks, but don't point it at untrusted tasks. +- **Apple Container specifics.** `--environment apple-container` needs + `brew install container && container system start` (macOS 26+, Apple + silicon). `--host-network` and `--cleanup*` are docker-only, and bind + mounts are read-write (the backend ignores `read_only`). +- **`--install local` reflects local TS changes** (inlined into `dist/cli.js`), + but **not** uncommitted Rust natives — rebuild `packages/natives` per target + first (the version sentinel must match). diff --git a/packages/harbor-manager/adapters/edit/bun-imports.d.ts b/packages/harbor-manager/adapters/edit/bun-imports.d.ts new file mode 100644 index 000000000..7c90c3d17 --- /dev/null +++ b/packages/harbor-manager/adapters/edit/bun-imports.d.ts @@ -0,0 +1,4 @@ +declare module "*.tar.gz" { + const content: string; + export default content; +} diff --git a/packages/harbor-manager/adapters/edit/cli.ts b/packages/harbor-manager/adapters/edit/cli.ts new file mode 100644 index 000000000..dbbe8ce04 --- /dev/null +++ b/packages/harbor-manager/adapters/edit/cli.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +/** Manager-owned executable adapter for the TypeScript edit benchmark. */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { parseArgs } from "node:util"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { loadTasksFromDir } from "@oh-my-pi/typescript-edit-benchmark/tasks"; +import { generateJsonReport } from "./report"; +import { type BenchmarkConfig, runBenchmark } from "./runner"; + +const EDIT_PACKAGE = path.resolve(import.meta.dir, "..", "..", "..", "typescript-edit-benchmark"); + +async function extractFixtures(): Promise<{ dir: string; temp: TempDir }> { + const temp = await TempDir.create("@harbor-edit-fixtures-"); + const archive = new Bun.Archive(await Bun.file(path.join(EDIT_PACKAGE, "fixtures.tar.gz")).arrayBuffer()); + for (const [filePath, file] of await archive.files()) { + await Bun.write(path.join(temp.path(), filePath), file); + } + const entries = await fs.readdir(temp.path(), { withFileTypes: true }); + const directories = entries.filter(entry => entry.isDirectory()); + const files = entries.filter(entry => entry.isFile()); + return { dir: directories.length === 1 && files.length === 0 ? path.join(temp.path(), directories[0]!.name) : temp.path(), temp }; +} + +/** Execute an edit benchmark and continuously materialize its normalized source artifact. */ +export async function main(argv = process.argv.slice(2)): Promise { + const { values } = parseArgs({ + args: argv, + options: { + model: { type: "string" }, + output: { type: "string" }, + "max-tasks": { type: "string", default: "80" }, + tasks: { type: "string" }, + "task-concurrency": { type: "string", default: "32" }, + runs: { type: "string", default: "1" }, + list: { type: "boolean", default: false }, + }, + strict: true, + }); + const fixtures = await extractFixtures(); + try { + let tasks = await loadTasksFromDir(fixtures.dir); + if (values.list) { + process.stdout.write(`${JSON.stringify(tasks.map(task => ({ id: task.id, name: task.name })))}\n`); + return; + } + if (!values.model || !values.output) throw new Error("edit adapter requires --model and --output"); + if (values.tasks) { + const selected = new Set(values.tasks.split(",").map(value => value.trim())); + tasks = tasks.filter(task => selected.has(task.id)); + if (tasks.length !== selected.size) throw new Error("one or more edit task ids were not found"); + } else { + const limit = Number(values["max-tasks"]); + if (limit > 0 && tasks.length > limit) { + const sorted = tasks.slice().sort((a, b) => a.id.localeCompare(b.id)); + const step = sorted.length / limit; + tasks = Array.from({ length: limit }, (_, index) => sorted[Math.floor(index * step)]!); + } + } + const slash = values.model.indexOf("/"); + const config: BenchmarkConfig = { + provider: slash === -1 ? "anthropic" : values.model.slice(0, slash), + model: values.model, + runsPerTask: Number(values.runs), + timeout: 120_000, + connectionTimeout: 30_000, + maxTurns: 30, + taskConcurrency: Number(values["task-concurrency"]), + guided: false, + maxAttempts: 1, + noOpRetryLimit: 2, + maxTimeoutRetries: 3, + maxProviderFailureRetries: 3, + mutationScopeWindow: 20, + conversationDumpDir: path.join(path.dirname(values.output), "result.dump"), + inProcess: true, + earlyStopOnMatch: true, + }; + let writes = Promise.resolve(); + const result = await runBenchmark(tasks, config, undefined, snapshot => { + writes = writes.then(async () => { + await Bun.write(values.output!, generateJsonReport(snapshot)); + }); + }); + await writes; + await Bun.write(values.output, generateJsonReport(result)); + } finally { + await fixtures.temp.remove(); + } +} + +if (import.meta.main) { + main().catch(error => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/typescript-edit-benchmark/src/prompts/benchmark-retry.md b/packages/harbor-manager/adapters/edit/prompts/benchmark-retry.md similarity index 100% rename from packages/typescript-edit-benchmark/src/prompts/benchmark-retry.md rename to packages/harbor-manager/adapters/edit/prompts/benchmark-retry.md diff --git a/packages/typescript-edit-benchmark/src/prompts/benchmark-system.md b/packages/harbor-manager/adapters/edit/prompts/benchmark-system.md similarity index 100% rename from packages/typescript-edit-benchmark/src/prompts/benchmark-system.md rename to packages/harbor-manager/adapters/edit/prompts/benchmark-system.md diff --git a/packages/typescript-edit-benchmark/src/prompts/benchmark-task.md b/packages/harbor-manager/adapters/edit/prompts/benchmark-task.md similarity index 100% rename from packages/typescript-edit-benchmark/src/prompts/benchmark-task.md rename to packages/harbor-manager/adapters/edit/prompts/benchmark-task.md diff --git a/packages/typescript-edit-benchmark/src/report.ts b/packages/harbor-manager/adapters/edit/report.ts similarity index 100% rename from packages/typescript-edit-benchmark/src/report.ts rename to packages/harbor-manager/adapters/edit/report.ts diff --git a/packages/typescript-edit-benchmark/test/runner.test.ts b/packages/harbor-manager/adapters/edit/runner.test.ts similarity index 98% rename from packages/typescript-edit-benchmark/test/runner.test.ts rename to packages/harbor-manager/adapters/edit/runner.test.ts index 2ff9434ad..b30cccd84 100644 --- a/packages/typescript-edit-benchmark/test/runner.test.ts +++ b/packages/harbor-manager/adapters/edit/runner.test.ts @@ -4,12 +4,8 @@ import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { formatSessionDumpText, SessionManager } from "@oh-my-pi/pi-coding-agent"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { generateReport } from "@oh-my-pi/typescript-edit-benchmark/report"; -import { - buildBenchmarkResult, - type TaskRunResult, - writeConversationDump, -} from "@oh-my-pi/typescript-edit-benchmark/runner"; +import { generateReport } from "./report"; +import { buildBenchmarkResult, type TaskRunResult, writeConversationDump } from "./runner"; import type { EditTask } from "@oh-my-pi/typescript-edit-benchmark/tasks"; const tempDirs: TempDir[] = []; diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/harbor-manager/adapters/edit/runner.ts similarity index 99% rename from packages/typescript-edit-benchmark/src/runner.ts rename to packages/harbor-manager/adapters/edit/runner.ts index e401c5c97..47568e6af 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/harbor-manager/adapters/edit/runner.ts @@ -13,15 +13,22 @@ import type { Model, ToolExample } from "@oh-my-pi/pi-ai"; import { formatSessionDumpText, RpcClient } from "@oh-my-pi/pi-coding-agent"; import { prompt } from "@oh-my-pi/pi-utils"; import { diffLines } from "diff"; -import { formatDirectory } from "./formatter"; -import { discoverSharedInfra, InProcessClient, type SharedInfra } from "./in-process-client"; +import { formatDirectory } from "@oh-my-pi/typescript-edit-benchmark/formatter"; +import { + discoverSharedInfra, + InProcessClient, + type SharedInfra, +} from "@oh-my-pi/typescript-edit-benchmark/in-process-client"; import benchmarkRetryPrompt from "./prompts/benchmark-retry.md" with { type: "text" }; import benchmarkSystemPrompt from "./prompts/benchmark-system.md" with { type: "text" }; import benchmarkTaskPrompt from "./prompts/benchmark-task.md" with { type: "text" }; -import type { EditTask } from "./tasks"; -import { verifyExpectedFileSubset, verifyExpectedFiles } from "./verify"; +import type { EditTask } from "@oh-my-pi/typescript-edit-benchmark/tasks"; +import { + verifyExpectedFileSubset, + verifyExpectedFiles, +} from "@oh-my-pi/typescript-edit-benchmark/verify"; -const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", ".."); +const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", "..", ".."); const RUNS_DIR = path.join(REPO_ROOT, "runs"); const TMP = path.join(RUNS_DIR, `rb-${Math.random().toString(36).slice(2, 10)}`); const CLI_PATH = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-coding-agent/cli")); diff --git a/packages/harbor-manager/adapters/edit/tsconfig.json b/packages/harbor-manager/adapters/edit/tsconfig.json new file mode 100644 index 000000000..936fe3f3c --- /dev/null +++ b/packages/harbor-manager/adapters/edit/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.workspace.json", + "include": ["."] +} diff --git a/packages/terminal-bench/agent/omp_local.py b/packages/harbor-manager/agent/omp_local.py similarity index 72% rename from packages/terminal-bench/agent/omp_local.py rename to packages/harbor-manager/agent/omp_local.py index ea73160cc..7cb9f32be 100644 --- a/packages/terminal-bench/agent/omp_local.py +++ b/packages/harbor-manager/agent/omp_local.py @@ -1,12 +1,18 @@ """Harbor agent that runs the LOCAL oh-my-pi (`omp`) build inside task containers. Unlike Harbor's built-in `pi` agent (which `npm i -g @mariozechner/pi-coding-agent`), -this installs the working tree at `/work/pi`: +this runs the working tree at `/work/pi`. Install modes (`OMP_BENCH_INSTALL`): - * the runner packs `packages/coding-agent` with `bun pm pack` (bundles every - workspace TS package into `dist/cli.js`) and hands us the tarball path, - * we upload it, install Bun, `bun install` the bundle's external deps + the - platform native addon, and run `bun .../dist/cli.js`. + * `source` (default): the runner bind-mounts the repo read-only plus a + prebuilt linux `node_modules` tree and a linux `bun` binary; omp runs + straight from `packages/coding-agent/src/cli.ts`. Zero-network setup, and + host TS edits apply to the next trial with no rebuild (Rust natives load + from the in-tree `packages/natives/native/*.node` prebuilds). + * `local`: the runner packs `packages/coding-agent` with `bun pm pack` + (bundles every workspace TS package into `dist/cli.js`) and hands us the + tarball path; we upload it, install Bun, `bun install` the bundle's + external deps + the platform native addon, and run `bun .../dist/cli.js`. + * binary (`--binary`): a self-contained compiled omp binary is uploaded. Auth never enters the container: a generated `~/.omp/agent/models.yml` routes the configured providers' `baseUrl` at the host's pm2 auth-gateway (default @@ -14,7 +20,7 @@ configured providers' `baseUrl` at the host's pm2 auth-gateway (default resolves credentials host-side. No provider API keys are passed in. All knobs come from environment variables the runner sets on the `harbor` process -(see `OMP_TB_*` below); the agent reads them from `os.environ` directly. +(see `OMP_BENCH_*` below); the agent reads them from `os.environ` directly. Selected via `harbor run --agent-import-path omp_local:OmpLocal` with the directory of this file on `PYTHONPATH`. @@ -82,14 +88,39 @@ def _patch_harbor_cleanup_cancellation() -> None: Trial._omp_cleanup_cancellation_patch = True +def _patch_apple_container_dns() -> None: + """Inject an explicit resolver into every Apple Container `container run`. + + Containers default to the vmnet gateway resolver (192.168.64.1:53), which is + unreachable when VPN/DNS agents on the host intercept port 53. The runner + sets OMP_BENCH_CONTAINER_DNS for apple-container jobs; absent, no-op. + """ + dns = os.environ.get("OMP_BENCH_CONTAINER_DNS") + if not dns: + return + from harbor.environments.apple_container import AppleContainerEnvironment + + if getattr(AppleContainerEnvironment, "_omp_dns_patch", False): + return + original = AppleContainerEnvironment._run_container_command + + async def _run_with_dns(self, args, *pargs, **kwargs): + if args and args[0] == "run": + args = ["run", "--dns", dns, *args[1:]] + return await original(self, args, *pargs, **kwargs) + + AppleContainerEnvironment._run_container_command = _run_with_dns + AppleContainerEnvironment._omp_dns_patch = True + + _patch_harbor_cleanup_cancellation() +_patch_apple_container_dns() # Container-side staging paths (absolute; never depend on $HOME at write time). _TARBALL_DST = "/tmp/omp-local.tgz" _MODELS_DST = "/tmp/omp-models.yml" _CONFIG_DST = "/tmp/omp-config.yml" _OUTPUT_FILENAME = "omp.txt" -_ADVISOR_FILENAME = "advisor.jsonl" # Provider → host env vars used in --no-gateway (direct-auth) mode only. _PROVIDER_KEYS: dict[str, list[str]] = { @@ -166,37 +197,42 @@ class OmpLocal(BaseInstalledAgent): def __init__(self, *args, **kwargs) -> None: # noqa: D401 - thin wrapper super().__init__(*args, **kwargs) - self._install_mode = _env("OMP_TB_INSTALL", "local") - self._tarball = _env("OMP_TB_TARBALL") - self._pkg_version = _env("OMP_TB_VERSION", "latest") - self._models_yaml_path = _env("OMP_TB_MODELS_YAML") - self._gateway_url = _env("OMP_TB_GATEWAY_URL", "http://host.docker.internal:4000") - self._gateway_token = _env("OMP_TB_GATEWAY_TOKEN", "no-auth-dummy") + self._install_mode = _env("OMP_BENCH_INSTALL", "source") + self._tarball = _env("OMP_BENCH_TARBALL") + self._pkg_version = _env("OMP_BENCH_VERSION", "latest") + self._models_yaml_path = _env("OMP_BENCH_MODELS_YAML") + self._gateway_url = _env("OMP_BENCH_GATEWAY_URL", "http://host.docker.internal:4000") + self._gateway_token = _env("OMP_BENCH_GATEWAY_TOKEN", "no-auth-dummy") self._gateway_providers = [ p.strip() - for p in _env("OMP_TB_GATEWAY_PROVIDERS", "anthropic,openai-codex").split(",") + for p in _env("OMP_BENCH_GATEWAY_PROVIDERS", "anthropic,openai-codex").split(",") if p.strip() ] - self._thinking = _env("OMP_TB_THINKING") - self._auto_approve = _truthy(_env("OMP_TB_AUTO_APPROVE", "1")) - self._extra_args = _env("OMP_TB_EXTRA_ARGS") - self._bun_version = _env("OMP_TB_BUN_VERSION", "1.3.14") - self._gateway_on = _env("OMP_TB_GATEWAY", "1") != "0" - # Optional second model reviewing the primary (separate spend, summed in). - self._advisor_model = _env("OMP_TB_ADVISOR_MODEL") - self._advisor_sync = _env("OMP_TB_ADVISOR_SYNC", "1") + self._thinking = _env("OMP_BENCH_THINKING") + self._auto_approve = _truthy(_env("OMP_BENCH_AUTO_APPROVE", "1")) + # Extra CLI args forwarded verbatim to the in-container omp invocation, + # JSON-array-encoded by the runner (OMP_BENCH_AGENT_ARGS) so multi-word + # values survive without a second layer of shell quoting. + self._agent_args = self._parse_agent_args() + self._bun_version = _env("OMP_BENCH_BUN_VERSION", "1.3.14") + self._gateway_on = _env("OMP_BENCH_GATEWAY", "1") != "0" + # web_search auth can't route through the gateway (dedicated provider creds); # off by default so search-using tasks don't false-negative on 401s. - self._web_search = _truthy(_env("OMP_TB_WEB_SEARCH", "0")) + self._web_search = _truthy(_env("OMP_BENCH_WEB_SEARCH", "0")) # Extra env (PI_* dialect knobs, explicit --env) the runner forwards into - # the in-container omp run, JSON-encoded in OMP_TB_FORWARD_ENV. + # the in-container omp run, JSON-encoded in OMP_BENCH_FORWARD_ENV. self._forward_env = self._parse_forward_env() + # Source-mount paths (defaults must match the runner's compose overlay). + self._source_dir = _env("OMP_BENCH_SOURCE_DIR", "/opt/omp/src") + self._source_bun = _env("OMP_BENCH_SOURCE_BUN", "/opt/omp/bin/bun") + self._source_arch = _env("OMP_BENCH_SOURCE_ARCH") # Resolved during install(); reused by version + run commands. self._home = "/root" self._bun = "/root/.bun/bin/bun" self._cli = "/root/.omp-bench/app/dist/cli.js" - self._binary_arm64 = _env("OMP_TB_BINARY_ARM64") - self._binary_x64 = _env("OMP_TB_BINARY_X64") + self._binary_arm64 = _env("OMP_BENCH_BINARY_ARM64") + self._binary_x64 = _env("OMP_BENCH_BINARY_X64") self._binary = bool(self._binary_arm64 or self._binary_x64) @staticmethod @@ -226,9 +262,10 @@ class OmpLocal(BaseInstalledAgent): omp spawns Bun worker subprocesses at runtime, so `bun` must resolve on PATH during `run()` too — not just for the entrypoint. """ + bun_dir = os.path.dirname(self._bun) return ( f'export BUN_INSTALL={shlex.quote(self._home + "/.bun")}; ' - f'export PATH="{self._home}/.bun/bin:$PATH"; ' + f'export PATH="{bun_dir}:$PATH"; ' f"{command}" ) @@ -242,6 +279,9 @@ class OmpLocal(BaseInstalledAgent): # Self-contained binary mode: upload + chmod only. No apt/curl/bun/npm, so # trial setup needs zero outbound network (no_network tasks set up cleanly). await self._install_binary(environment) + elif self._install_mode == "source": + # Everything is bind-mounted by the runner; nothing to download. + self._cli = await self._install_source(environment) else: # 1) System deps (root). curl+unzip for the Bun installer; ca-certs for TLS. await self.exec_as_root( @@ -279,9 +319,39 @@ class OmpLocal(BaseInstalledAgent): await self._write_models_yaml(environment) await self._write_config(environment) + async def _install_source(self, environment: BaseEnvironment) -> str: + """Verify the read-only repo + linux deps mounts and run omp from TS source. + + The runner mounts the repo at `self._source_dir`, shadows every host + `node_modules` with a linux tree, and mounts a linux `bun` binary — so + setup needs zero outbound network and no rebuild for TS changes. + """ + arch = (await self.exec_as_agent(environment, command="uname -m")).stdout.strip() + norm = {"aarch64": "arm64", "arm64": "arm64", "x86_64": "x64", "amd64": "x64"}.get(arch) + if self._source_arch and norm != self._source_arch: + raise RuntimeError( + f"source mode: container arch {arch!r} != mounted deps tree arch " + f"({self._source_arch}); use --binary for emulated-arch tasks" + ) + self._bun = self._source_bun + cli = f"{self._source_dir}/packages/coding-agent/src/cli.ts" + q = shlex.quote + await self.exec_as_agent( + environment, + command=( + "set -e; " + f"test -x {q(self._source_bun)} || {{ echo 'omp source mode: bun mount missing' >&2; exit 5; }}; " + f"test -f {q(cli)} || {{ echo 'omp source mode: repo mount missing' >&2; exit 5; }}; " + f"test -d {q(self._source_dir + '/node_modules/@oh-my-pi')} || " + "{ echo 'omp source mode: linux deps mount missing' >&2; exit 5; }; " + f"{q(self._source_bun)} --version" + ), + ) + return cli + async def _install_local(self, environment: BaseEnvironment) -> str: if not self._tarball: - raise RuntimeError("OMP_TB_INSTALL=local requires OMP_TB_TARBALL (host tarball path)") + raise RuntimeError("OMP_BENCH_INSTALL=local requires OMP_BENCH_TARBALL (host tarball path)") await environment.upload_file(self._tarball, _TARBALL_DST) app = f"{self._home}/.omp-bench/app" await self.exec_as_agent( @@ -363,7 +433,7 @@ class OmpLocal(BaseInstalledAgent): ) def _generate_models_yaml(self) -> str: - lines = ["# Generated by terminal-bench runner — routes auth via host gateway.", "providers:"] + lines = ["# Generated by harbor-manager runner — routes auth via host gateway.", "providers:"] for provider in self._gateway_providers: lines += [ f" {provider}:", @@ -375,25 +445,15 @@ class OmpLocal(BaseInstalledAgent): return "\n".join(lines) async def _write_config(self, environment: BaseEnvironment) -> None: - """Write $HOME/.omp/agent/config.yml: web_search toggle + optional advisor. + """Write $HOME/.omp/agent/config.yml: the web_search toggle. - The advisor is a separate model with its own spend; its turns are written - to /__advisor.jsonl (requires a persisted session, see run()). web_search can't authenticate through the gateway, so it's off by default. """ lines = [ - "# Generated by terminal-bench runner.", + "# Generated by harbor-manager runner.", "web_search:", f" enabled: {'true' if self._web_search else 'false'}", ] - if self._advisor_model: - lines += [ - "modelRoles:", - f" advisor: {self._advisor_model}", - "advisor:", - " enabled: true", - f' syncBacklog: "{self._advisor_sync}"', - ] content = "\n".join(lines) heredoc = f"cat > {_CONFIG_DST} <<'OMP_CONFIG_EOF'\n{content}\nOMP_CONFIG_EOF" await self.exec_as_agent(environment, command=heredoc) @@ -407,8 +467,8 @@ class OmpLocal(BaseInstalledAgent): @staticmethod def _parse_forward_env() -> dict[str, str]: - """Extra run-time env from the runner (OMP_TB_FORWARD_ENV = JSON object).""" - raw = _env("OMP_TB_FORWARD_ENV") + """Extra run-time env from the runner (OMP_BENCH_FORWARD_ENV = JSON object).""" + raw = _env("OMP_BENCH_FORWARD_ENV") if not raw: return {} try: @@ -419,17 +479,27 @@ class OmpLocal(BaseInstalledAgent): return {} return {str(key): str(value) for key, value in parsed.items()} + @staticmethod + def _parse_agent_args() -> list[str]: + """Extra CLI args from the runner (OMP_BENCH_AGENT_ARGS = JSON array).""" + raw = _env("OMP_BENCH_AGENT_ARGS") + if not raw: + return [] + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if not isinstance(parsed, list): + return [] + return [str(item) for item in parsed] + def _collect_provider_keys(self, provider: str) -> dict[str, str]: - """Host env vars for the primary + advisor providers (direct-auth mode).""" - providers = {provider} - if self._advisor_model and "/" in self._advisor_model: - providers.add(self._advisor_model.split("/", 1)[0]) + """Host env vars for the primary model's provider (direct-auth mode only).""" env: dict[str, str] = {} - for prov in providers: - for key in _PROVIDER_KEYS.get(prov, []): - value = os.environ.get(key) - if value: - env[key] = value + for key in _PROVIDER_KEYS.get(provider, []): + value = os.environ.get(key) + if value: + env[key] = value return env # ---------------------------------------------------------------------- run @@ -455,17 +525,13 @@ class OmpLocal(BaseInstalledAgent): "--mode json", f"--provider {shlex.quote(provider)}", f"--model {shlex.quote(model)}", + "--no-session", ] - # The advisor records its (separately-billed) turns to /__advisor.jsonl, - # which only exists with a persisted session — so keep sessions on for advisor runs. - if not self._advisor_model: - parts.append("--no-session") if self._auto_approve: parts.append("--auto-approve") if self._thinking: parts.append(f"--thinking {shlex.quote(self._thinking)}") - if self._extra_args: - parts.append(self._extra_args) + parts.extend(shlex.quote(arg) for arg in self._agent_args) # POSIX positional separator: some task prompts start with "-" (e.g. a # markdown bullet, as in pytorch-model-recovery). Without this, omp parses # the prompt as an unknown flag and exits 2. `--` forces positional mode. @@ -474,13 +540,6 @@ class OmpLocal(BaseInstalledAgent): # No pipes/stdbuf (absent in minimal images): redirect raw JSONL to the # mounted agent log dir; populate_context_post_run parses it on the host. run = " ".join(parts) + f" > /logs/agent/{_OUTPUT_FILENAME} 2>&1" - if self._advisor_model: - # Preserve omp's exit code, then collect advisor spend into the mounted dir. - run += ( - "; rc=$?; " - f'find "$HOME/.omp/agent/sessions" -name __advisor.jsonl -exec cat {{}} + ' - f"> /logs/agent/{_ADVISOR_FILENAME} 2>/dev/null || true; exit $rc" - ) # Exec env for the omp run. Direct-auth (no-gateway) mode contributes the # selected providers' keys (via exec env, never argv); forwarded PI_* / # --env knobs apply last so an explicit --env always wins. @@ -494,21 +553,15 @@ class OmpLocal(BaseInstalledAgent): def populate_context_post_run(self, context: AgentContext) -> None: main = _Usage() self._sum_main(self.logs_dir / _OUTPUT_FILENAME, main) - advisor = _Usage() - if self._advisor_model: - self._sum_advisor(self.logs_dir / _ADVISOR_FILENAME, advisor) - if main.empty() and advisor.empty(): + if main.empty(): return - total_cost = main.cost + advisor.cost - context.n_input_tokens = main.in_tok + main.cache_read + advisor.in_tok + advisor.cache_read - context.n_output_tokens = main.out_tok + advisor.out_tok - context.n_cache_tokens = main.cache_read + advisor.cache_read - context.cost_usd = total_cost if total_cost > 0 else None + context.n_input_tokens = main.in_tok + main.cache_read + context.n_output_tokens = main.out_tok + context.n_cache_tokens = main.cache_read + context.cost_usd = main.cost if main.cost > 0 else None context.metadata = { **(context.metadata or {}), - "cache_write_tokens": main.cache_write + advisor.cache_write, - "main_cost_usd": main.cost, - "advisor_cost_usd": advisor.cost, + "cache_write_tokens": main.cache_write, } def _sum_main(self, path: Path, acc: "_Usage") -> None: @@ -522,19 +575,3 @@ class OmpLocal(BaseInstalledAgent): message = event.get("message") if isinstance(message, dict) and message.get("role") == "assistant": acc.add(message.get("usage")) - - def _sum_advisor(self, path: Path, acc: "_Usage") -> None: - """Sum assistant-turn usage from concatenated __advisor.jsonl session entries.""" - if not path.exists(): - return - for line in path.read_text(errors="replace").splitlines(): - entry = _loads(line) - if not entry: - continue - # Session-tree entries are flat: {role: "assistant", usage: {...}}. - if entry.get("role") == "assistant": - acc.add(entry.get("usage")) - else: - message = entry.get("message") - if isinstance(message, dict) and message.get("role") == "assistant": - acc.add(message.get("usage")) diff --git a/packages/harbor-manager/package.json b/packages/harbor-manager/package.json new file mode 100644 index 000000000..d6e7af6e7 --- /dev/null +++ b/packages/harbor-manager/package.json @@ -0,0 +1,55 @@ +{ + "type": "module", + "private": true, + "name": "@oh-my-pi/harbor-manager", + "version": "0.0.1", + "description": "Unified benchmark runners plus Harbor run storage, REST/SSE APIs, and a live web dashboard", + "homepage": "https://omp.sh", + "author": "Can Boluk", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/harbor-manager" + }, + "bin": { + "harbor-manager": "src/server.ts" + }, + "scripts": { + "check": "biome check . && bun run check:types", + "check:types": "tsgo -p tsconfig.json --noEmit && tsgo -p adapters/edit/tsconfig.json --noEmit", + "lint": "biome lint .", + "start": "bun run src/server.ts", + "serve": "bun run src/server.ts", + "dev": "bun scripts/dev.ts", + "test": "bun test" + }, + "dependencies": { + "@oh-my-pi/hashline": "catalog:", + "@oh-my-pi/pi-agent-core": "catalog:", + "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-coding-agent": "catalog:", + "@oh-my-pi/pi-utils": "catalog:", + "@oh-my-pi/typescript-edit-benchmark": "workspace:*", + "clsx": "^2.1.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "diff": "catalog:", + "motion": "^12.15.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.3.0" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.4", + "vite": "catalog:" + }, + "engines": { + "bun": ">=1.3.14" + } +} diff --git a/packages/harbor-manager/scripts/dev.ts b/packages/harbor-manager/scripts/dev.ts new file mode 100755 index 000000000..5cd7c102b --- /dev/null +++ b/packages/harbor-manager/scripts/dev.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env bun +/** + * Dev harness: runs the Bun API server (auto-restarting on server edits via + * `--watch`) and a Vite dev server (React Fast Refresh for the dashboard) + * together, tearing both down on one Ctrl-C. Vite proxies `/api` to the API + * server; the shared port travels through `HARBOR_API_PORT`. + * + * Extra args pass through to the API server: + * bun run dev -- --port 4700 --jobs-dir ../../runs/harbor + * + * Vite runs under Node (its bin shebang), the API under Bun; only the frontend + * hot-reloads in place, while server-side changes trigger a fast `--watch` restart. + */ +const args = Bun.argv.slice(2); +const portIndex = args.indexOf("--port"); +const apiPort = portIndex >= 0 ? (args[portIndex + 1] ?? "4700") : "4700"; +process.env.HARBOR_API_PORT = apiPort; + +const io = { stdout: "inherit", stderr: "inherit", stdin: "inherit", env: { ...process.env } } as const; +const api = Bun.spawn(["bun", "--watch", "src/server.ts", ...args], io); +const web = Bun.spawn(["vite"], io); + +let stopping = false; +const stop = (): void => { + if (stopping) return; + stopping = true; + try { + api.kill(); + } catch {} + try { + web.kill(); + } catch {} +}; +process.on("SIGINT", stop); +process.on("SIGTERM", stop); + +await Promise.race([api.exited, web.exited]); +stop(); +process.exit(0); diff --git a/packages/snapcompact/research/run.py b/packages/harbor-manager/src/adapters/snapcompact.py similarity index 93% rename from packages/snapcompact/research/run.py rename to packages/harbor-manager/src/adapters/snapcompact.py index efc570d75..b45daf69b 100644 --- a/packages/snapcompact/research/run.py +++ b/packages/harbor-manager/src/adapters/snapcompact.py @@ -36,6 +36,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path HERE = Path(__file__).resolve().parent +RESEARCH = HERE.parents[2] / "snapcompact" / "research" def find_agent_prompts() -> Path: for parent in HERE.parents: @@ -48,16 +49,16 @@ def find_agent_prompts() -> Path: raise FileNotFoundError("Could not find agent compaction prompts") -sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(RESEARCH)) import squad # noqa: E402 from anthropic_api import complete, image_block, load_api_key # noqa: E402 from bdf import VARIANTS, FontCfg, capacity, render # noqa: E402 AGENT_PROMPTS = find_agent_prompts() -CACHE = HERE / ".cache" +CACHE = RESEARCH / ".cache" QA_CACHE = CACHE / "qa" -RESULTS = HERE / "results" +RESULTS = RESEARCH / "results" FONTS = { "8x13": FontCfg("8x13", "8x13", 8, 13), @@ -100,7 +101,7 @@ def sha8(*parts: str) -> str: def load_prompt(name: str) -> str: - return (HERE / "prompts" / name).read_text() + return (RESEARCH / "prompts" / name).read_text() def agent_prompt(name: str) -> str: @@ -276,6 +277,7 @@ def main() -> None: ap.add_argument("--price-in", type=float, default=10.0, help="$ per 1M input tokens") ap.add_argument("--price-out", type=float, default=50.0, help="$ per 1M output tokens") ap.add_argument("--env", default="~/.env") + ap.add_argument("--output-dir", help="write records.jsonl and summary.json directly to this directory") args = ap.parse_args() CACHE.mkdir(exist_ok=True) @@ -288,7 +290,11 @@ def main() -> None: f"-e{args.effort}" if args.effort else "", ] ) - run_dir = RESULTS / f"{args.model}-seed{args.seed}-qpc{args.qpc}-{scope}{tag}" + run_dir = ( + Path(args.output_dir).expanduser().resolve() + if args.output_dir + else RESULTS / f"{args.model}-seed{args.seed}-qpc{args.qpc}-{scope}{tag}" + ) run_dir.mkdir(parents=True, exist_ok=True) paras = squad.load_paragraphs(CACHE) @@ -312,17 +318,18 @@ def main() -> None: ctx_args = {"args": args, "flow": flow, "paras": paras, "offsets": offsets, "api_key": api_key} records: list[dict] = [] done = 0 - with ThreadPoolExecutor(args.workers) as pool: - futures = [pool.submit(run_chunk, cond, start, end, ctx_args) for cond, start, end in tasks] - for fut in futures: - records.extend(fut.result()) - done += 1 - if done % 20 == 0: - print(f" {done}/{len(tasks)} chunks", flush=True) - - with (run_dir / "records.jsonl").open("w") as fh: - for r in records: - fh.write(json.dumps(r) + "\n") + with (run_dir / "records.jsonl").open("w") as records_file: + with ThreadPoolExecutor(args.workers) as pool: + futures = [pool.submit(run_chunk, cond, start, end, ctx_args) for cond, start, end in tasks] + for fut in futures: + chunk_records = fut.result() + records.extend(chunk_records) + for record in chunk_records: + records_file.write(json.dumps(record) + "\n") + records_file.flush() + done += 1 + if done % 20 == 0: + print(f" {done}/{len(tasks)} chunks", flush=True) rows = [ aggregate(cond["name"], [r for r in records if r["cond"] == cond["name"]], args.price_in, args.price_out) diff --git a/packages/harbor-manager/src/benchmarks.test.ts b/packages/harbor-manager/src/benchmarks.test.ts new file mode 100644 index 000000000..d7a00018a --- /dev/null +++ b/packages/harbor-manager/src/benchmarks.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { BENCHMARK_DEFINITIONS, readBenchmarkSnapshot } from "./benchmarks"; + +const cleanups: string[] = []; + +function jobDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harbor-benchmark-")); + cleanups.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of cleanups.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("benchmark adapters", () => { + it("normalizes edit attempts, traces, tokens, and declared metrics", () => { + const dir = jobDir(); + fs.writeFileSync( + path.join(dir, "result.json"), + JSON.stringify({ + tasks: [ + { + id: "rename-symbol", + name: "Rename symbol", + runs: [ + { + runIndex: 0, + success: true, + duration: 1200, + tokens: { input: 100, output: 20, reasoning: 5 }, + }, + ], + }, + ], + summary: { + totalRuns: 1, + successfulRuns: 1, + taskSuccessRate: 1, + editSuccessRate: 0.75, + totalTokens: { input: 100, output: 20 }, + }, + }), + ); + + const snapshot = readBenchmarkSnapshot("edit", dir); + expect(snapshot.metrics).toEqual({ task_success_rate: 1, edit_success_rate: 0.75 }); + expect(snapshot.traces[0]).toMatchObject({ + name: "rename-symbol__1", + status: "pass", + tracePath: path.join("result.dump", "rename-symbol", "run-1.md"), + }); + expect([snapshot.tokIn, snapshot.tokOut]).toEqual([100, 20]); + }); + + it("normalizes SnapCompact records and weighted quality metrics", () => { + const dir = jobDir(); + fs.writeFileSync( + path.join(dir, "records.jsonl"), + `${JSON.stringify({ cond: "text", chunk: 0, pos_rel: 0.2, q: "q", answer: "a", golds: ["a"], em: 1, f1: 1 })}\n`, + ); + fs.writeFileSync( + path.join(dir, "summary.json"), + JSON.stringify({ + rows: [ + { n: 2, f1: 0.75, em: 0.5, cost_usd: 1.25, tokens_in: 200, tokens_out: 30, cache_w: 10, cache_r: 20 }, + ], + }), + ); + + const snapshot = readBenchmarkSnapshot("snapcompact", dir); + expect(snapshot.metrics).toEqual({ f1: 0.75, exact_match: 0.5 }); + expect(snapshot.traces[0]).toMatchObject({ status: "pass", reward: 1, tracePath: "record:1" }); + expect(snapshot.costUsd).toBe(1.25); + expect(snapshot.tokCache).toBe(30); + }); + + it("publishes metric definitions for every managed benchmark", () => { + expect(BENCHMARK_DEFINITIONS.map(definition => definition.kind)).toEqual(["harbor", "edit", "snapcompact"]); + expect(BENCHMARK_DEFINITIONS.every(definition => definition.metrics.length > 0)).toBe(true); + }); +}); diff --git a/packages/harbor-manager/src/benchmarks.ts b/packages/harbor-manager/src/benchmarks.ts new file mode 100644 index 000000000..93523ddfa --- /dev/null +++ b/packages/harbor-manager/src/benchmarks.ts @@ -0,0 +1,273 @@ +/** Benchmark adapters normalize native artifacts into manager runs and traces. */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { aggregate, readJobResult, readTrials } from "./runner"; +import type { BenchmarkKind } from "./store"; + +/** Describes a benchmark metric so storage and UI do not hard-code benchmark semantics. */ +export interface MetricDefinition { + key: string; + label: string; + format: "percent" | "number" | "usd"; + higherIsBetter: boolean; +} + +/** Adapter metadata exposed to launch clients and the dashboard. */ +export interface BenchmarkDefinition { + kind: BenchmarkKind; + label: string; + metrics: MetricDefinition[]; +} + +/** Built-in benchmark adapters and their native score definitions. */ +export const BENCHMARK_DEFINITIONS: BenchmarkDefinition[] = [ + { + kind: "harbor", + label: "Harbor", + metrics: [{ key: "success_rate", label: "Success rate", format: "percent", higherIsBetter: true }], + }, + { + kind: "edit", + label: "TypeScript edit", + metrics: [ + { key: "task_success_rate", label: "Task success", format: "percent", higherIsBetter: true }, + { key: "edit_success_rate", label: "Edit success", format: "percent", higherIsBetter: true }, + ], + }, + { + kind: "snapcompact", + label: "SnapCompact", + metrics: [ + { key: "f1", label: "F1", format: "percent", higherIsBetter: true }, + { key: "exact_match", label: "Exact match", format: "percent", higherIsBetter: true }, + ], + }, +]; + +/** A normalized trace emitted by any benchmark adapter. */ +export interface BenchmarkTrace { + name: string; + task: string; + status: "pass" | "fail" | "error" | "running"; + reward: number | null; + costUsd: number; + durationMs: number; + detail: string; + tracePath: string | null; +} + +/** Uniform aggregate and traces read from benchmark-native artifacts. */ +export interface BenchmarkSnapshot { + traces: BenchmarkTrace[]; + total: number; + done: number; + pass: number; + fail: number; + error: number; + running: number; + costUsd: number; + tokIn: number; + tokOut: number; + tokCache: number; + score: number | null; + metrics: Record; +} + +interface EditRun { + runIndex: number; + success: boolean; + error?: string; + duration: number; + tokens: { input: number; output: number; reasoning: number }; + toolCalls?: { read: number; edit: number; write: number }; +} + +interface EditTask { + id: string; + name: string; + runs: EditRun[]; +} + +interface EditResult { + tasks: EditTask[]; + summary: { + totalRuns: number; + successfulRuns: number; + taskSuccessRate: number; + editSuccessRate: number; + totalTokens: { input: number; output: number }; + }; +} + +interface SnapRecord { + cond: string; + chunk: number; + pos_rel: number; + q: string; + answer: string; + golds: string[]; + em: number; + f1: number; +} + +interface SnapSummaryRow { + n: number; + f1: number; + em: number; + cost_usd: number; + tokens_in: number; + tokens_out: number; + cache_w: number; + cache_r: number; +} + +interface SnapSummary { + rows: SnapSummaryRow[]; +} + +function emptySnapshot(): BenchmarkSnapshot { + return { + traces: [], + total: 0, + done: 0, + pass: 0, + fail: 0, + error: 0, + running: 0, + costUsd: 0, + tokIn: 0, + tokOut: 0, + tokCache: 0, + score: null, + metrics: {}, + }; +} + +function readEditSnapshot(jobDir: string): BenchmarkSnapshot { + const file = path.join(jobDir, "result.json"); + if (!fs.existsSync(file)) return emptySnapshot(); + const result: EditResult = JSON.parse(fs.readFileSync(file, "utf8")); + const traces: BenchmarkTrace[] = []; + let tokIn = 0; + let tokOut = 0; + for (const task of result.tasks) { + for (const run of task.runs) { + tokIn += run.tokens.input; + tokOut += run.tokens.output; + const runNumber = run.runIndex + 1; + traces.push({ + name: `${task.id}__${runNumber}`, + task: task.id, + status: run.success ? "pass" : run.error ? "error" : "fail", + reward: run.success ? 1 : 0, + costUsd: 0, + durationMs: run.duration, + detail: JSON.stringify({ name: task.name, error: run.error ?? null, tools: run.toolCalls ?? null }), + tracePath: path.join("result.dump", task.id.replace(/[^a-zA-Z0-9._-]/g, "_"), `run-${runNumber}.md`), + }); + } + } + const pass = traces.filter(trace => trace.status === "pass").length; + const error = traces.filter(trace => trace.status === "error").length; + return { + traces, + total: result.summary.totalRuns, + done: traces.length, + pass, + fail: traces.length - pass, + error, + running: Math.max(0, result.summary.totalRuns - traces.length), + costUsd: 0, + tokIn, + tokOut, + tokCache: 0, + score: result.summary.taskSuccessRate, + metrics: { + task_success_rate: result.summary.taskSuccessRate, + edit_success_rate: result.summary.editSuccessRate, + }, + }; +} + +function readSnapcompactSnapshot(jobDir: string): BenchmarkSnapshot { + const recordsFile = path.join(jobDir, "records.jsonl"); + const summaryFile = path.join(jobDir, "summary.json"); + if (!fs.existsSync(recordsFile)) return emptySnapshot(); + const records = fs + .readFileSync(recordsFile, "utf8") + .split("\n") + .filter(Boolean) + .map(line => JSON.parse(line) as SnapRecord); + const traces = records.map( + (record, index): BenchmarkTrace => ({ + name: `${record.cond}__${record.chunk}__${index + 1}`, + task: `${record.cond}:${record.chunk}`, + status: record.f1 > 0 ? "pass" : "fail", + reward: record.f1, + costUsd: 0, + durationMs: 0, + detail: JSON.stringify({ question: record.q, answer: record.answer, golds: record.golds, em: record.em }), + tracePath: `record:${index + 1}`, + }), + ); + let rows: SnapSummaryRow[] = []; + if (fs.existsSync(summaryFile)) { + const summary: SnapSummary = JSON.parse(fs.readFileSync(summaryFile, "utf8")); + rows = summary.rows; + } + const samples = rows.reduce((sum, row) => sum + row.n, 0); + const weightedF1 = rows.reduce((sum, row) => sum + row.f1 * row.n, 0); + const weightedEm = rows.reduce((sum, row) => sum + row.em * row.n, 0); + const pass = traces.filter(trace => trace.status === "pass").length; + return { + traces, + total: traces.length, + done: traces.length, + pass, + fail: traces.length - pass, + error: 0, + running: 0, + costUsd: rows.reduce((sum, row) => sum + row.cost_usd, 0), + tokIn: rows.reduce((sum, row) => sum + row.tokens_in, 0), + tokOut: rows.reduce((sum, row) => sum + row.tokens_out, 0), + tokCache: rows.reduce((sum, row) => sum + row.cache_w + row.cache_r, 0), + score: samples > 0 ? weightedF1 / samples : null, + metrics: { + f1: samples > 0 ? weightedF1 / samples : null, + exact_match: samples > 0 ? weightedEm / samples : null, + }, + }; +} + +/** Read and normalize the latest artifacts for a benchmark run. */ +export function readBenchmarkSnapshot(benchmark: BenchmarkKind, jobDir: string): BenchmarkSnapshot { + if (benchmark === "edit") return readEditSnapshot(jobDir); + if (benchmark === "snapcompact") return readSnapcompactSnapshot(jobDir); + const trials = readTrials(jobDir); + const job = readJobResult(jobDir); + const totals = aggregate(trials, job, job?.nTotal ?? trials.length); + return { + traces: trials.map(trial => ({ + name: trial.name, + task: trial.name.replace(/__[^_]+$/, ""), + status: trial.status, + reward: trial.reward, + costUsd: trial.costUsd, + durationMs: trial.durationMs, + detail: trial.detail, + tracePath: path.join(trial.name, "agent", "omp.txt"), + })), + total: totals.total, + done: totals.done, + pass: totals.pass, + fail: totals.fail, + error: totals.error, + running: totals.running, + costUsd: totals.costUsd, + tokIn: totals.tokIn, + tokOut: totals.tokOut, + tokCache: totals.tokCache, + score: totals.done > 0 ? totals.pass / totals.done : null, + metrics: { success_rate: totals.done > 0 ? totals.pass / totals.done : null }, + }; +} diff --git a/packages/harbor-manager/src/experiments.test.ts b/packages/harbor-manager/src/experiments.test.ts new file mode 100644 index 000000000..cd0e8f630 --- /dev/null +++ b/packages/harbor-manager/src/experiments.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "bun:test"; +import { + armOf, + calibratedFinalPassPct, + canonicalArmOf, + experimentOf, + pickMergedTrials, + summarizeArm, +} from "./experiments"; +import type { RunRow, TraceRow } from "./store"; + +/** + * Contracts under test: + * - job names group by their first `-` token; arm labels strip that prefix. + * - summarizeArm computes observed metrics from decided trials only and + * projects running arms linearly (ETA, pass%, total cost). + */ + +function runRow(overrides: Partial): RunRow { + return { + benchmark: "harbor", + jobName: "exp-arm", + dataset: "d", + agent: "omp", + models: "anthropic/claude-opus-4-8", + label: "", + downshift: null, + config: {}, + role: "", + note: "", + status: "running", + pid: null, + exitCode: null, + createdAt: Date.now(), + finishedAt: null, + nTotal: 0, + done: 0, + pass: 0, + fail: 0, + error: 0, + running: 0, + costUsd: 0, + tokIn: 0, + tokOut: 0, + tokCache: 0, + score: null, + metrics: {}, + ...overrides, + }; +} + +function traceRow(overrides: Partial): TraceRow { + return { + jobName: "exp-arm", + name: "task__x", + task: "task", + status: "pass", + reward: 1, + costUsd: 1, + durationMs: 60_000, + detail: "", + updatedAt: Date.now(), + tracePath: null, + ...overrides, + }; +} + +describe("experiment grouping", () => { + it("groups by prefix and strips it from arm labels", () => { + expect(experimentOf("sb2-n4p-fix")).toBe("sb2"); + expect(armOf("sb2-n4p-fix")).toBe("n4p-fix"); + expect(experimentOf("standalone")).toBe("standalone"); + expect(armOf("standalone")).toBe("standalone"); + }); +}); + +describe("summarizeArm", () => { + it("computes observed and projected stats from decided trials, not total spend", () => { + const tenMinutesAgo = Date.now() - 10 * 60_000; + // 10 decided trials (8 pass / 2 fail) at $0.50 each; run.costUsd = $15 + // includes in-flight spend that must NOT inflate $/task. + const trials = Array.from({ length: 10 }, (_, i) => + traceRow({ + name: `t${i}__x`, + task: `t${i}`, + status: i < 8 ? "pass" : "fail", + reward: i < 8 ? 1 : 0, + costUsd: 0.5, + durationMs: 120_000, + }), + ); + const running = summarizeArm( + runRow({ + jobName: "sb2-n8", + status: "running", + createdAt: tenMinutesAgo, + nTotal: 20, + done: 10, + pass: 8, + costUsd: 15, + }), + trials, + ); + expect(running.arm).toBe("n8"); + expect(running.projected).not.toBeNull(); + // 10 decided in 10 min → 1/min → 10 remaining ≈ 10 min out. + const etaMin = ((running.projected?.etaMs ?? 0) - Date.now()) / 60_000; + expect(etaMin).toBeGreaterThan(8); + expect(etaMin).toBeLessThan(12); + expect(running.projected?.passPct).toBeCloseTo(80, 5); + // $/task from decided trials ($0.50), not costUsd/done ($1.50). + expect(running.costPerTask).toBeCloseTo(0.5, 5); + expect(running.projected?.costPerTask).toBeCloseTo(0.5, 5); + // Projected total = committed spend + decided-rate estimate of the rest. + expect(running.projected?.totalCostUsd).toBeCloseTo(15 + 0.5 * 10, 5); + expect(running.passPct).toBeCloseTo(80, 5); + expect(running.meanTrialMs).toBeCloseTo(120_000, 5); + + const finished = summarizeArm( + runRow({ jobName: "sb2-opus", status: "complete", nTotal: 20, done: 20, pass: 15, costUsd: 30 }), + [traceRow({})], + ); + expect(finished.projected).toBeNull(); + // Decided-trace cost ($1 trial), not costUsd/done. + expect(finished.costPerTask).toBeCloseTo(1, 5); + }); + + it("describes the downshift config in the arm line", () => { + const arm = summarizeArm( + runRow({ + jobName: "sb2-nact", + downshift: JSON.stringify({ into: "google/gemini-3.5-flash" }), + }), + [], + ); + expect(arm.config).toBe("harbor · anthropic/claude-opus-4-8 → google/gemini-3.5-flash at first action"); + }); + + it("still labels legacy reasoning-slide rows", () => { + const arm = summarizeArm( + runRow({ + jobName: "sb2-nact", + downshift: JSON.stringify({ model: "google/gemini-3.5-flash", onAction: true, plan: true }), + }), + [], + ); + expect(arm.config).toBe("harbor · anthropic/claude-opus-4-8 → google/gemini-3.5-flash on first edit/write +plan"); + }); +}); + +describe("calibratedFinalPassPct", () => { + const sib = (entries: Array<[string, number, number]>) => + new Map(entries.map(([task, passes, decided]) => [task, { passes, decided }])); + + it("discounts a perfect score earned on tasks every sibling also passes", () => { + // Arm decided 3 easy tasks (5/5 siblings pass) at 100%; the remaining 5 + // tasks are hard (0/5 siblings pass). Naive projection says 100%. + const projected = calibratedFinalPassPct({ + decided: [ + { task: "e1", passed: true }, + { task: "e2", passed: true }, + { task: "e3", passed: true }, + ], + siblings: sib([ + ["e1", 5, 5], + ["e2", 5, 5], + ["e3", 5, 5], + ["h1", 0, 5], + ["h2", 0, 5], + ["h3", 0, 5], + ["h4", 0, 5], + ["h5", 0, 5], + ]), + remaining: ["h1", "h2", "h3", "h4", "h5"], + nTotal: 8, + }); + expect(projected).not.toBeNull(); + // Far below naive 100%: the hard remainder dominates. + expect(projected as number).toBeLessThan(70); + // But never below what it already banked (3/8 = 37.5%). + expect(projected as number).toBeGreaterThanOrEqual(37.5); + }); + + it("projects the sibling mean when the arm performs exactly at sibling level", () => { + // Arm decided a representative half: passed the easy task, failed the + // hard one — exactly the sibling pattern. Projection ≈ sibling overall + // rate (~50%), matching what a difficulty-aware estimate must return. + const projected = calibratedFinalPassPct({ + decided: [ + { task: "e1", passed: true }, + { task: "h1", passed: false }, + ], + siblings: sib([ + ["e1", 4, 4], + ["h1", 0, 4], + ["e2", 4, 4], + ["h2", 0, 4], + ]), + remaining: ["e2", "h2"], + nTotal: 4, + }); + expect(projected).not.toBeNull(); + expect(projected as number).toBeGreaterThan(35); + expect(projected as number).toBeLessThan(65); + }); + + it("returns null with nothing decided to calibrate on", () => { + expect(calibratedFinalPassPct({ decided: [], siblings: new Map(), remaining: ["a"], nTotal: 4 })).toBeNull(); + }); +}); + +describe("re-run merging", () => { + it("strips stacked re-run suffixes down to the base arm", () => { + expect(canonicalArmOf("sb3-n4p2-fix")).toBe("n4p2"); + expect(canonicalArmOf("sb3-n4p2-fix2")).toBe("n4p2"); + expect(canonicalArmOf("sb3-planyolo2-fix2")).toBe("planyolo2"); + expect(canonicalArmOf("sb3-nact-backfill")).toBe("nact"); + expect(canonicalArmOf("sb3-nact-fix-retry2")).toBe("nact"); + // Not a re-run suffix — stays intact. + expect(canonicalArmOf("sb3-nbmrng")).toBe("nbmrng"); + expect(canonicalArmOf("sb2-opus48")).toBe("opus48"); + }); + + it("prefers decided re-runs over errors but never downgrades a decided result", () => { + const merged = pickMergedTrials([ + // errored in the base run, fixed by the re-run → fix wins + traceRow({ name: "a__1", task: "a", status: "error", reward: null, updatedAt: 100 }), + traceRow({ name: "a__2", task: "a", status: "pass", reward: 1, updatedAt: 50 }), + // decided twice → latest decided wins + traceRow({ name: "b__1", task: "b", status: "fail", reward: 0, updatedAt: 100 }), + traceRow({ name: "b__2", task: "b", status: "pass", reward: 1, updatedAt: 200 }), + // decided, then a later still-running retry → decided kept + traceRow({ name: "c__1", task: "c", status: "pass", reward: 1, updatedAt: 100 }), + traceRow({ name: "c__2", task: "c", status: "running", reward: null, updatedAt: 999 }), + ]); + const byTask = Object.fromEntries(merged.map(t => [t.task, t.name])); + expect(byTask).toEqual({ a: "a__2", b: "b__2", c: "c__1" }); + }); +}); diff --git a/packages/harbor-manager/src/experiments.ts b/packages/harbor-manager/src/experiments.ts new file mode 100644 index 000000000..9f19c97ef --- /dev/null +++ b/packages/harbor-manager/src/experiments.ts @@ -0,0 +1,351 @@ +/** + * Experiment layer: groups runs that share a job-name prefix (`sb2-n8`, + * `sb2-gemini` → experiment `sb2`) so comparable arms can be charted together, + * with linear projections for arms still in flight. + */ +import type { RunRow, RunStore, TraceRow } from "./store"; + +/** Linear extrapolation of a running arm to its full task count. */ +export interface ArmProjection { + /** Expected finish timestamp (ms epoch), from observed completion rate. */ + etaMs: number | null; + passPct: number; + costPerTask: number; + totalCostUsd: number; + meanTrialMs: number; +} + +export interface ArmSummary { + run: RunRow; + /** Arm label: job name minus the experiment prefix. */ + arm: string; + /** Human config line: models plus downshift description when known. */ + config: string; + /** Observed pass% over decided trials. */ + passPct: number | null; + costPerTask: number | null; + meanTrialMs: number | null; + /** Present only while the arm is running with at least one decided trial. */ + projected: ArmProjection | null; +} + +export interface ExperimentSummary { + id: string; + goal: string; + arms: number; + runningArms: number; + datasets: string[]; + nTotal: number; + done: number; + pass: number; + fail: number; + error: number; + costUsd: number; + createdAt: number; + updatedAt: number; +} + +export interface ExperimentDetail { + id: string; + goal: string; + arms: ArmSummary[]; + /** Union of task ids across arms, sorted. */ + tasks: string[]; + /** arm label → task → cell. */ + matrix: Record>; +} + +/** Experiment id = first `-`-delimited token of the job name. */ +export function experimentOf(jobName: string): string { + const dash = jobName.indexOf("-"); + return dash > 0 ? jobName.slice(0, dash) : jobName; +} + +/** Arm label = job name minus the experiment prefix (falls back to the full name). */ +export function armOf(jobName: string): string { + const exp = experimentOf(jobName); + return jobName.length > exp.length ? jobName.slice(exp.length + 1) : jobName; +} + +function downshiftLabel(downshiftJson: string | null): string { + if (!downshiftJson) return ""; + try { + // Historical rows may hold legacy reasoning-slide JSON ({model, turns, onAction, plan}). + const parsed = JSON.parse(downshiftJson) as { + into?: string; + model?: string; + turns?: number; + onAction?: boolean; + plan?: boolean; + }; + if (parsed.model) { + const trigger = parsed.onAction ? "on first edit/write" : `after ${parsed.turns} turns`; + return ` → ${parsed.model} ${trigger}${parsed.plan ? " +plan" : ""}`; + } + return ` → ${parsed.into ?? "smol"} at first action`; + } catch { + return ""; + } +} + +export function summarizeArm(run: RunRow, traces: TraceRow[]): ArmSummary { + // Every observed stat is computed over DECIDED trials only — numerator and + // denominator from the same population. `run.costUsd` includes in-flight + // trials' accumulating spend, so dividing it by the decided count wildly + // overstates $/task early in a run; per-trial trace costs don't. + const decided = traces.filter(t => t.status === "pass" || t.status === "fail" || t.status === "error"); + const durations = decided.filter(t => t.durationMs > 0).map(t => t.durationMs); + const meanTrialMs = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : null; + const decidedPass = decided.filter(t => t.status === "pass").length; + const decidedCost = decided.reduce((sum, t) => sum + (t.costUsd || 0), 0); + const passPct = decided.length > 0 ? (100 * decidedPass) / decided.length : null; + const costPerTask = decided.length > 0 ? decidedCost / decided.length : null; + + let projected: ArmProjection | null = null; + if (run.status === "running" && decided.length > 0 && run.nTotal > decided.length) { + const elapsed = Date.now() - run.createdAt; + const rate = decided.length / Math.max(elapsed, 1); + const remaining = run.nTotal - decided.length; + projected = { + etaMs: rate > 0 ? Date.now() + remaining / rate : null, + passPct: passPct ?? 0, + costPerTask: costPerTask ?? 0, + // Spend already committed plus the decided-rate estimate for what's left. + totalCostUsd: run.costUsd + (decidedCost / decided.length) * remaining, + meanTrialMs: meanTrialMs ?? 0, + }; + } + return { + run, + arm: armOf(run.jobName), + config: `${run.benchmark} · ${run.models}${downshiftLabel(run.downshift)}`, + passPct, + costPerTask, + meanTrialMs, + projected, + }; +} + +/** + * Difficulty-calibrated final pass-rate projection for a running arm. + * + * Naive extrapolation (observed pass% → whole run) is wrong whenever the + * decided subset isn't difficulty-representative: an arm that has so far only + * decided tasks every sibling also passes should NOT project its 100%. This + * uses every sibling result as a per-task difficulty signal (a one-parameter + * Rasch-style fit): + * + * 1. Task difficulty: smoothed sibling pass rate p_t = (passes+1)/(n+2). + * 2. Arm skill: a single log-odds shift `b`, moment-matched on the DECIDED + * tasks so that Σ σ(logit(p_t)+b) equals the arm's actual pass count. + * 3. Projection: score the REMAINING tasks through σ(logit(p_t)+b); tasks + * beyond the sibling union (no signal) score at the mean difficulty. + * + * Returns the projected final pass percentage over `nTotal`, or null when the + * arm has no reward-decided trials to calibrate on. + */ +export function calibratedFinalPassPct(options: { + /** This arm's reward-decided outcomes. */ + decided: Array<{ task: string; passed: boolean }>; + /** Per-task decided outcomes across sibling arms. */ + siblings: Map; + /** Tasks this arm has not decided yet. */ + remaining: string[]; + /** Full sample size to project over. */ + nTotal: number; +}): number | null { + const { decided, siblings, remaining, nTotal } = options; + if (decided.length === 0 || nTotal <= 0) return null; + const sigma = (x: number): number => 1 / (1 + Math.exp(-x)); + const smoothed = (s: { passes: number; decided: number } | undefined): number | null => + s && s.decided > 0 ? (s.passes + 1) / (s.decided + 2) : null; + const known = [...siblings.values()].map(s => smoothed(s)).filter((p): p is number => p !== null); + const meanP = known.length > 0 ? known.reduce((a, b) => a + b, 0) / known.length : 0.5; + // Clamped logit keeps unanimous tasks from saturating the fit. + const logit = (p: number): number => Math.max(-4, Math.min(4, Math.log(p / (1 - p)))); + const decidedLogits = decided.map(d => logit(smoothed(siblings.get(d.task)) ?? meanP)); + const passes = decided.filter(d => d.passed).length; + + // Moment-match the skill shift on the decided set (monotone → bisection). + // One pseudo-task of mean difficulty, "passed" at the sibling base rate, + // shrinks the fit toward sibling-average skill — a perfect (or zero) + // decided record would otherwise drive the shift to ±∞ (separation) and + // project near-certainty everywhere. + const fitLogits = [...decidedLogits, logit(meanP)]; + const target = passes + meanP; + let lo = -6; + let hi = 6; + for (let i = 0; i < 50; i++) { + const mid = (lo + hi) / 2; + const expected = fitLogits.reduce((sum, l) => sum + sigma(l + mid), 0); + if (expected < target) lo = mid; + else hi = mid; + } + const b = (lo + hi) / 2; + + const remainingKnown = remaining.map(task => sigma(logit(smoothed(siblings.get(task)) ?? meanP) + b)); + const padCount = Math.max(0, nTotal - decided.length - remaining.length); + const expectedRemaining = remainingKnown.reduce((a, x) => a + x, 0) + padCount * sigma(logit(meanP) + b); + return (100 * (passes + expectedRemaining)) / nTotal; +} + +export function buildExperiments(store: RunStore): ExperimentSummary[] { + const groups = new Map(); + for (const run of store.listRuns()) { + const id = experimentOf(run.jobName); + let bucket = groups.get(id); + if (!bucket) { + bucket = []; + groups.set(id, bucket); + } + bucket.push(run); + } + const out: ExperimentSummary[] = []; + for (const [id, runs] of groups) { + out.push({ + id, + goal: store.getExperimentGoal(id), + arms: runs.length, + runningArms: runs.filter(r => r.status === "running").length, + datasets: [...new Set(runs.map(r => r.dataset).filter(Boolean))], + nTotal: runs.reduce((a, r) => a + r.nTotal, 0), + done: runs.reduce((a, r) => a + r.done, 0), + pass: runs.reduce((a, r) => a + r.pass, 0), + fail: runs.reduce((a, r) => a + r.fail, 0), + error: runs.reduce((a, r) => a + r.error, 0), + costUsd: runs.reduce((a, r) => a + r.costUsd, 0), + createdAt: Math.min(...runs.map(r => r.createdAt)), + updatedAt: Math.max(...runs.map(r => r.finishedAt ?? Date.now())), + }); + } + out.sort((a, b) => b.updatedAt - a.updatedAt); + return out; +} + +/** `-fix`/`-backfill`/`-retry` (optionally numbered) re-run suffixes that fold into the base arm. */ +const RERUN_SUFFIX = /-(fix|backfill|refill|retry|rerun|bf)\d*$/i; + +/** Arm label with re-run suffixes stripped: `n4p2-fix2` and `n4p2-backfill` both merge into `n4p2`. */ +export function canonicalArmOf(jobName: string): string { + let arm = armOf(jobName); + for (;;) { + const next = arm.replace(RERUN_SUFFIX, ""); + if (next === arm || next.length === 0) return arm; + arm = next; + } +} + +/** + * Collapse re-run trials onto one row per task: a reward-decided trial always + * beats an undecided one (error/running), and within the same class the + * latest update wins — so a `-fix` re-run of an errored task replaces the + * error, but never a genuine earlier pass/fail... unless it is itself decided + * and newer. + */ +export function pickMergedTrials(traces: TraceRow[]): TraceRow[] { + const byTask = new Map(); + const decided = (t: TraceRow): boolean => t.status === "pass" || t.status === "fail"; + for (const t of traces) { + const cur = byTask.get(t.task); + if (!cur) { + byTask.set(t.task, t); + continue; + } + const wins = decided(t) === decided(cur) ? t.updatedAt >= cur.updatedAt : decided(t); + if (wins) byTask.set(t.task, t); + } + return [...byTask.values()]; +} + +export function experimentDetail(store: RunStore, id: string): ExperimentDetail | null { + const runs = store.listRuns().filter(r => experimentOf(r.jobName) === id); + if (runs.length === 0) return null; + // One row per CANONICAL arm: `-fix`/`-backfill` re-runs merge into their + // base arm — per-task best trial, summed spend. + const groups = new Map(); + for (const run of runs) { + const key = canonicalArmOf(run.jobName); + const bucket = groups.get(key); + if (bucket) bucket.push(run); + else groups.set(key, [run]); + } + const arms: ArmSummary[] = []; + const matrix: ExperimentDetail["matrix"] = {}; + const tasks = new Set(); + for (const [canonical, members] of groups) { + members.sort((a, b) => a.createdAt - b.createdAt); + const base = members.find(m => armOf(m.jobName) === canonical) ?? members[0]; + const armLabel = base.label || canonical; + const merged = pickMergedTrials(members.flatMap(m => store.listTraces(m.jobName))); + const running = members.some(m => m.status === "running"); + const decidedCount = merged.filter( + t => t.status === "pass" || t.status === "fail" || t.status === "error", + ).length; + const nTotal = Math.max(merged.length, ...members.map(m => m.nTotal)); + const mergedRun: RunRow = { + ...base, + status: running ? "running" : nTotal > 0 && decidedCount >= nTotal ? "complete" : base.status, + nTotal, + done: decidedCount, + pass: merged.filter(t => t.status === "pass").length, + fail: merged.filter(t => t.status === "fail").length, + error: merged.filter(t => t.status === "error").length, + running: members.reduce((a, m) => a + m.running, 0), + costUsd: members.reduce((a, m) => a + m.costUsd, 0), + tokIn: members.reduce((a, m) => a + m.tokIn, 0), + tokOut: members.reduce((a, m) => a + m.tokOut, 0), + tokCache: members.reduce((a, m) => a + m.tokCache, 0), + createdAt: Math.min(...members.map(m => m.createdAt)), + finishedAt: running + ? null + : members.reduce((a, m) => Math.max(a ?? 0, m.finishedAt ?? 0) || null, null), + }; + const summary = summarizeArm(mergedRun, merged); + summary.arm = armLabel; + if (members.length > 1) summary.config += ` · merged ${members.length} runs`; + arms.push(summary); + const cells: Record = {}; + for (const t of merged) { + tasks.add(t.task); + cells[t.task] = { status: t.status, reward: t.reward }; + } + matrix[armLabel] = cells; + } + // Replace naive running-arm pass projections with the sibling-calibrated + // estimate: per-task difficulty from every other arm's outcome on the + // shared sample. + const taskList = [...tasks]; + for (const arm of arms) { + if (!arm.projected) continue; + const own = matrix[arm.arm] ?? {}; + const siblings = new Map(); + for (const otherArm in matrix) { + if (otherArm === arm.arm) continue; + const cells = matrix[otherArm]; + for (const task in cells) { + const cell = cells[task]; + if (cell.status !== "pass" && cell.status !== "fail") continue; + const s = siblings.get(task) ?? { passes: 0, decided: 0 }; + s.decided++; + if (cell.status === "pass") s.passes++; + siblings.set(task, s); + } + } + const decided: Array<{ task: string; passed: boolean }> = []; + const decidedTasks = new Set(); + for (const task in own) { + const cell = own[task]; + if (cell.status !== "pass" && cell.status !== "fail") continue; + decided.push({ task, passed: cell.status === "pass" }); + decidedTasks.add(task); + } + const remaining = taskList.filter(task => !decidedTasks.has(task)); + const calibrated = calibratedFinalPassPct({ decided, siblings, remaining, nTotal: arm.run.nTotal }); + if (calibrated !== null) arm.projected.passPct = calibrated; + } + // Baselines first, then variants, then untagged — the table reads as + // "reference rows, then treatments". + const roleRank = (role: string) => (role === "baseline" ? 0 : role === "variant" ? 1 : 2); + arms.sort((a, b) => roleRank(a.run.role) - roleRank(b.run.role) || a.arm.localeCompare(b.arm)); + return { id, goal: store.getExperimentGoal(id), arms, tasks: [...tasks].sort(), matrix }; +} diff --git a/packages/harbor-manager/src/manager.test.ts b/packages/harbor-manager/src/manager.test.ts new file mode 100644 index 000000000..42e6f3224 --- /dev/null +++ b/packages/harbor-manager/src/manager.test.ts @@ -0,0 +1,390 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { experimentDetail } from "./experiments"; +import { ManagerServer, resolveArmLaunch } from "./server"; +import { RunStore } from "./store"; + +/** + * Contracts under test: + * - discover() backfills historical job dirs into run rows. + * - syncRun() mirrors trial outcomes (pass / error / running) and rollups. + * - REST API surfaces runs, trials, compact transcripts, and rejects bad launches. + */ + +const cleanups: Array<() => void> = []; +afterEach(() => { + while (cleanups.length) cleanups.pop()?.(); +}); + +function makeJobsDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harbor-manager-test-")); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function writeFixtureJob(jobsDir: string, jobName: string): void { + const jobDir = path.join(jobsDir, jobName); + fs.mkdirSync(jobDir, { recursive: true }); + fs.writeFileSync( + path.join(jobDir, "result.json"), + JSON.stringify({ + n_total_trials: 3, + stats: { n_running_trials: 1, n_pending_trials: 0 }, + }), + ); + fs.writeFileSync( + path.join(jobDir, "config.json"), + JSON.stringify({ + dataset: "test-dataset@1.0", + agents: [{ name: "omp", model_name: "anthropic/claude-opus-4-8" }], + }), + ); + const mkTrial = (name: string, body: Record | null) => { + const dir = path.join(jobDir, name, "agent"); + fs.mkdirSync(dir, { recursive: true }); + if (body) fs.writeFileSync(path.join(jobDir, name, "result.json"), JSON.stringify(body)); + }; + mkTrial("alpha__abc", { + started_at: "2026-07-12T10:00:00", + finished_at: "2026-07-12T10:05:00", + verifier_result: { rewards: { reward: 1 } }, + agent_result: { cost_usd: 0.5, n_input_tokens: 100, n_output_tokens: 10, n_cache_tokens: 80 }, + }); + mkTrial("beta__def", { + started_at: "2026-07-12T10:00:00", + finished_at: "2026-07-12T10:02:00", + exception_info: { exception_type: "AgentTimeoutError" }, + agent_result: { cost_usd: 0.2 }, + }); + mkTrial("gamma__ghi", null); // running: no result.json yet + // transcript for alpha + const transcript = [ + JSON.stringify({ + type: "message_end", + message: { + role: "assistant", + model: "claude-opus-4-8", + content: [ + { type: "text", text: "Reading the file first." }, + { type: "toolCall", id: "t1", name: "read", arguments: { path: "x" } }, + ], + }, + }), + JSON.stringify({ + type: "message_end", + message: { + role: "toolResult", + toolName: "read", + isError: false, + content: [{ type: "text", text: "file contents" }], + }, + }), + ].join("\n"); + fs.writeFileSync(path.join(jobDir, "alpha__abc", "agent", "omp.txt"), transcript); +} + +describe("RunStore", () => { + it("discovers historical job dirs and mirrors trial state", () => { + const jobsDir = makeJobsDir(); + writeFixtureJob(jobsDir, "job-a"); + const store = new RunStore(jobsDir); + cleanups.push(() => store.close()); + + expect(store.discover()).toBe(1); + const run = store.getRun("job-a"); + // No job-level finished_at + fresh dir + a running trial → still running. + expect(run?.status).toBe("running"); + expect(run?.dataset).toBe("test-dataset@1.0"); + expect(run?.models).toBe("anthropic/claude-opus-4-8"); + expect(run?.nTotal).toBe(3); + expect(run?.pass).toBe(1); + expect(run?.error).toBe(1); + expect(run?.running).toBe(1); + expect(run?.costUsd).toBeCloseTo(0.7, 5); + + const traces = store.listTraces("job-a"); + expect(traces.map(t => [t.task, t.status])).toEqual([ + ["alpha", "pass"], + ["beta", "error"], + ["gamma", "running"], + ]); + expect(traces[1].detail).toBe("AgentTimeoutError"); + + // re-discover is idempotent + expect(store.discover()).toBe(0); + }); + + it("marks discovered runs complete when harbor recorded a terminal state", () => { + const jobsDir = makeJobsDir(); + writeFixtureJob(jobsDir, "job-done"); + const jobDir = path.join(jobsDir, "job-done"); + fs.writeFileSync( + path.join(jobDir, "result.json"), + JSON.stringify({ + n_total_trials: 2, + finished_at: "2026-07-12T11:00:00", + stats: { n_running_trials: 0, n_pending_trials: 0 }, + }), + ); + fs.rmSync(path.join(jobDir, "gamma__ghi"), { recursive: true, force: true }); + const store = new RunStore(jobsDir); + cleanups.push(() => store.close()); + store.discover(); + expect(store.getRun("job-done")?.status).toBe("complete"); + expect(store.getRun("job-done")?.finishedAt).not.toBeNull(); + }); + + it("stores experiment goals and run roles/labels, and orders baselines first", () => { + const jobsDir = makeJobsDir(); + writeFixtureJob(jobsDir, "exp-treat"); + writeFixtureJob(jobsDir, "exp-base"); + const store = new RunStore(jobsDir); + cleanups.push(() => store.close()); + store.discover(); + store.setExperimentGoal("exp", "does the treatment beat the baseline?"); + expect(store.setRunMeta("exp-base", { role: "baseline", note: "plain model" })).toBe(true); + expect(store.setRunMeta("exp-treat", { role: "variant", note: "downshift flash", label: "flash@edit" })).toBe( + true, + ); + expect(store.setRunMeta("exp-missing", { role: "variant" })).toBe(false); + + const detail = experimentDetail(store, "exp"); + expect(detail?.goal).toBe("does the treatment beat the baseline?"); + // ArmSummary.arm resolves to the display label when one is set. + expect(detail?.arms.map(a => [a.arm, a.run.role, a.run.note, a.run.label])).toEqual([ + ["base", "baseline", "plain model", ""], + ["flash@edit", "variant", "downshift flash", "flash@edit"], + ]); + + // Partial updates keep the omitted fields. + expect(store.setRunMeta("exp-treat", { note: "downshift flash v2" })).toBe(true); + const treat = store.getRun("exp-treat"); + expect(treat?.label).toBe("flash@edit"); + expect(treat?.role).toBe("variant"); + expect(treat?.note).toBe("downshift flash v2"); + }); + + it("finalizes running rows whose owning process died", () => { + const jobsDir = makeJobsDir(); + writeFixtureJob(jobsDir, "job-b"); + const store = new RunStore(jobsDir); + cleanups.push(() => store.close()); + store.registerLaunch({ + benchmark: "harbor", + jobName: "job-b", + dataset: "test-dataset@1.0", + agent: "omp", + models: ["m"], + pid: 999999999, // certainly dead + }); + const rows = store.syncActive(); + expect(rows).toHaveLength(1); + expect(store.getRun("job-b")?.status).toBe("failed"); + }); +}); + +describe("ManagerServer API", () => { + it("serves uniform runs, traces, and rejects invalid launches", async () => { + const jobsDir = makeJobsDir(); + writeFixtureJob(jobsDir, "job-api"); + const manager = new ManagerServer(jobsDir); + const server = manager.start(0); + cleanups.push(() => { + void manager.stop(); + }); + const base = `http://localhost:${server.port}`; + + const runs = (await (await fetch(`${base}/api/runs`)).json()) as Array<{ jobName: string; pass: number }>; + expect(runs.map(r => r.jobName)).toContain("job-api"); + + const detailRes = await fetch(`${base}/api/runs/job-api`); + expect(detailRes.status).toBe(200); + const detail = (await detailRes.json()) as { run: { pass: number }; traces: Array<{ status: string }> }; + expect(detail.run.pass).toBe(1); + expect(detail.traces).toHaveLength(3); + + const tr = await fetch(`${base}/api/runs/job-api/traces/alpha__abc?tail=10`); + expect(tr.status).toBe(200); + const trace = (await tr.json()) as { entries: Array<{ kind: string; tools?: string[] }> }; + expect(trace.entries.map(e => e.kind)).toEqual(["assistant", "toolResult"]); + expect(trace.entries[0].tools).toEqual(["read"]); + + const missing = await fetch(`${base}/api/runs/nope`); + expect(missing.status).toBe(404); + + const badLaunch = await fetch(`${base}/api/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(badLaunch.status).toBe(400); + + const cancelUnknown = (await (await fetch(`${base}/api/runs/nope`, { method: "DELETE" })).json()) as { + cancelled: boolean; + }; + expect(cancelUnknown.cancelled).toBe(false); + }); + + it("serves edit and SnapCompact metrics and native traces through one API", async () => { + const jobsDir = makeJobsDir(); + const manager = new ManagerServer(jobsDir); + for (const benchmark of ["edit", "snapcompact"] as const) { + const jobName = `${benchmark}-arm`; + manager.store.registerLaunch({ + benchmark, + jobName, + dataset: benchmark === "edit" ? "typescript-edit" : "squad-dev", + agent: benchmark, + models: ["test/model"], + pid: process.pid, + }); + manager.store.markExit(jobName, 0); + } + const editDir = path.join(jobsDir, "edit-arm"); + fs.writeFileSync( + path.join(editDir, "result.json"), + JSON.stringify({ + tasks: [ + { + id: "rename", + name: "Rename", + runs: [{ runIndex: 0, success: true, duration: 10, tokens: { input: 8, output: 2, reasoning: 0 } }], + }, + ], + summary: { + totalRuns: 1, + successfulRuns: 1, + taskSuccessRate: 1, + editSuccessRate: 1, + totalTokens: { input: 8, output: 2 }, + }, + }), + ); + fs.mkdirSync(path.join(editDir, "result.dump", "rename"), { recursive: true }); + fs.writeFileSync(path.join(editDir, "result.dump", "rename", "run-1.md"), "# conversation\n\nassistant answer"); + const snapDir = path.join(jobsDir, "snapcompact-arm"); + fs.writeFileSync( + path.join(snapDir, "records.jsonl"), + `${JSON.stringify({ cond: "text", chunk: 0, pos_rel: 0, q: "question", answer: "answer", golds: ["gold"], em: 0, f1: 0.5 })}\n`, + ); + fs.writeFileSync( + path.join(snapDir, "summary.json"), + JSON.stringify({ + rows: [{ n: 1, f1: 0.5, em: 0, cost_usd: 0.1, tokens_in: 10, tokens_out: 2, cache_w: 0, cache_r: 0 }], + }), + ); + manager.store.syncAll(); + const server = manager.start(0); + cleanups.push(() => { + void manager.stop(); + }); + const base = `http://localhost:${server.port}`; + + const edit = (await (await fetch(`${base}/api/runs/edit-arm`)).json()) as { + run: { benchmark: string; metrics: Record }; + traces: Array<{ name: string }>; + }; + expect(edit.run).toMatchObject({ benchmark: "edit", metrics: { task_success_rate: 1, edit_success_rate: 1 } }); + const editTrace = (await ( + await fetch(`${base}/api/runs/edit-arm/traces/${encodeURIComponent(edit.traces[0].name)}`) + ).json()) as { entries: Array<{ kind: string; text: string }> }; + expect(editTrace.entries).toEqual([{ kind: "conversation", text: "# conversation\n\nassistant answer" }]); + + const snap = (await (await fetch(`${base}/api/runs/snapcompact-arm`)).json()) as { + run: { benchmark: string; metrics: Record }; + traces: Array<{ name: string }>; + }; + expect(snap.run).toMatchObject({ benchmark: "snapcompact", metrics: { f1: 0.5, exact_match: 0 } }); + const snapTrace = (await ( + await fetch(`${base}/api/runs/snapcompact-arm/traces/${encodeURIComponent(snap.traces[0].name)}`) + ).json()) as { entries: Array<{ kind: string }> }; + expect(snapTrace.entries.map(entry => entry.kind)).toEqual(["question", "answer", "reference"]); + }); +}); + +describe("resolveArmLaunch", () => { + it("inherits dataset + exact task sample + scale from a sibling arm", () => { + const store = new RunStore(makeJobsDir()); + cleanups.push(() => store.close()); + store.registerLaunch({ + benchmark: "harbor", + jobName: "exp-base", + dataset: "swe-bench/swe-bench-verified", + agent: "omp", + models: ["anthropic/claude-opus-4-8"], + pid: 4321, + role: "baseline", + config: { + include: ["astropy__astropy-1", "django__django-2", "sympy__sympy-3"], + tasks: 3, + concurrency: 4, + timeoutMultiplier: 2, + }, + }); + + const launch = resolveArmLaunch(store, "exp", { + arm: "n8", + model: "google/gemini-3.5-flash", + role: "variant", + note: "downshift@flash", + downshift: { into: "google/gemini-3.5-flash" }, + }); + + expect(launch.jobName).toBe("exp-n8"); + expect(launch.dataset).toBe("swe-bench/swe-bench-verified"); + expect(launch.include).toEqual(["astropy__astropy-1", "django__django-2", "sympy__sympy-3"]); + expect(launch.tasks).toBe(3); + expect(launch.concurrency).toBe(4); + expect(launch.timeoutMultiplier).toBe(2); + expect(launch.model).toBe("google/gemini-3.5-flash"); + expect(launch.role).toBe("variant"); + expect(launch.downshift?.into).toBe("google/gemini-3.5-flash"); + }); + + it("prefers the sibling with a recorded include list over newer include-less siblings", () => { + const store = new RunStore(makeJobsDir()); + cleanups.push(() => store.close()); + // Older sibling carries the authoritative sample… + store.registerLaunch({ + benchmark: "harbor", + jobName: "exp-base", + dataset: "swe-bench/swe-bench-verified", + agent: "omp", + models: ["anthropic/claude-opus-4-8"], + pid: 1, + config: { include: ["swe-bench/astropy__astropy-1", "swe-bench/django__django-2"] }, + }); + // …while a newer arm (e.g. discovered from disk) recorded no include. + store.registerLaunch({ + benchmark: "harbor", + jobName: "exp-noinc", + dataset: "swe-bench/swe-bench-verified", + agent: "omp", + models: ["anthropic/claude-opus-4-8"], + pid: 2, + config: {}, + }); + + const launch = resolveArmLaunch(store, "exp", { arm: "next", model: "anthropic/claude-opus-4-8" }); + expect(launch.include).toEqual(["swe-bench/astropy__astropy-1", "swe-bench/django__django-2"]); + expect(launch.tasks).toBe(2); + }); + + it("rejects a duplicate arm and an unknown experiment", () => { + const store = new RunStore(makeJobsDir()); + cleanups.push(() => store.close()); + store.registerLaunch({ + benchmark: "harbor", + jobName: "exp-base", + dataset: "d", + agent: "omp", + models: ["m/x"], + pid: 1, + config: { include: ["t1"] }, + }); + expect(() => resolveArmLaunch(store, "exp", { arm: "base", model: "m/y" })).toThrow(/already exists/); + expect(() => resolveArmLaunch(store, "ghost", { arm: "x", model: "m/y" })).toThrow(/no runs to inherit/); + }); +}); diff --git a/packages/harbor-manager/src/runner.test.ts b/packages/harbor-manager/src/runner.test.ts new file mode 100644 index 000000000..1b5e53431 --- /dev/null +++ b/packages/harbor-manager/src/runner.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test"; +import { buildHarborEnv, collectForwardEnv, parseArgs } from "./runner"; + +describe("generic agent-arg / env passthrough", () => { + it("forwards repeated --agent-arg as a JSON array the in-container agent can parse", () => { + const cfg = parseArgs([ + "--model", + "anthropic/claude-opus-4-8", + "--agent-arg", + "--downshift", + "--agent-arg", + "--downshift-into", + "--agent-arg", + "google/gemini-3.5-flash", + ]); + expect(cfg.agentArgs).toEqual(["--downshift", "--downshift-into", "google/gemini-3.5-flash"]); + + const env = buildHarborEnv(cfg, "/tmp/models.yml", null, "test"); + expect(JSON.parse(env.OMP_BENCH_AGENT_ARGS ?? "[]")).toEqual(cfg.agentArgs); + }); + + it("omits OMP_BENCH_AGENT_ARGS when no --agent-arg was passed", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8"]); + const env = buildHarborEnv(cfg, "/tmp/models.yml", null, "test"); + expect(env.OMP_BENCH_AGENT_ARGS).toBeUndefined(); + }); + + it("routes an explicit --providers entry alongside the model's own provider", () => { + // The runner has no built-in concept of a "second model"; gateway routing + // for any extra model introduced via --agent-arg must be declared + // explicitly via --providers. + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8", "--providers", "google"]); + const env = buildHarborEnv(cfg, "/tmp/models.yml", null, "test"); + expect(new Set(env.OMP_BENCH_GATEWAY_PROVIDERS?.split(","))).toEqual(new Set(["anthropic", "google"])); + }); + + it("collects explicit --env pairs, with an explicit value winning over a bare host-forwarded key", () => { + const cfg = parseArgs([ + "--model", + "anthropic/claude-opus-4-8", + "--env", + "SOME_FLAG=1", + "--env", + "OTHER=two words", + ]); + const forwarded = collectForwardEnv(cfg); + expect(forwarded.SOME_FLAG).toBe("1"); + expect(forwarded.OTHER).toBe("two words"); + }); +}); + +describe("install modes", () => { + it("defaults to source mode and publishes the mount contract to the agent", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8"]); + expect(cfg.install).toBe("source"); + const env = buildHarborEnv(cfg, "/tmp/models.yml", null, "test", { + arch: "arm64", + depsDir: "/tmp/deps", + nodeModules: ["node_modules"], + }); + expect(env.OMP_BENCH_INSTALL).toBe("source"); + expect(env.OMP_BENCH_SOURCE_DIR).toBe("/opt/omp/src"); + expect(env.OMP_BENCH_SOURCE_BUN).toBe("/opt/omp/bin/bun"); + expect(env.OMP_BENCH_SOURCE_ARCH).toBe("arm64"); + }); + + it("omits source mount env when no mount was prepared (binary/local runs)", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8", "--install", "local"]); + const env = buildHarborEnv(cfg, "/tmp/models.yml", "/tmp/omp.tgz", "test"); + expect(env.OMP_BENCH_INSTALL).toBe("local"); + expect(env.OMP_BENCH_SOURCE_DIR).toBeUndefined(); + expect(env.OMP_BENCH_SOURCE_ARCH).toBeUndefined(); + }); + + it("--tarball implies a local (tarball) install", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8", "--tarball", "/tmp/omp.tgz"]); + expect(cfg.install).toBe("local"); + expect(cfg.build).toBe(false); + }); +}); + +describe("parseArgs validation", () => { + it("rejects an unknown flag", () => { + expect(() => parseArgs(["--model", "anthropic/claude-opus-4-8", "--not-a-real-flag"])).toThrow(/unknown flag/); + }); + + it("defaults to a generic, dataset-agnostic jobs directory", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8"]); + expect(cfg.jobsDir.endsWith("/runs/harbor")).toBe(true); + }); +}); + +describe("environment backends", () => { + it("defaults to docker with the host.docker.internal gateway", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8"]); + expect(cfg.envType).toBe("docker"); + expect(cfg.gatewayUrl).toBe("http://host.docker.internal:4000"); + }); + + it("apple-container swaps the default gateway host to the vmnet bridge address", () => { + const cfg = parseArgs(["--model", "anthropic/claude-opus-4-8", "--environment", "apple-container"]); + expect(cfg.envType).toBe("apple-container"); + expect(cfg.gatewayUrl).toBe("http://192.168.64.1:4000"); + }); + + it("an explicit --gateway-url wins over the apple-container default, regardless of flag order", () => { + const cfg = parseArgs([ + "--model", + "anthropic/claude-opus-4-8", + "--gateway-url", + "http://10.0.0.5:9999", + "--environment", + "apple-container", + ]); + expect(cfg.gatewayUrl).toBe("http://10.0.0.5:9999"); + }); + + it("rejects --host-network with apple-container (compose overlay is docker-only)", () => { + expect(() => + parseArgs(["--model", "anthropic/claude-opus-4-8", "--environment", "apple-container", "--host-network"]), + ).toThrow(/docker-only/); + }); + + it("rejects an invalid --environment value", () => { + expect(() => parseArgs(["--model", "anthropic/claude-opus-4-8", "--environment", "podman"])).toThrow( + /--environment must be/, + ); + }); +}); diff --git a/packages/terminal-bench/src/runner.ts b/packages/harbor-manager/src/runner.ts similarity index 64% rename from packages/terminal-bench/src/runner.ts rename to packages/harbor-manager/src/runner.ts index 2aa0ee02c..8b7d2b362 100755 --- a/packages/terminal-bench/src/runner.ts +++ b/packages/harbor-manager/src/runner.ts @@ -1,23 +1,24 @@ #!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; /** - * terminal-bench-2 runner for the local `omp` build. + * Harbor benchmark runner for the local `omp` build. * - * Orchestrates Harbor (`harbor run`) against the harbor-framework/terminal-bench-2 - * dataset using a custom agent (`agent/omp_local.py`) that installs the working - * tree at /work/pi and routes all model auth through the host pm2 auth-gateway - * (no provider keys ever enter the task containers). + * Orchestrates Harbor (`harbor run`) against any Harbor dataset (default + * terminal-bench-2) using a custom agent (`agent/omp_local.py`) that installs + * the working tree at /work/pi and routes all model auth through the host pm2 + * auth-gateway (no provider keys ever enter the task containers). * * It owns the terminal: Harbor's own output is redirected to a log file and this * process renders a live dashboard (progress / success% / spend / tokens / ETA) * by polling each trial's `result.json`. On completion it writes a markdown report. * - * bun src/runner.ts --model anthropic/claude-sonnet-4-6 --tasks 20 --concurrency 4 - * bun src/runner.ts --agent oracle --tasks 2 # cheap pipeline smoke - * bun src/runner.ts --help + * harbor-manager harbor --model anthropic/claude-sonnet-4-6 --tasks 20 --concurrency 4 + * harbor-manager harbor --agent oracle --tasks 2 # cheap pipeline smoke + * harbor-manager harbor --help */ -import { spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as path from "node:path"; +import type { Server } from "bun"; // ────────────────────────────────────────────────────────────────────── config @@ -27,6 +28,21 @@ const AGENT_DIR = path.join(PKG_DIR, "agent"); const CODING_AGENT_DIR = path.join(REPO_ROOT, "packages", "coding-agent"); const AGENT_IMPORT_PATH = "omp_local:OmpLocal"; +/** Container-side mount points for `--install source` (must match omp_local.py defaults). */ +const SOURCE_SRC_MOUNT = "/opt/omp/src"; +const SOURCE_BIN_MOUNT = "/opt/omp/bin"; + +/** Host address containers see on Apple Container's vmnet (bridge) network. */ +const VMNET_HOST_IP = "192.168.64.1"; +const DOCKER_GATEWAY_URL = "http://host.docker.internal:4000"; +const VMNET_GATEWAY_URL = `http://${VMNET_HOST_IP}:4000`; +/** + * Resolver injected into Apple Container runs (OMP_BENCH_CONTAINER_DNS overrides). + * The vmnet gateway resolver (192.168.64.1:53) is unreachable when VPN/DNS + * agents on the host intercept port 53, so containers get an explicit one. + */ +const CONTAINER_DNS = process.env.OMP_BENCH_CONTAINER_DNS || "1.1.1.1"; + export interface Config { models: string[]; dataset: string; @@ -36,10 +52,11 @@ export interface Config { include: string[]; exclude: string[]; thinking: string | null; - advisorModel: string | null; - advisorSync: string; + /** Extra args forwarded verbatim to the in-container omp CLI invocation (repeatable). */ + agentArgs: string[]; + agent: string; - install: "local" | "published"; + install: "source" | "local" | "published"; version: string | null; tarball: string | null; binaryArm64: string | null; @@ -59,6 +76,8 @@ export interface Config { cleanup: boolean; cleanupForce: boolean; hostNetwork: boolean; + /** Harbor environment backend running the task containers. */ + envType: "docker" | "apple-container"; passthrough: string[]; env: Record; } @@ -73,18 +92,18 @@ function defaultConfig(): Config { include: [], exclude: [], thinking: null, - advisorModel: null, - advisorSync: "1", + agentArgs: [], + agent: "omp", - install: "local", + install: "source", version: null, tarball: null, binaryArm64: null, binaryX64: null, build: true, - jobsDir: path.join(REPO_ROOT, "runs", "tb2"), + jobsDir: path.join(REPO_ROOT, "runs", "harbor"), jobName: null, - gatewayUrl: "http://host.docker.internal:4000", + gatewayUrl: DOCKER_GATEWAY_URL, gatewayToken: "no-auth", providers: [], gateway: true, @@ -96,14 +115,15 @@ function defaultConfig(): Config { cleanup: false, cleanupForce: false, hostNetwork: false, + envType: "docker", passthrough: [], env: {}, }; } -const HELP = `terminal-bench-2 runner (local omp) +const HELP = `harbor-manager runner (local omp) -Usage: bun src/runner.ts [options] [-- ] +Usage: harbor-manager harbor [options] [-- ] Commands: cleanup Force-remove ALL leftover Harbor containers + networks, then exit @@ -111,13 +131,15 @@ Commands: Model / agent: -m, --model Model (repeatable). Default anthropic/claude-sonnet-4-6 --agent omp (default) | oracle | nop | any harbor agent - --install omp source. local = pack /work/pi (default) + --install omp install mode (default: source). + source = mount /work/pi read-only + prebuilt linux deps tree; TS changes + apply per-trial with no rebuild. local = pack a tarball. published = npm. --version omp version for published install (default: latest) --thinking off|minimal|low|medium|high|xhigh|max - --advisor-model

      Second model reviewing the primary (spend summed in) - --advisor-sync Advisor catch-up backlog (default 1 = accurate spend; off = faster) - --tarball Reuse a prebuilt omp tarball (implies --no-build) - --no-build Skip packing; reuse newest tarball in bench dir + + --tarball Reuse a prebuilt omp tarball (implies --install local, --no-build) + --no-build Skip packing; reuse newest tarball in bench dir (--install local) + --agent-arg Extra arg forwarded verbatim to the in-container omp CLI (repeatable) --env Forward env into omp container (repeatable). KEY alone forwards host value; host PI_* auto-forwarded. @@ -137,12 +159,16 @@ Gateway (auth, no keys in container): --web-search Enable omp web_search (off by default; can't auth via gateway) --allow-host harbor --allow-agent-host (repeatable) +Environment: + --environment docker (default) | apple-container (Apple 'container' CLI; + no Docker needed, gateway auto-forwarded via 192.168.64.1) + Output / control: - -o, --jobs-dir Default /runs/tb2 - --job-name Default tb2-- + -o, --jobs-dir Default /runs/harbor + --job-name Default - --dry-run Print the harbor command + models.yml and exit - --cleanup Clean up stale and exited Harbor Docker resources safely before starting - --cleanup-force Force-stop and remove ALL previous Harbor Docker containers and networks + --cleanup Clean up stale and exited Harbor Docker resources safely before starting (docker only) + --cleanup-force Force-stop and remove ALL previous Harbor Docker containers and networks (docker only) --host-network Run Docker task containers using host networking (experimental) -h, --help This help `; @@ -180,7 +206,9 @@ export function parseArgs(argv: string[]): Config { break; case "--install": { const v = take(arg); - if (v !== "local" && v !== "published") throw new Error("--install must be local|published"); + if (v !== "source" && v !== "local" && v !== "published") { + throw new Error("--install must be source|local|published"); + } cfg.install = v; break; } @@ -190,14 +218,9 @@ export function parseArgs(argv: string[]): Config { case "--thinking": cfg.thinking = take(arg); break; - case "--advisor-model": - cfg.advisorModel = take(arg); - break; - case "--advisor-sync": - cfg.advisorSync = take(arg); - break; case "--tarball": cfg.tarball = path.resolve(take(arg)); + cfg.install = "local"; cfg.build = false; break; case "--binary": { @@ -212,6 +235,9 @@ export function parseArgs(argv: string[]): Config { case "--no-build": cfg.build = false; break; + case "--agent-arg": + cfg.agentArgs.push(take(arg)); + break; case "-l": case "--tasks": case "--n-tasks": @@ -239,6 +265,7 @@ export function parseArgs(argv: string[]): Config { case "--dataset": cfg.dataset = take(arg); break; + case "--gateway-url": cfg.gatewayUrl = take(arg); break; @@ -305,11 +332,24 @@ export function parseArgs(argv: string[]): Config { } break; } + case "--environment": { + const v = take(arg); + if (v !== "docker" && v !== "apple-container") { + throw new Error("--environment must be docker|apple-container"); + } + cfg.envType = v; + break; + } default: throw new Error(`unknown flag: ${arg} (see --help)`); } } if (cfg.models.length === 0) cfg.models = ["anthropic/claude-sonnet-4-6"]; + if (cfg.envType === "apple-container") { + if (cfg.hostNetwork) throw new Error("--host-network is docker-only (compose overlay)"); + // host.docker.internal doesn't exist on vmnet; containers reach the host at the bridge address. + if (cfg.gatewayUrl === DOCKER_GATEWAY_URL) cfg.gatewayUrl = VMNET_GATEWAY_URL; + } return cfg; } @@ -357,16 +397,19 @@ function pad(s: string, w: number): string { return s.length >= w ? s.slice(0, w) : s + " ".repeat(w - s.length); } +function agentArgsLabel(cfg: Config): string | null { + return cfg.agentArgs.length > 0 ? cfg.agentArgs.join(" ") : null; +} + // ───────────────────────────────────────────────────────────── result parsing -type TrialStatus = "pass" | "fail" | "error" | "running"; +export type TrialStatus = "pass" | "fail" | "error" | "running"; -interface Trial { +export interface Trial { name: string; status: TrialStatus; reward: number | null; costUsd: number; - advisorCostUsd: number; tokIn: number; tokOut: number; tokCache: number; @@ -379,7 +422,6 @@ interface AgentCtxLike { n_cache_tokens?: unknown; n_output_tokens?: unknown; cost_usd?: unknown; - metadata?: unknown; } function num(v: unknown): number { @@ -457,7 +499,6 @@ function parseTrial(dir: string, name: string): Trial | null { status: "running", reward: null, costUsd, - advisorCostUsd: 0, tokIn, tokOut, tokCache, @@ -481,7 +522,6 @@ function parseTrial(dir: string, name: string): Trial | null { } } let costUsd = 0, - advisorCostUsd = 0, tokIn = 0, tokOut = 0, tokCache = 0; @@ -490,9 +530,6 @@ function parseTrial(dir: string, name: string): Trial | null { tokIn += num(ctx.n_input_tokens); tokOut += num(ctx.n_output_tokens); tokCache += num(ctx.n_cache_tokens); - if (ctx.metadata && typeof ctx.metadata === "object") { - advisorCostUsd += num((ctx.metadata as Record).advisor_cost_usd); - } } // rewards: top-level verifier_result, else step_results last verifier @@ -531,10 +568,10 @@ function parseTrial(dir: string, name: string): Trial | null { } else { status = "fail"; } - return { name, status, reward, costUsd, advisorCostUsd, tokIn, tokOut, tokCache, durationMs, detail }; + return { name, status, reward, costUsd, tokIn, tokOut, tokCache, durationMs, detail }; } -function readTrials(jobDir: string): Trial[] { +export function readTrials(jobDir: string): Trial[] { let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(jobDir, { withFileTypes: true }); @@ -551,13 +588,15 @@ function readTrials(jobDir: string): Trial[] { } /** Authoritative job-level totals from /result.json (written incrementally). */ -interface JobInfo { +export interface JobInfo { nTotal: number; running: number | null; pending: number | null; + /** Harbor sets this only when the job reached a terminal state. */ + finishedAt: number | null; } -function readJobResult(jobDir: string): JobInfo | null { +export function readJobResult(jobDir: string): JobInfo | null { const raw = readJson(path.join(jobDir, "result.json")); if (!raw || typeof raw !== "object") return null; const r = raw as Record; @@ -569,12 +608,14 @@ function readJobResult(jobDir: string): JobInfo | null { if (typeof s.n_running_trials === "number") running = s.n_running_trials; if (typeof s.n_pending_trials === "number") pending = s.n_pending_trials; } - return nTotal > 0 ? { nTotal, running, pending } : null; + const finishedRaw = typeof r.finished_at === "string" ? Date.parse(r.finished_at) : NaN; + const finishedAt = Number.isFinite(finishedRaw) ? finishedRaw : null; + return nTotal > 0 ? { nTotal, running, pending, finishedAt } : null; } // ──────────────────────────────────────────────────────────────────── totals -interface Totals { +export interface Totals { total: number; done: number; pass: number; @@ -583,13 +624,13 @@ interface Totals { running: number; pending: number; costUsd: number; - advisorCostUsd: number; tokIn: number; tokOut: number; tokCache: number; + durationMs: number; } -function aggregate(trials: Trial[], job: JobInfo | null, fallbackExpected: number): Totals { +export function aggregate(trials: Trial[], job: JobInfo | null, fallbackExpected: number): Totals { const t: Totals = { total: fallbackExpected, done: 0, @@ -599,14 +640,13 @@ function aggregate(trials: Trial[], job: JobInfo | null, fallbackExpected: numbe running: 0, pending: 0, costUsd: 0, - advisorCostUsd: 0, tokIn: 0, tokOut: 0, tokCache: 0, + durationMs: 0, }; for (const tr of trials) { t.costUsd += tr.costUsd; - t.advisorCostUsd += tr.advisorCostUsd; t.tokIn += tr.tokIn; t.tokOut += tr.tokOut; t.tokCache += tr.tokCache; @@ -614,6 +654,8 @@ function aggregate(trials: Trial[], job: JobInfo | null, fallbackExpected: numbe t.running++; continue; } + t.durationMs += tr.durationMs; + t.done++; if (tr.status === "pass") t.pass++; else if (tr.status === "error") t.error++; @@ -671,8 +713,9 @@ function render(st: RenderState): void { const successPct = tot.done > 0 ? (tot.pass / tot.done) * 100 : 0; const rows: string[] = []; - const advisorTag = st.cfg.advisorModel ? `${dim(" + advisor ")}${st.cfg.advisorModel}` : ""; - const header = `${bold("terminal-bench-2")} ${dim("·")} ${cyan(st.cfg.agent)} ${dim("·")} ${st.cfg.models.join(",")}${advisorTag} ${dim(`· conc=${st.cfg.concurrency} k=${st.cfg.attempts}`)}`; + const argsLabel = agentArgsLabel(st.cfg); + const argsTag = argsLabel ? `${dim(" · args ")}${argsLabel}` : ""; + const header = `${bold(st.cfg.dataset)} ${dim("·")} ${cyan(st.cfg.agent)} ${dim("·")} ${st.cfg.models.join(",")}${argsTag} ${dim(`· conc=${st.cfg.concurrency} k=${st.cfg.attempts}`)}`; rows.push(header); const width = 28; rows.push( @@ -681,9 +724,8 @@ function render(st: RenderState): void { rows.push( `${green(`pass ${tot.pass}`)} ${dim(`(${successPct.toFixed(0)}%)`)} ${red(`fail ${tot.fail}`)} ${yellow(`err ${tot.error}`)} ${cyan(`run ${tot.running}`)} ${gray(`pend ${tot.pending}`)}`, ); - const advisorSpend = tot.advisorCostUsd > 0 ? dim(` (advisor ${fmtUsd(tot.advisorCostUsd)})`) : ""; rows.push( - `${bold("spend")} ${fmtUsd(tot.costUsd)}${advisorSpend} ${dim("in")} ${fmtNum(tot.tokIn)} ${dim("out")} ${fmtNum(tot.tokOut)} ${dim("cache")} ${fmtNum(tot.tokCache)}`, + `${bold("spend")} ${fmtUsd(tot.costUsd)} ${dim("in")} ${fmtNum(tot.tokIn)} ${dim("out")} ${fmtNum(tot.tokOut)} ${dim("cache")} ${fmtNum(tot.tokCache)}`, ); rows.push(dim("─".repeat(54))); @@ -709,7 +751,7 @@ function render(st: RenderState): void { process.stdout.write(out); } else { process.stdout.write( - `[tb2] ${tot.done}/${tot.total} pass=${tot.pass}(${successPct.toFixed(0)}%) fail=${tot.fail} err=${tot.error} run=${tot.running} spend=${fmtUsd(tot.costUsd)} elapsed=${fmtDur(elapsed)}\n`, + `[harbor] ${tot.done}/${tot.total} pass=${tot.pass}(${successPct.toFixed(0)}%) fail=${tot.fail} err=${tot.error} run=${tot.running} spend=${fmtUsd(tot.costUsd)} elapsed=${fmtDur(elapsed)}\n`, ); } } @@ -722,11 +764,10 @@ function writeReport(st: RenderState, benchDir: string, exitCode: number): strin const successPct = tot.done > 0 ? (tot.pass / tot.done) * 100 : 0; const lines: string[] = []; const isOmp = st.cfg.agent === "omp"; - const modelLine = - isOmp && st.cfg.advisorModel - ? `${st.cfg.models.join(", ")} + advisor ${st.cfg.advisorModel}` - : st.cfg.models.join(", "); - lines.push(`# terminal-bench-2 — ${st.cfg.agent} — ${modelLine}`); + const argsLabel = agentArgsLabel(st.cfg); + const baseModelLine = st.cfg.models.join(", "); + const modelLine = argsLabel ? `${baseModelLine} (${argsLabel})` : baseModelLine; + lines.push(`# ${st.cfg.dataset} — ${st.cfg.agent} — ${modelLine}`); lines.push(""); lines.push(`- dataset: \`${st.cfg.dataset}\``); lines.push(`- tasks: ${st.cfg.tasks} · attempts: ${st.cfg.attempts} · concurrency: ${st.cfg.concurrency}`); @@ -735,13 +776,12 @@ function writeReport(st: RenderState, benchDir: string, exitCode: number): strin `- install: ${st.cfg.install} · auth: ${st.cfg.gateway ? "host gateway (no keys in container)" : "direct provider keys"}`, ); lines.push(`- tools: web_search=${st.cfg.webSearch ? "on" : "off"}`); - if (st.cfg.advisorModel) lines.push(`- advisor: ${st.cfg.advisorModel}`); + if (argsLabel) lines.push(`- agent args: ${argsLabel}`); } lines.push(`- elapsed: ${fmtDur(Date.now() - st.startMs)} · harbor exit: ${exitCode}`); lines.push(""); - const advisorSpend = tot.advisorCostUsd > 0 ? ` (advisor ${fmtUsd(tot.advisorCostUsd)})` : ""; lines.push( - `**${tot.pass}/${tot.done} passed (${successPct.toFixed(1)}%)** · fail ${tot.fail} · error ${tot.error} · spend ${fmtUsd(tot.costUsd)}${advisorSpend}`, + `**${tot.pass}/${tot.done} passed (${successPct.toFixed(1)}%)** · fail ${tot.fail} · error ${tot.error} · spend ${fmtUsd(tot.costUsd)}`, ); lines.push(`tokens: in ${fmtNum(tot.tokIn)} · out ${fmtNum(tot.tokOut)} · cache ${fmtNum(tot.tokCache)}`); lines.push(""); @@ -816,16 +856,214 @@ function newestTarball(benchDir: string): string | null { } } +// ─────────────────────────────────────────────────────── source mount (--install source) + +/** Linux deps tree + mount plan for running omp straight from the mounted repo. */ +export interface SourceMount { + arch: "arm64" | "x64"; + /** Host dir holding the linux `bin/bun` + skeleton `node_modules` trees. */ + depsDir: string; + /** Repo-relative node_modules dirs to shadow-mount over the darwin ones. */ + nodeModules: string[]; +} + +/** Bun version pinned by the repo's `packageManager` field. */ +function repoBunVersion(): string { + const raw = readJson(path.join(REPO_ROOT, "package.json")); + if (raw && typeof raw === "object") { + const pm = (raw as Record).packageManager; + if (typeof pm === "string" && pm.startsWith("bun@")) return pm.slice("bun@".length); + } + return "1.3.14"; +} + +/** Native arch of the docker daemon (what non-emulated task containers run as). */ +function dockerServerArch(): "arm64" | "x64" { + const r = spawnSync("docker", ["version", "--format", "{{.Server.Arch}}"], { encoding: "utf8" }); + const a = (r.stdout ?? "").trim(); + if (a === "arm64" || a === "aarch64") return "arm64"; + if (a === "amd64" || a === "x86_64") return "x64"; + throw new Error(`cannot detect docker server arch (got ${a || "nothing"}); is docker running?`); +} + +/** Workspace member dirs (repo-relative), expanded from root package.json `workspaces.packages`. */ +function workspacePackageDirs(): string[] { + const raw = readJson(path.join(REPO_ROOT, "package.json")) as { + workspaces?: { packages?: string[] }; + } | null; + const dirs = new Set(); + for (const pattern of raw?.workspaces?.packages ?? []) { + for (const match of new Bun.Glob(`${pattern}/package.json`).scanSync({ cwd: REPO_ROOT })) { + dirs.add(path.dirname(match)); + } + } + return [...dirs].sort(); +} + +/** Manifest files (repo-relative) that fully determine a `bun install` result. */ +function sourceManifestFiles(pkgDirs: string[]): string[] { + const files = ["package.json", "bun.lock"]; + if (fs.existsSync(path.join(REPO_ROOT, "bunfig.toml"))) files.push("bunfig.toml"); + const patchesDir = path.join(REPO_ROOT, "patches"); + if (fs.existsSync(patchesDir)) { + for (const f of fs.readdirSync(patchesDir).sort()) files.push(path.join("patches", f)); + } + for (const dir of pkgDirs) files.push(path.join(dir, "package.json")); + return files; +} + +function sourceDepsStamp(manifests: string[], bunVersion: string): string { + const h = new Bun.CryptoHasher("sha256"); + h.update(`bun@${bunVersion}\0source-deps-v1\0`); + for (const rel of manifests) { + h.update(rel); + h.update("\0"); + h.update(fs.readFileSync(path.join(REPO_ROOT, rel))); + h.update("\0"); + } + return h.digest("hex"); +} + +/** + * Ensure the cached linux deps tree for source mode: a manifest-only skeleton of the + * workspace with `bun install --production` run inside `oven/bun:` (matching the + * daemon's native arch), plus the image's linux `bun` under `bin/`. Rebuilt only when + * a manifest/lockfile or the pinned bun version changes; TS edits never invalidate it. + */ +export function prepareSourceDeps(cfg: Config): SourceMount { + const arch = cfg.envType === "apple-container" ? "arm64" : dockerServerArch(); + const bunVersion = repoBunVersion(); + const depsDir = path.join(cfg.jobsDir, "_bench", "_deps", `linux-${arch}`); + const pkgDirs = workspacePackageDirs(); + const manifests = sourceManifestFiles(pkgDirs); + const stamp = sourceDepsStamp(manifests, bunVersion); + const stampFile = path.join(depsDir, ".stamp"); + let current: string | null = null; + try { + current = fs.readFileSync(stampFile, "utf8").trim(); + } catch { + /* no stamp yet */ + } + if (current !== stamp) { + process.stdout.write(dim(`building linux-${arch} deps tree for source mount (one-time per lockfile change)…\n`)); + fs.rmSync(depsDir, { recursive: true, force: true }); + fs.mkdirSync(depsDir, { recursive: true }); + for (const rel of manifests) { + const dst = path.join(depsDir, rel); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(path.join(REPO_ROOT, rel), dst); + } + // --ignore-scripts: the skeleton has manifests only, so lifecycle scripts + // (root `prepare` → gen:tool-views) would fail; patchedDependencies still apply. + const script = + 'mkdir -p /deps/bin && cp "$(command -v bun)" /deps/bin/bun && cd /deps && bun install --production --omit=optional --ignore-scripts'; + const image = `oven/bun:${bunVersion}`; + const runArgv = + cfg.envType === "apple-container" + ? [ + "container", + "run", + "--rm", + "--dns", + CONTAINER_DNS, + "-e", + "HOME=/tmp", + "-v", + `${depsDir}:/deps`, + image, + "sh", + "-c", + script, + ] + : [ + "docker", + "run", + "--rm", + "--platform", + `linux/${arch === "x64" ? "amd64" : "arm64"}`, + "-e", + "HOME=/tmp", + "-v", + `${depsDir}:/deps`, + image, + "sh", + "-c", + script, + ]; + const r = spawnSync(runArgv[0], runArgv.slice(1), { stdio: ["ignore", "inherit", "inherit"] }); + if (r.status !== 0) { + fs.rmSync(stampFile, { force: true }); + throw new Error(`source deps install failed (${runArgv[0]} exit ${r.status})`); + } + fs.writeFileSync(stampFile, `${stamp}\n`); + } + if (!fs.existsSync(path.join(depsDir, "node_modules"))) { + throw new Error(`source deps tree has no node_modules (${depsDir}); delete it and retry`); + } + // Shadow-mount every node_modules visible in the host tree (they hold darwin + // binaries) with the skeleton's linux one; both sides of each mount must exist. + const nodeModules = ["node_modules"]; + for (const dir of pkgDirs) { + const rel = path.join(dir, "node_modules"); + const inHost = fs.existsSync(path.join(REPO_ROOT, rel)); + const inDeps = fs.existsSync(path.join(depsDir, rel)); + if (!inHost && !inDeps) continue; + if (!inDeps) fs.mkdirSync(path.join(depsDir, rel), { recursive: true }); + if (!inHost) fs.mkdirSync(path.join(REPO_ROOT, rel), { recursive: true }); + nodeModules.push(rel); + } + return { arch, depsDir, nodeModules }; +} + +/** + * Compose overlay applied to every trial's `main` service: host networking and/or the + * read-only source + linux-deps mounts. Returns null when nothing needs overlaying. + */ +function writeComposeOverlay(benchDir: string, cfg: Config, source: SourceMount | null): string | null { + const lines: string[] = []; + if (cfg.hostNetwork) lines.push(' network_mode: "host"'); + if (source) { + lines.push(" volumes:"); + lines.push(` - ${REPO_ROOT}:${SOURCE_SRC_MOUNT}:ro`); + for (const rel of source.nodeModules) { + lines.push(` - ${path.join(source.depsDir, rel)}:${SOURCE_SRC_MOUNT}/${rel}:ro`); + } + lines.push(` - ${path.join(source.depsDir, "bin")}:${SOURCE_BIN_MOUNT}:ro`); + } + if (lines.length === 0) return null; + const file = path.join(benchDir, "omp-compose-overlay.yaml"); + fs.writeFileSync(file, `${["services:", " main:", ...lines].join("\n")}\n`); + return file; +} + +/** + * `harbor run --mounts` JSON (compose service-volume format) for non-compose + * environments (apple-container): source repo + linux deps tree. Apple + * Container currently mounts binds read-write regardless of `read_only`. + */ +function buildMountsJson(source: SourceMount | null): string | null { + if (!source) return null; + const mounts: Array<{ type: "bind"; source: string; target: string; read_only: true }> = [ + { type: "bind", source: REPO_ROOT, target: SOURCE_SRC_MOUNT, read_only: true }, + ]; + for (const rel of source.nodeModules) { + mounts.push({ + type: "bind", + source: path.join(source.depsDir, rel), + target: `${SOURCE_SRC_MOUNT}/${rel}`, + read_only: true, + }); + } + mounts.push({ type: "bind", source: path.join(source.depsDir, "bin"), target: SOURCE_BIN_MOUNT, read_only: true }); + return JSON.stringify(mounts); +} + function deriveProviders(cfg: Config): string[] { const set = new Set(cfg.providers); for (const m of cfg.models) { const slash = m.indexOf("/"); if (slash > 0) set.add(m.slice(0, slash)); } - if (cfg.advisorModel) { - const slash = cfg.advisorModel.indexOf("/"); - if (slash > 0) set.add(cfg.advisorModel.slice(0, slash)); - } if (set.size === 0) { set.add("anthropic"); set.add("openai-codex"); @@ -835,7 +1073,7 @@ function deriveProviders(cfg: Config): string[] { function writeModelsYaml(benchDir: string, cfg: Config): string { const providers = deriveProviders(cfg); - const lines = ["# Generated by terminal-bench runner — auth via host pm2 gateway.", "providers:"]; + const lines = ["# Generated by harbor-manager — auth via host pm2 gateway.", "providers:"]; for (const p of providers) { lines.push(` ${p}:`); lines.push(` baseUrl: ${cfg.gatewayUrl}`); @@ -849,17 +1087,63 @@ function writeModelsYaml(benchDir: string, cfg: Config): string { } function gatewayHealthOk(url: string): boolean { - const hostUrl = url.replace("host.docker.internal", "127.0.0.1").replace(/\/+$/, ""); + const hostUrl = url + .replace("host.docker.internal", "127.0.0.1") + .replace(VMNET_HOST_IP, "127.0.0.1") + .replace(/\/+$/, ""); const r = spawnSync("curl", ["-s", "--max-time", "4", `${hostUrl}/healthz`], { encoding: "utf8" }); return r.status === 0 && (r.stdout ?? "").includes('"ok":true'); } +/** + * HTTP forward from the vmnet host address to the loopback-bound auth gateway. + * Apple Container has no host.docker.internal: containers reach the host at + * 192.168.64.1, but the pm2 gateway binds 127.0.0.1 only. The bridge interface + * only exists while a container is running, so binding retries until it appears. + */ +function startVmnetGatewayForward(cfg: Config): { stop(): void } | null { + if (cfg.envType !== "apple-container" || !cfg.gateway) return null; + const url = new URL(cfg.gatewayUrl); + if (url.hostname !== VMNET_HOST_IP) return null; + const port = Number(url.port || "80"); + let server: Server | null = null; + let timer: Timer | undefined; + let stopped = false; + const bind = (): void => { + if (stopped) return; + try { + server = Bun.serve({ + hostname: VMNET_HOST_IP, + port, + idleTimeout: 0, + fetch(req) { + const target = new URL(req.url); + target.hostname = "127.0.0.1"; + return fetch(target, { method: req.method, headers: req.headers, body: req.body, redirect: "manual" }); + }, + }); + process.stdout.write(dim(`gateway forward: ${VMNET_HOST_IP}:${port} → 127.0.0.1:${port}\n`)); + } catch { + timer = setTimeout(bind, 2000); + } + }; + bind(); + return { + stop(): void { + stopped = true; + clearTimeout(timer); + server?.stop(true); + }, + }; +} + function buildHarborArgs( cfg: Config, jobName: string, modelsYaml: string, tarball: string | null, - hostNetworkOverlayPath: string | null, + composeOverlayPath: string | null, + mountsJson: string | null, ): string[] { const a: string[] = ["run", "-d", cfg.dataset, "-o", cfg.jobsDir, "--job-name", jobName]; a.push("-n", String(cfg.concurrency), "-k", String(cfg.attempts), "-l", String(cfg.tasks)); @@ -869,12 +1153,14 @@ function buildHarborArgs( for (const h of cfg.allowHosts) a.push("--allow-agent-host", h); if (cfg.timeoutMultiplier !== null) a.push("--timeout-multiplier", String(cfg.timeoutMultiplier)); if (cfg.yes) a.push("-y"); - if (hostNetworkOverlayPath) { - a.push("--extra-docker-compose", hostNetworkOverlayPath); + if (composeOverlayPath) { + a.push("--extra-docker-compose", composeOverlayPath); } + if (cfg.envType !== "docker") a.push("-e", cfg.envType); + if (mountsJson) a.push("--mounts", mountsJson); if (cfg.agent === "omp") { - // Config + secrets travel via env (OMP_TB_*); the agent reads os.environ. + // Config + secrets travel via env (OMP_BENCH_*); the agent reads os.environ. a.push("--agent-import-path", AGENT_IMPORT_PATH); void modelsYaml; void tarball; @@ -918,36 +1204,40 @@ export function buildHarborEnv( modelsYaml: string, tarball: string | null, version: string, + source: SourceMount | null = null, ): Record { const env: Record = { ...(process.env as Record) }; - // Drop any stale OMP_TB_FORWARD_ENV inherited from the caller's shell before + // Drop any stale OMP_BENCH_FORWARD_ENV inherited from the caller's shell before // the agent-type early return, so it never leaks (incl. into the dry-run dump). - delete env.OMP_TB_FORWARD_ENV; + delete env.OMP_BENCH_FORWARD_ENV; if (cfg.agent !== "omp") return env; const prepend = (k: string, v: string): void => { env[k] = env[k] ? `${v}:${env[k]}` : v; }; prepend("PYTHONPATH", AGENT_DIR); - env.OMP_TB_INSTALL = cfg.install; - env.OMP_TB_VERSION = cfg.version ?? version; - if (tarball) env.OMP_TB_TARBALL = tarball; - if (cfg.binaryArm64) env.OMP_TB_BINARY_ARM64 = cfg.binaryArm64; - if (cfg.binaryX64) env.OMP_TB_BINARY_X64 = cfg.binaryX64; - if (cfg.thinking) env.OMP_TB_THINKING = cfg.thinking; - if (cfg.advisorModel) { - env.OMP_TB_ADVISOR_MODEL = cfg.advisorModel; - env.OMP_TB_ADVISOR_SYNC = cfg.advisorSync; + env.OMP_BENCH_INSTALL = cfg.install; + env.OMP_BENCH_VERSION = cfg.version ?? version; + if (tarball) env.OMP_BENCH_TARBALL = tarball; + if (source) { + env.OMP_BENCH_SOURCE_DIR = SOURCE_SRC_MOUNT; + env.OMP_BENCH_SOURCE_BUN = `${SOURCE_BIN_MOUNT}/bun`; + env.OMP_BENCH_SOURCE_ARCH = source.arch; } - if (cfg.webSearch) env.OMP_TB_WEB_SEARCH = "1"; - env.OMP_TB_GATEWAY = cfg.gateway ? "1" : "0"; + if (cfg.binaryArm64) env.OMP_BENCH_BINARY_ARM64 = cfg.binaryArm64; + if (cfg.binaryX64) env.OMP_BENCH_BINARY_X64 = cfg.binaryX64; + if (cfg.thinking) env.OMP_BENCH_THINKING = cfg.thinking; + if (cfg.agentArgs.length > 0) env.OMP_BENCH_AGENT_ARGS = JSON.stringify(cfg.agentArgs); + if (cfg.webSearch) env.OMP_BENCH_WEB_SEARCH = "1"; + env.OMP_BENCH_GATEWAY = cfg.gateway ? "1" : "0"; if (cfg.gateway) { - env.OMP_TB_MODELS_YAML = modelsYaml; - env.OMP_TB_GATEWAY_URL = cfg.gatewayUrl; - env.OMP_TB_GATEWAY_TOKEN = cfg.gatewayToken; - env.OMP_TB_GATEWAY_PROVIDERS = deriveProviders(cfg).join(","); + env.OMP_BENCH_MODELS_YAML = modelsYaml; + env.OMP_BENCH_GATEWAY_URL = cfg.gatewayUrl; + env.OMP_BENCH_GATEWAY_TOKEN = cfg.gatewayToken; + env.OMP_BENCH_GATEWAY_PROVIDERS = deriveProviders(cfg).join(","); } + if (cfg.envType === "apple-container") env.OMP_BENCH_CONTAINER_DNS = CONTAINER_DNS; const forward = collectForwardEnv(cfg); - if (Object.keys(forward).length > 0) env.OMP_TB_FORWARD_ENV = JSON.stringify(forward); + if (Object.keys(forward).length > 0) env.OMP_BENCH_FORWARD_ENV = JSON.stringify(forward); return env; } @@ -1056,25 +1346,33 @@ function runDockerCleanup(force: boolean): void { // ──────────────────────────────────────────────────────────────────────── main -async function main(): Promise { - const argv = process.argv.slice(2); - if (argv[0] === "cleanup") { - if (!which("docker")) throw new Error("docker not found on PATH (required for cleanup)."); - runDockerCleanup(true); - return; - } - const cfg = parseArgs(argv); +interface BenchmarkRun { + exitCode: number; + jobName: string; + jobDir: string; + benchDir: string; + tarball: string | null; + elapsedMs: number; + totals: Totals | null; + reportPath: string | null; +} +async function runBenchmark(cfg: Config): Promise { if (!which("harbor")) { throw new Error("harbor not found on PATH. Install with: uv tool install harbor"); } - if (cfg.agent === "omp" && !which("docker")) { + if (cfg.agent === "omp" && cfg.envType === "docker" && !which("docker")) { throw new Error("docker not found on PATH (required to run task containers)."); } + if (cfg.envType === "apple-container" && !which("container")) { + throw new Error( + "Apple 'container' CLI not found. Install with: brew install container && container system start", + ); + } const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const modelSlug = cfg.models[0].replace(/[^a-zA-Z0-9]+/g, "-"); - const jobName = cfg.jobName ?? `tb2-${modelSlug}-${stamp}`; + const modelSlug = (cfg.models[0] ?? "model").replace(/[^a-zA-Z0-9]+/g, "-"); + const jobName = cfg.jobName ?? `${modelSlug}-${stamp}`; const jobDir = path.join(cfg.jobsDir, jobName); const benchDir = path.join(cfg.jobsDir, "_bench", jobName); fs.mkdirSync(benchDir, { recursive: true }); @@ -1094,6 +1392,12 @@ async function main(): Promise { } } + // source mount (default): repo bind-mounted read-only + cached linux deps tree + let source: SourceMount | null = null; + if (cfg.agent === "omp" && cfg.install === "source" && !cfg.binaryArm64 && !cfg.binaryX64) { + source = prepareSourceDeps(cfg); + } + // models.yml (gateway) let modelsYaml = ""; if (cfg.agent === "omp" && cfg.gateway) { @@ -1106,18 +1410,11 @@ async function main(): Promise { ); } } - let hostNetworkOverlayPath: string | null = null; - if (cfg.hostNetwork) { - hostNetworkOverlayPath = path.join(benchDir, "host-network-overlay.yaml"); - const content = `services: - main: - network_mode: "host" -`; - fs.writeFileSync(hostNetworkOverlayPath, content); - } + const composeOverlayPath = cfg.envType === "docker" ? writeComposeOverlay(benchDir, cfg, source) : null; + const mountsJson = cfg.envType === "docker" ? null : buildMountsJson(source); - const harborArgs = buildHarborArgs(cfg, jobName, modelsYaml, tarball, hostNetworkOverlayPath); - const harborEnv = buildHarborEnv(cfg, modelsYaml, tarball, version); + const harborArgs = buildHarborArgs(cfg, jobName, modelsYaml, tarball, composeOverlayPath, mountsJson); + const harborEnv = buildHarborEnv(cfg, modelsYaml, tarball, version, source); const logPath = path.join(benchDir, "harbor.log"); if (cfg.dryRun) { process.stdout.write(bold("\nharbor command:\n")); @@ -1127,23 +1424,28 @@ async function main(): Promise { process.stdout.write(`${fs.readFileSync(modelsYaml, "utf8")}\n`); } process.stdout.write(bold("omp env:\n")); - for (const k in harborEnv) { - if (k === "OMP_TB_FORWARD_ENV") continue; - if (k.startsWith("OMP_TB_") || k === "PYTHONPATH") process.stdout.write(` ${k}=${harborEnv[k]}\n`); + for (const key in harborEnv) { + if (key === "OMP_BENCH_FORWARD_ENV") continue; + if (key.startsWith("OMP_BENCH_") || key === "PYTHONPATH") process.stdout.write(` ${key}=${harborEnv[key]}\n`); } - if (harborEnv.OMP_TB_FORWARD_ENV) { - const keys = Object.keys(JSON.parse(harborEnv.OMP_TB_FORWARD_ENV) as Record); - process.stdout.write(` OMP_TB_FORWARD_ENV=${keys.join(",")} (values hidden)\n`); + if (harborEnv.OMP_BENCH_FORWARD_ENV) { + const parsedForwardEnv: unknown = JSON.parse(harborEnv.OMP_BENCH_FORWARD_ENV); + if (parsedForwardEnv !== null && typeof parsedForwardEnv === "object" && !Array.isArray(parsedForwardEnv)) { + const keys: string[] = []; + for (const key in parsedForwardEnv) keys.push(key); + process.stdout.write(` OMP_BENCH_FORWARD_ENV=${keys.join(",")} (values hidden)\n`); + } } process.stdout.write(`\njob dir: ${jobDir}\nbench dir: ${benchDir}\n`); - return; + return { exitCode: 0, jobName, jobDir, benchDir, tarball, elapsedMs: 0, totals: null, reportPath: null }; } // Pre-run cleanup of leftover Harbor resources, if requested. - if ((cfg.cleanup || cfg.cleanupForce) && which("docker")) { + if ((cfg.cleanup || cfg.cleanupForce) && cfg.envType === "docker" && which("docker")) { runDockerCleanup(cfg.cleanupForce); } + const gatewayForward = startVmnetGatewayForward(cfg); process.stdout.write(dim(`launching harbor → ${logPath}\n`)); const logFd = fs.openSync(logPath, "a"); const proc = Bun.spawn(["harbor", ...harborArgs], { @@ -1182,6 +1484,7 @@ async function main(): Promise { } render(st); // final frame } finally { + gatewayForward?.stop(); if (isTTY) process.stdout.write(`${ESC}?25h${ESC}?1049l`); // restore cursor + screen try { fs.closeSync(logFd); @@ -1194,23 +1497,36 @@ async function main(): Promise { // final summary (printed to the normal screen) const trials = readTrials(jobDir); - const tot = aggregate(trials, readJobResult(jobDir), expected); - const successPct = tot.done > 0 ? (tot.pass / tot.done) * 100 : 0; + const totals = aggregate(trials, readJobResult(jobDir), expected); + const successPct = totals.done > 0 ? (totals.pass / totals.done) * 100 : 0; + const elapsedMs = Date.now() - st.startMs; const reportPath = writeReport(st, benchDir, exitCode); process.stdout.write("\n"); process.stdout.write( - `${bold("terminal-bench-2 complete")} — ${green(`${tot.pass}/${tot.done} passed (${successPct.toFixed(1)}%)`)}\n`, + `${bold(`${st.cfg.dataset} complete`)} — ${green(`${totals.pass}/${totals.done} passed (${successPct.toFixed(1)}%)`)}\n`, ); process.stdout.write( - `fail ${tot.fail} · error ${tot.error} · spend ${fmtUsd(tot.costUsd)} · elapsed ${fmtDur(Date.now() - st.startMs)}\n`, + `fail ${totals.fail} · error ${totals.error} · spend ${fmtUsd(totals.costUsd)} · elapsed ${fmtDur(elapsedMs)}\n`, ); process.stdout.write( - `tokens: in ${fmtNum(tot.tokIn)} · out ${fmtNum(tot.tokOut)} · cache ${fmtNum(tot.tokCache)}\n`, + `tokens: in ${fmtNum(totals.tokIn)} · out ${fmtNum(totals.tokOut)} · cache ${fmtNum(totals.tokCache)}\n`, ); process.stdout.write(`${dim("report:")} ${reportPath}\n`); process.stdout.write(`${dim("logs: ")} ${logPath}\n`); process.stdout.write(`${dim("trials:")} ${jobDir}\n`); if (exitCode !== 0) process.stdout.write(yellow(`harbor exited ${exitCode}; see harbor.log\n`)); + return { exitCode, jobName, jobDir, benchDir, tarball, elapsedMs, totals, reportPath }; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + if (argv[0] === "cleanup") { + if (!which("docker")) throw new Error("docker not found on PATH (required for cleanup)."); + runDockerCleanup(true); + return; + } + const cfg = parseArgs(argv); + const exitCode = (await runBenchmark(cfg)).exitCode; process.exit(exitCode); } diff --git a/packages/harbor-manager/src/server.ts b/packages/harbor-manager/src/server.ts new file mode 100755 index 000000000..7ccca4368 --- /dev/null +++ b/packages/harbor-manager/src/server.ts @@ -0,0 +1,605 @@ +#!/usr/bin/env bun +/** + * harbor-manager server: REST + SSE API over the run store, static web + * dashboard, and a launcher that spawns the CLI runner as a managed child. + * + * bun src/server.ts [--port 4700] [--jobs-dir ] + * + * API: + * GET /api/experiments → experiment summaries across all benchmarks + * GET /api/runs → RunRow[] + * POST /api/runs → launch any benchmark + * GET /api/runs/:name → { run, traces } + * DELETE /api/runs/:name → cancel a managed run + * GET /api/runs/:name/traces/:trace → normalized trace + * GET /api/events → SSE: run-list snapshots on change + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Server, Subprocess } from "bun"; +import { BENCHMARK_DEFINITIONS } from "./benchmarks"; +import { buildExperiments, experimentDetail, experimentOf } from "./experiments"; +import { type BenchmarkKind, type RunRole, type RunRow, RunStore } from "./store"; + +/** PUT /api/experiments/:id body — goal and per-run role/note/label metadata. */ +export interface ExperimentMetaUpdate { + goal?: string; + runs?: Record; +} + +const INDEX_HTML_PATH = new URL("./web/index.html", import.meta.url).pathname; + +const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", ".."); +const PKG_DIR = path.resolve(import.meta.dir, ".."); +const DEFAULT_JOBS_DIR = path.join(REPO_ROOT, "runs", "harbor"); + +/** POST /api/runs body. Mirrors the runner CLI surface we actually use. */ +export interface LaunchRequest { + /** Benchmark adapter to execute. */ + benchmark?: BenchmarkKind; + model: string; + dataset?: string; + /** Task count for a dataset sample, or omit when `include` is given. */ + tasks?: number; + /** Explicit task names (passed as repeated --include). */ + include?: string[]; + concurrency?: number; + /** SnapCompact conditions; ignored by other benchmarks. */ + conditions?: string[]; + timeoutMultiplier?: number; + attempts?: number; + agent?: string; + jobName?: string; + webSearch?: boolean; + /** Downshift to a fast/cheap model at the first edit/write once the todo list exists; `into` overrides the default "smol" target. */ + downshift?: { into?: string }; + /** Role of this run inside its experiment (baseline vs treatment). */ + role?: RunRole; + /** One-line description of what this arm tests. */ + note?: string; + /** Experiment goal; upserted for the run's experiment (job-name prefix). */ + goal?: string; + /** Use prebuilt dist/omp-linux-* binaries instead of the default source mount. */ + prebuiltBinaries?: boolean; + /** Extra raw runner args, appended verbatim. */ + extraArgs?: string[]; +} + +/** POST /api/experiments/:id/arms body — a new comparable arm; sample+config inherited. */ +export interface AddArmRequest { + /** Arm label; becomes the `-` job name. */ + arm: string; + model: string; + downshift?: LaunchRequest["downshift"]; + /** Explicit task sample; skips sibling inheritance when provided. */ + include?: string[]; + role?: RunRole; + note?: string; + extraArgs?: string[]; +} + +interface ManagedChild { + proc: Subprocess; + jobName: string; + cancelled: boolean; +} + +const enum SseState { + Open = 0, + Closed = 1, +} + +interface SseClient { + controller: ReadableStreamDefaultController; + state: SseState; +} + +function parseServerArgs(argv: string[]): { port: number; jobsDir: string } { + let port = 4700; + let jobsDir = DEFAULT_JOBS_DIR; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--port" && argv[i + 1]) port = Number(argv[++i]); + else if (argv[i] === "--jobs-dir" && argv[i + 1]) jobsDir = path.resolve(argv[++i]); + } + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("--port must be 1..65535"); + return { port, jobsDir }; +} + +/** + * Resolve the launch request for a new arm added to an existing experiment. + * Inherits the experiment's benchmark, dataset, and — crucially — the exact + * task sample from a sibling arm (its recorded `include`, else its observed + * trial tasks) so the arm is directly comparable. Only per-arm knobs (model, + * downshift, role, note, extra args) come from `req`. Throws if the experiment has + * no runs to inherit from or the arm name is taken. + */ +export function resolveArmLaunch(store: RunStore, experimentId: string, req: AddArmRequest): LaunchRequest { + if (!req.arm || /[^\w.-]/.test(req.arm)) throw new Error("arm must be a non-empty [A-Za-z0-9_.-] token"); + if (!req.model) throw new Error("model is required"); + const siblings = store.listRuns().filter(r => experimentOf(r.jobName) === experimentId); + if (siblings.length === 0) throw new Error(`experiment '${experimentId}' has no runs to inherit from`); + // Template = the sibling whose recorded `include` list is the longest (the + // fullest expression of the experiment's sample — partial re-run arms + // record subsets); among include-less siblings, the most observed trials. + // listRuns is newest-first so ties keep the newest. + const strings = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; + const recordedInclude = (r: RunRow): string[] => strings((r.config as Partial).include); + const score = (r: RunRow): [number, number] => { + const recorded = recordedInclude(r).length; + return recorded > 0 ? [1, recorded] : [0, store.listTraces(r.jobName).length]; + }; + let template = siblings[0]; + let templateScore = score(template); + for (const r of siblings.slice(1)) { + const s = score(r); + if (s[0] > templateScore[0] || (s[0] === templateScore[0] && s[1] > templateScore[1])) { + [template, templateScore] = [r, s]; + } + } + const cfg = template.config as Partial; + const str = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined); + const numberOr = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined); + // Exact task sample: prefer the intended include list, else observed trial + // tasks. Trial task names are stored bare, while org-prefixed datasets + // (e.g. "swe-bench/swe-bench-verified") address tasks as "/" — + // re-derive the prefix for the fallback. + let include = req.include && req.include.length > 0 ? req.include : strings(cfg.include); + if (include.length === 0) { + const org = template.dataset.includes("/") ? `${template.dataset.split("/", 1)[0]}/` : ""; + include = [ + ...new Set( + store + .listTraces(template.jobName) + .map(t => t.task) + .filter(Boolean) + .map(task => (task.includes("/") ? task : `${org}${task}`)), + ), + ]; + } + const jobName = `${experimentId}-${req.arm}`; + if (store.getRun(jobName)) throw new Error(`arm '${req.arm}' already exists in '${experimentId}'`); + const conditions = strings(cfg.conditions); + return { + benchmark: template.benchmark, + model: req.model, + dataset: template.dataset, + include: include.length > 0 ? include : undefined, + tasks: include.length > 0 ? include.length : numberOr(cfg.tasks), + concurrency: numberOr(cfg.concurrency), + timeoutMultiplier: numberOr(cfg.timeoutMultiplier), + attempts: numberOr(cfg.attempts), + agent: str(cfg.agent), + webSearch: cfg.webSearch === true || undefined, + prebuiltBinaries: cfg.prebuiltBinaries === true || undefined, + conditions: conditions.length > 0 ? conditions : undefined, + jobName, + downshift: req.downshift, + role: req.role, + note: req.note, + extraArgs: req.extraArgs, + }; +} + +export class ManagerServer { + #store: RunStore; + #children = new Map(); + #sse = new Set(); + #lastSnapshot = ""; + #syncTimer: Timer | undefined; + #server: Server | null = null; + #appBundleCode: string | null = null; + readonly jobsDir: string; + + constructor(jobsDir: string, dbPath?: string) { + this.jobsDir = jobsDir; + this.#store = new RunStore(jobsDir, dbPath); + } + + get store(): RunStore { + return this.#store; + } + + start(port: number): Server { + this.#store.discover(); + this.#store.syncAll(); + this.#syncTimer = setInterval(() => this.#tick(), 2000); + this.#server = Bun.serve({ + port, + idleTimeout: 0, + fetch: request => this.#route(request), + }); + return this.#server; + } + + async stop(): Promise { + clearInterval(this.#syncTimer); + for (const client of this.#sse) { + client.state = SseState.Closed; + try { + client.controller.close(); + } catch {} + } + this.#sse.clear(); + this.#server?.stop(true); + this.#store.close(); + } + + #tick(): void { + this.#store.syncActive(); + const snapshot = JSON.stringify(this.#store.listRuns()); + if (snapshot !== this.#lastSnapshot) { + this.#lastSnapshot = snapshot; + this.#broadcast(`data: ${snapshot}\n\n`); + } + } + + /** Bundle the React dashboard once per process; served at /app.tsx (matches the Vite dev entry). */ + async #appBundle(): Promise { + if (this.#appBundleCode !== null) return this.#appBundleCode; + const result = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "web", "app.tsx")], + target: "browser", + minify: true, + define: { "process.env.NODE_ENV": '"production"' }, + }); + if (!result.success) { + throw new Error(`dashboard bundle failed:\n${result.logs.map(l => l.message).join("\n")}`); + } + this.#appBundleCode = await result.outputs[0].text(); + return this.#appBundleCode; + } + + #broadcast(frame: string): void { + const bytes = new TextEncoder().encode(frame); + for (const client of this.#sse) { + if (client.state === SseState.Closed) continue; + try { + client.controller.enqueue(bytes); + } catch { + client.state = SseState.Closed; + this.#sse.delete(client); + } + } + } + + async #route(request: Request): Promise { + const url = new URL(request.url); + const p = url.pathname; + try { + if (p === "/" || p === "/index.html") { + return new Response(Bun.file(INDEX_HTML_PATH)); + } + if (p === "/app.tsx") { + return new Response(await this.#appBundle(), { + headers: { "content-type": "text/javascript; charset=utf-8" }, + }); + } + if (p === "/api/events") return this.#sseResponse(); + if (p === "/api/benchmarks" && request.method === "GET") { + return Response.json(BENCHMARK_DEFINITIONS); + } + if (p === "/api/experiments" && request.method === "GET") { + return Response.json(buildExperiments(this.#store)); + } + const expMatch = p.match(/^\/api\/experiments\/([^/]+)$/); + if (expMatch) { + const id = decodeURIComponent(expMatch[1]); + if (request.method === "PUT") { + const body = (await request.json()) as ExperimentMetaUpdate; + return Response.json(this.updateExperimentMeta(id, body)); + } + const detail = experimentDetail(this.#store, id); + if (!detail) return Response.json({ error: "experiment not found" }, { status: 404 }); + return Response.json(detail); + } + const armMatch = p.match(/^\/api\/experiments\/([^/]+)\/arms$/); + if (armMatch && request.method === "POST") { + const id = decodeURIComponent(armMatch[1]); + const body = (await request.json()) as AddArmRequest; + return Response.json(this.addArm(id, body), { status: 201 }); + } + if (p === "/api/runs" && request.method === "GET") { + return Response.json(this.#store.listRuns()); + } + if (p === "/api/runs" && request.method === "POST") { + const body = (await request.json()) as LaunchRequest; + return Response.json(this.launch(body), { status: 201 }); + } + const runMatch = p.match(/^\/api\/runs\/([^/]+)$/); + if (runMatch) { + const jobName = decodeURIComponent(runMatch[1]); + if (request.method === "DELETE") return Response.json(this.cancel(jobName)); + const run = this.#store.syncRun(jobName); + if (!run) return Response.json({ error: "run not found" }, { status: 404 }); + return Response.json({ run, traces: this.#store.listTraces(jobName) }); + } + const traceMatch = p.match(/^\/api\/runs\/([^/]+)\/traces\/([^/]+)$/); + if (traceMatch) { + const jobName = decodeURIComponent(traceMatch[1]); + const trace = decodeURIComponent(traceMatch[2]); + const tail = Number(url.searchParams.get("tail") ?? "120"); + const raw = url.searchParams.get("raw") === "1"; + return this.#trace(jobName, trace, tail, raw); + } + return Response.json({ error: "not found" }, { status: 404 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return Response.json({ error: message }, { status: 400 }); + } + } + + #sseResponse(): Response { + let client: SseClient; + const sse = this.#sse; + const initial = `data: ${JSON.stringify(this.#store.listRuns())}\n\n`; + const stream = new ReadableStream({ + start(controller) { + client = { controller, state: SseState.Open }; + sse.add(client); + controller.enqueue(new TextEncoder().encode(initial)); + }, + cancel() { + client.state = SseState.Closed; + sse.delete(client); + }, + }); + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); + } + + /** Launch any supported benchmark and register it in the uniform run store. */ + launch(request: LaunchRequest): { jobName: string; pid: number } { + if (!request.model) throw new Error("model is required"); + const benchmark = request.benchmark ?? "harbor"; + if (benchmark !== "harbor" && benchmark !== "edit" && benchmark !== "snapcompact") { + throw new Error(`unsupported benchmark: ${benchmark}`); + } + const dataset = + request.dataset ?? + (benchmark === "harbor" ? "terminal-bench@2.0" : benchmark === "edit" ? "typescript-edit" : "squad-dev"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const modelSlug = request.model.replace(/[^a-zA-Z0-9]+/g, "-"); + const jobName = request.jobName ?? `${modelSlug}-${stamp}`; + if (this.#children.has(jobName) || this.#store.getRun(jobName)?.status === "running") { + throw new Error(`run ${jobName} is already running`); + } + const jobDir = path.join(this.jobsDir, jobName); + fs.mkdirSync(jobDir, { recursive: true }); + + let argv: string[]; + let cwd: string; + if (benchmark === "edit") { + cwd = PKG_DIR; + argv = ["bun", "adapters/edit/cli.ts", "--model", request.model, "--output", path.join(jobDir, "result.json")]; + if (request.tasks !== undefined) argv.push("--max-tasks", String(request.tasks)); + if (request.include?.length) argv.push("--tasks", request.include.join(",")); + if (request.concurrency !== undefined) argv.push("--task-concurrency", String(request.concurrency)); + if (request.attempts !== undefined) argv.push("--runs", String(request.attempts)); + } else if (benchmark === "snapcompact") { + cwd = PKG_DIR; + argv = ["uv", "run", "src/adapters/snapcompact.py", "--model", request.model, "--output-dir", jobDir]; + if (request.tasks !== undefined) argv.push("--limit-paras", String(request.tasks)); + if (request.concurrency !== undefined) argv.push("--workers", String(request.concurrency)); + if (request.conditions?.length) argv.push("--conditions", request.conditions.join(",")); + } else { + cwd = PKG_DIR; + argv = [ + "bun", + "src/runner.ts", + "--model", + request.model, + "-d", + dataset, + "--job-name", + jobName, + "--jobs-dir", + this.jobsDir, + ]; + if (request.agent) argv.push("--agent", request.agent); + if (request.tasks !== undefined) argv.push("--tasks", String(request.tasks)); + if (request.concurrency !== undefined) argv.push("--concurrency", String(request.concurrency)); + if (request.attempts !== undefined) argv.push("--attempts", String(request.attempts)); + if (request.timeoutMultiplier !== undefined) + argv.push("--timeout-multiplier", String(request.timeoutMultiplier)); + if (request.webSearch) argv.push("--web-search"); + for (const task of request.include ?? []) argv.push("--include", task); + if (request.downshift) { + argv.push("--agent-arg", "--downshift"); + if (request.downshift.into) { + argv.push("--agent-arg", "--downshift-into", "--agent-arg", request.downshift.into); + const provider = request.downshift.into.split("/", 1)[0]; + if (provider && request.downshift.into.includes("/")) argv.push("--providers", provider); + } + } + if (request.prebuiltBinaries) { + for (const name of ["omp-linux-arm64", "omp-linux-x64"]) { + const binary = path.join(REPO_ROOT, "packages", "coding-agent", "dist", name); + if (fs.existsSync(binary)) argv.push("--binary", binary); + } + } + } + argv.push(...(request.extraArgs ?? [])); + + const logDir = path.join(this.jobsDir, "_manager", "logs"); + fs.mkdirSync(logDir, { recursive: true }); + const logFile = fs.openSync(path.join(logDir, `${jobName}.log`), "w"); + const proc = Bun.spawn(argv, { + cwd, + stdout: logFile, + stderr: logFile, + env: { ...process.env }, + }); + const child: ManagedChild = { proc, jobName, cancelled: false }; + this.#children.set(jobName, child); + proc.exited.then(exitCode => { + this.#store.markExit(jobName, exitCode, child.cancelled); + // Final sync AFTER the terminal state: the ticker only revisits + // running rows, so the last-2s trial results would otherwise be lost. + this.#store.syncRun(jobName); + this.#children.delete(jobName); + this.#tick(); + }); + this.#store.registerLaunch({ + benchmark, + jobName, + dataset, + agent: request.agent ?? "omp", + models: [request.model], + downshift: request.downshift, + config: { ...request }, + pid: proc.pid, + role: request.role, + note: request.note, + }); + if (request.goal) this.#store.setExperimentGoal(experimentOf(jobName), request.goal); + this.#tick(); + return { jobName, pid: proc.pid }; + } + + /** Apply goal + per-run role/note metadata; used by the UI and for backfill. */ + updateExperimentMeta(id: string, update: ExperimentMetaUpdate): { id: string; updatedRuns: string[] } { + if (update.goal !== undefined) this.#store.setExperimentGoal(id, update.goal); + const updatedRuns: string[] = []; + for (const [jobName, meta] of Object.entries(update.runs ?? {})) { + if (experimentOf(jobName) !== id) continue; + if (this.#store.setRunMeta(jobName, meta)) updatedRuns.push(jobName); + } + this.#tick(); + return { id, updatedRuns }; + } + + /** Add a comparable arm to an existing experiment, inheriting its sample + config. */ + addArm(experimentId: string, req: AddArmRequest): { jobName: string; pid: number } { + return this.launch(resolveArmLaunch(this.#store, experimentId, req)); + } + + /** Cancel a managed run. SIGTERM first so the runner forwards the signal to + * its harbor child (SIGKILL is untrappable — it used to orphan the harbor + * process, which kept running trials into the job dir); escalates to + * SIGKILL after a grace window. */ + cancel(jobName: string): { jobName: string; cancelled: boolean } { + const child = this.#children.get(jobName); + if (child) { + child.cancelled = true; + child.proc.kill("SIGTERM"); + const escalate = setTimeout(() => { + try { + child.proc.kill(9); + } catch {} + }, 5000); + child.proc.exited.then(() => clearTimeout(escalate)); + return { jobName, cancelled: true }; + } + const run = this.#store.getRun(jobName); + if (run?.pid != null) { + const pid = run.pid; + try { + process.kill(pid, "SIGTERM"); + } catch {} + setTimeout(() => { + try { + process.kill(pid, "SIGKILL"); + } catch {} + }, 5000); + this.#store.markExit(jobName, null, true); + return { jobName, cancelled: true }; + } + return { jobName, cancelled: false }; + } + + /** Return a normalized trace regardless of the benchmark's native artifact format. */ + #trace(jobName: string, traceName: string, tail: number, raw: boolean): Response { + const trace = this.#store.listTraces(jobName).find(item => item.name === traceName); + if (!trace?.tracePath) return Response.json({ error: "trace not found" }, { status: 404 }); + const jobDir = path.join(this.jobsDir, jobName); + const n = Number.isSafeInteger(tail) && tail > 0 ? Math.min(tail, 2000) : 120; + if (trace.tracePath.startsWith("record:")) { + const lineNumber = Number(trace.tracePath.slice("record:".length)); + const line = fs.readFileSync(path.join(jobDir, "records.jsonl"), "utf8").split("\n")[lineNumber - 1]; + if (!line) return Response.json({ error: "trace not found" }, { status: 404 }); + if (raw) return new Response(line, { headers: { "content-type": "application/json" } }); + const record = JSON.parse(line) as Record; + return Response.json({ + jobName, + trace: traceName, + entries: [ + { kind: "question", text: String(record.q ?? "") }, + { kind: "answer", model: this.#store.getRun(jobName)?.models ?? "", text: String(record.answer ?? "") }, + { kind: "reference", text: JSON.stringify(record.golds ?? []) }, + ], + totalEvents: 3, + }); + } + const file = path.resolve(jobDir, trace.tracePath); + if (!file.startsWith(`${path.resolve(jobDir)}${path.sep}`) || !fs.existsSync(file)) { + return Response.json({ error: "trace not found" }, { status: 404 }); + } + const text = fs.readFileSync(file, "utf8"); + if (!file.endsWith(".txt")) { + if (raw) return new Response(text, { headers: { "content-type": "text/plain; charset=utf-8" } }); + return Response.json({ + jobName, + trace: traceName, + entries: [{ kind: "conversation", text }], + totalEvents: 1, + }); + } + const lines = text.split("\n").filter(Boolean); + if (raw) { + return new Response(lines.slice(-n).join("\n"), { + headers: { "content-type": "application/x-ndjson" }, + }); + } + const entries: Array> = []; + for (const line of lines) { + let event: Record; + try { + event = JSON.parse(line) as Record; + } catch { + continue; + } + if (event.type === "message_end") { + const message = event.message as Record | undefined; + if (!message) continue; + const content = Array.isArray(message.content) ? (message.content as Array>) : []; + const body = content + .filter(block => block.type === "text") + .map(block => String(block.text ?? "")) + .join("\n"); + if (message.role === "assistant") { + const tools = content.filter(block => block.type === "toolCall").map(block => String(block.name ?? "?")); + entries.push({ kind: "assistant", model: message.model ?? "", text: body, tools }); + } else if (message.role === "toolResult") { + entries.push({ + kind: "toolResult", + tool: message.toolName ?? "?", + isError: message.isError === true, + text: body.length > 1600 ? `${body.slice(0, 1600)}…` : body, + }); + } + } else if (event.type === "notice") { + entries.push({ kind: "notice", text: event.message ?? "" }); + } + } + return Response.json({ jobName, trace: traceName, entries: entries.slice(-n), totalEvents: lines.length }); + } +} + +if (import.meta.main) { + const { port, jobsDir } = parseServerArgs(process.argv.slice(2)); + const manager = new ManagerServer(jobsDir); + const server = manager.start(port); + process.stdout.write(`harbor-manager listening on http://localhost:${server.port} (jobs: ${jobsDir})\n`); + const shutdown = async () => { + await manager.stop(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/packages/harbor-manager/src/store.ts b/packages/harbor-manager/src/store.ts new file mode 100644 index 000000000..cf3653375 --- /dev/null +++ b/packages/harbor-manager/src/store.ts @@ -0,0 +1,502 @@ +/** + * SQLite-backed store for Harbor runs managed by this package. + * + * The filesystem stays the source of truth (Harbor writes `result.json` + * per job and per trial); the store mirrors it into queryable rows and adds + * manager-owned metadata Harbor has no notion of: launch pid, requested + * config, lifecycle status. `syncRun` re-reads a job dir and upserts. + */ + +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { readBenchmarkSnapshot } from "./benchmarks"; +import { readJobResult } from "./runner"; + +export type RunStatus = "running" | "complete" | "failed" | "cancelled"; + +/** Benchmark implementation that produced a run. */ +export type BenchmarkKind = "harbor" | "edit" | "snapcompact"; + +/** How a run relates to its experiment's question. */ +export type RunRole = "baseline" | "variant" | ""; + +export interface RunRow { + benchmark: BenchmarkKind; + jobName: string; + dataset: string; + agent: string; + models: string; + /** JSON downshift config (`{ into?: string }`); older rows may hold legacy reasoning-slide JSON. */ + downshift: string | null; + /** Benchmark-specific launch configuration. */ + config: Record; + /** Role inside the experiment (baseline vs treatment); "" when unspecified. */ + role: RunRole; + /** One-line description of what this arm tests (e.g. "downshift→flash at first edit/write"). */ + note: string; + /** Display-name override for the arm; "" falls back to the jobName-derived arm label. */ + label: string; + status: RunStatus; + pid: number | null; + exitCode: number | null; + createdAt: number; + finishedAt: number | null; + nTotal: number; + done: number; + pass: number; + fail: number; + error: number; + running: number; + costUsd: number; + tokIn: number; + tokOut: number; + tokCache: number; + /** Benchmark-native aggregate score, when the benchmark exposes one. */ + score: number | null; + /** Values keyed by the adapter's metric definitions. */ + metrics: Record; +} + +export interface TraceRow { + jobName: string; + name: string; + task: string; + status: string; + reward: number | null; + costUsd: number; + durationMs: number; + detail: string; + updatedAt: number; + /** Adapter-owned locator used by the uniform trace endpoint. */ + tracePath: string | null; +} + +export interface LaunchRecord { + benchmark: BenchmarkKind; + jobName: string; + dataset: string; + agent: string; + models: string[]; + downshift?: { into?: string }; + pid: number; + role?: RunRole; + note?: string; + config?: Record; +} + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS runs ( + job_name TEXT PRIMARY KEY, + benchmark TEXT NOT NULL DEFAULT 'harbor', + dataset TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL DEFAULT 'omp', + models TEXT NOT NULL DEFAULT '', + downshift TEXT, + role TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + label TEXT NOT NULL DEFAULT '', + config_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'running', + pid INTEGER, + exit_code INTEGER, + created_at INTEGER NOT NULL, + finished_at INTEGER, + n_total INTEGER NOT NULL DEFAULT 0, + done INTEGER NOT NULL DEFAULT 0, + pass INTEGER NOT NULL DEFAULT 0, + fail INTEGER NOT NULL DEFAULT 0, + error INTEGER NOT NULL DEFAULT 0, + running INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0, + tok_in INTEGER NOT NULL DEFAULT 0, + tok_out INTEGER NOT NULL DEFAULT 0, + score REAL, + metrics_json TEXT NOT NULL DEFAULT '{}', + tok_cache INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS trials ( + job_name TEXT NOT NULL, + name TEXT NOT NULL, + task TEXT NOT NULL, + status TEXT NOT NULL, + reward REAL, + cost_usd REAL NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + trace_path TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (job_name, name) +); +CREATE INDEX IF NOT EXISTS idx_trials_job ON trials(job_name); +CREATE TABLE IF NOT EXISTS experiments ( + id TEXT PRIMARY KEY, + goal TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL +); +`; + +/** Directory names inside the jobs root that are not Harbor job dirs. */ +const NON_JOB_DIRS = new Set(["_bench", "_manager"]); + +export class RunStore { + #db: Database; + readonly jobsDir: string; + + constructor(jobsDir: string, dbPath?: string) { + this.jobsDir = jobsDir; + fs.mkdirSync(path.join(jobsDir, "_manager"), { recursive: true }); + this.#db = new Database(dbPath ?? path.join(jobsDir, "_manager", "harbor-manager.sqlite")); + this.#db.run("PRAGMA journal_mode = WAL"); + this.#db.run(SCHEMA); + const runColumns = new Set( + (this.#db.query("PRAGMA table_info(runs)").all() as Array<{ name: string }>).map(c => c.name), + ); + if (!runColumns.has("role")) this.#db.run("ALTER TABLE runs ADD COLUMN role TEXT NOT NULL DEFAULT ''"); + if (!runColumns.has("note")) this.#db.run("ALTER TABLE runs ADD COLUMN note TEXT NOT NULL DEFAULT ''"); + if (!runColumns.has("label")) this.#db.run("ALTER TABLE runs ADD COLUMN label TEXT NOT NULL DEFAULT ''"); + if (!runColumns.has("benchmark")) { + this.#db.run("ALTER TABLE runs ADD COLUMN benchmark TEXT NOT NULL DEFAULT 'harbor'"); + } + if (!runColumns.has("config_json")) { + this.#db.run("ALTER TABLE runs ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}'"); + } + if (!runColumns.has("score")) this.#db.run("ALTER TABLE runs ADD COLUMN score REAL"); + if (!runColumns.has("metrics_json")) { + this.#db.run("ALTER TABLE runs ADD COLUMN metrics_json TEXT NOT NULL DEFAULT '{}'"); + } + if (runColumns.has("slide") && !runColumns.has("downshift")) { + this.#db.run("ALTER TABLE runs RENAME COLUMN slide TO downshift"); + } + if (!runColumns.has("slide") && !runColumns.has("downshift")) { + this.#db.run("ALTER TABLE runs ADD COLUMN downshift TEXT"); + } + const traceColumns = new Set( + (this.#db.query("PRAGMA table_info(trials)").all() as Array<{ name: string }>).map(c => c.name), + ); + if (!traceColumns.has("trace_path")) this.#db.run("ALTER TABLE trials ADD COLUMN trace_path TEXT"); + } + + close(): void { + this.#db.close(); + } + + /** Register a run this manager just launched (pid-owning). */ + registerLaunch(launch: LaunchRecord): void { + this.#db.query("DELETE FROM trials WHERE job_name = ?").run(launch.jobName); + this.#db + .query( + `INSERT INTO runs + (job_name, benchmark, dataset, agent, models, downshift, role, note, config_json, status, pid, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?) + ON CONFLICT(job_name) DO UPDATE SET + benchmark = excluded.benchmark, pid = excluded.pid, status = 'running', + config_json = excluded.config_json, + role = CASE WHEN excluded.role != '' THEN excluded.role ELSE runs.role END, + note = CASE WHEN excluded.note != '' THEN excluded.note ELSE runs.note END`, + ) + .run( + launch.jobName, + launch.benchmark, + launch.dataset, + launch.agent, + launch.models.join(","), + launch.downshift ? JSON.stringify(launch.downshift) : null, + launch.role ?? "", + launch.note ?? "", + JSON.stringify(launch.config ?? {}), + launch.pid, + Date.now(), + ); + const jobDir = path.join(this.jobsDir, launch.jobName); + fs.mkdirSync(jobDir, { recursive: true }); + fs.writeFileSync(path.join(jobDir, "manager.json"), JSON.stringify(launch, null, 2)); + } + + /** Upsert the experiment's stated goal. */ + setExperimentGoal(id: string, goal: string): void { + this.#db + .query( + `INSERT INTO experiments (id, goal, updated_at) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET goal = excluded.goal, updated_at = excluded.updated_at`, + ) + .run(id, goal, Date.now()); + } + + getExperimentGoal(id: string): string { + const row = this.#db.query("SELECT goal FROM experiments WHERE id = ?").get(id) as { goal: string } | null; + return row?.goal ?? ""; + } + + /** Set role/note/label metadata on an existing run row. */ + setRunMeta(jobName: string, meta: { role?: RunRole; note?: string; label?: string }): boolean { + const existing = this.getRun(jobName); + if (!existing) return false; + this.#db + .query("UPDATE runs SET role = ?, note = ?, label = ? WHERE job_name = ?") + .run(meta.role ?? existing.role, meta.note ?? existing.note, meta.label ?? existing.label, jobName); + return true; + } + + /** Mark a launched run's terminal state (called when its child process exits). */ + markExit(jobName: string, exitCode: number | null, cancelled = false): void { + const status: RunStatus = cancelled ? "cancelled" : exitCode === 0 ? "complete" : "failed"; + this.#db + .query("UPDATE runs SET status = ?, exit_code = ?, finished_at = ?, pid = NULL WHERE job_name = ?") + .run(status, exitCode, Date.now(), jobName); + } + + /** + * Discover job dirs on disk that have no run row yet (runs launched by the + * CLI or a previous manager instance) and backfill them as historical rows. + */ + discover(): number { + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(this.jobsDir, { withFileTypes: true }); + } catch { + return 0; + } + const known = new Set( + (this.#db.query("SELECT job_name FROM runs").all() as Array<{ job_name: string }>).map(r => r.job_name), + ); + let added = 0; + for (const e of entries) { + if (!e.isDirectory() || NON_JOB_DIRS.has(e.name) || known.has(e.name)) continue; + const jobDir = path.join(this.jobsDir, e.name); + const meta = readHarborConfig(jobDir); + const createdAt = dirCreatedAt(jobDir); + this.#db + .query( + `INSERT INTO runs (job_name, dataset, agent, models, status, created_at) + VALUES (?, ?, ?, ?, 'running', ?)`, + ) + .run(e.name, meta.dataset, meta.agent, meta.models, createdAt); + this.syncRun(e.name); + added++; + } + return added; + } + + /** Re-read a job dir from disk and mirror trial + rollup state into the DB. */ + syncRun(jobName: string): RunRow | null { + const jobDir = path.join(this.jobsDir, jobName); + if (!fs.existsSync(jobDir)) return this.getRun(jobName); + const row = this.getRun(jobName); + if (!row) return null; + const snapshot = readBenchmarkSnapshot(row.benchmark, jobDir); + const now = Date.now(); + const upsert = this.#db.query( + `INSERT INTO trials + (job_name, name, task, status, reward, cost_usd, duration_ms, detail, trace_path, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(job_name, name) DO UPDATE SET + status = excluded.status, reward = excluded.reward, cost_usd = excluded.cost_usd, + duration_ms = excluded.duration_ms, detail = excluded.detail, + trace_path = excluded.trace_path, updated_at = excluded.updated_at`, + ); + const tx = this.#db.transaction(() => { + for (const trace of snapshot.traces) { + upsert.run( + jobName, + trace.name, + trace.task, + trace.status, + trace.reward, + trace.costUsd, + trace.durationMs, + trace.detail, + trace.tracePath, + now, + ); + } + this.#db + .query( + `UPDATE runs SET n_total = ?, done = ?, pass = ?, fail = ?, error = ?, running = ?, + cost_usd = ?, tok_in = ?, tok_out = ?, tok_cache = ?, score = ?, metrics_json = ? + WHERE job_name = ?`, + ) + .run( + snapshot.total, + snapshot.done, + snapshot.pass, + snapshot.fail, + snapshot.error, + snapshot.running, + snapshot.costUsd, + snapshot.tokIn, + snapshot.tokOut, + snapshot.tokCache, + snapshot.score, + JSON.stringify(snapshot.metrics), + jobName, + ); + // Historical Harbor runs have no owning process. Infer their terminal + // state from result metadata or directory freshness. + if (row.benchmark === "harbor" && row.pid === null && row.finishedAt === null && row.status !== "cancelled") { + const result = readJobResult(jobDir); + let status: RunStatus; + let finishedAt: number | null = null; + if (result?.finishedAt != null) { + status = "complete"; + finishedAt = result.finishedAt; + } else if (jobDirFresh(jobDir)) { + status = "running"; + } else { + status = snapshot.done > 0 && snapshot.done >= snapshot.total ? "complete" : "failed"; + finishedAt = jobDirMtime(jobDir); + } + if (status !== row.status) { + this.#db + .query("UPDATE runs SET status = ?, finished_at = ? WHERE job_name = ?") + .run(status, finishedAt, jobName); + } + } + }); + tx(); + return this.getRun(jobName); + } + + /** Sync every run currently marked running; returns the refreshed rows. */ + syncActive(): RunRow[] { + const active = this.#db.query("SELECT job_name FROM runs WHERE status = 'running'").all() as Array<{ + job_name: string; + }>; + const out: RunRow[] = []; + for (const { job_name } of active) { + // A pid-owning run whose process died without markExit (manager restart) + // is finalized here so it doesn't stay "running" forever. + const row = this.getRun(job_name); + if (row?.pid != null && !processAlive(row.pid)) { + this.markExit(job_name, null); + } + const synced = this.syncRun(job_name); + if (synced) out.push(synced); + } + return out; + } + + /** + * Sync every known run once — startup reconciliation. Rows stamped before a + * status-inference change (or by an older manager) self-correct here, since + * the periodic ticker only revisits rows already marked running. + */ + syncAll(): void { + const rows = this.#db.query("SELECT job_name FROM runs").all() as Array<{ job_name: string }>; + for (const { job_name } of rows) this.syncRun(job_name); + } + + getRun(jobName: string): RunRow | null { + const r = this.#db.query("SELECT * FROM runs WHERE job_name = ?").get(jobName) as Record | null; + return r ? rowToRun(r) : null; + } + + listRuns(): RunRow[] { + const rows = this.#db.query("SELECT * FROM runs ORDER BY created_at DESC").all() as Array< + Record + >; + return rows.map(rowToRun); + } + + listTraces(jobName: string): TraceRow[] { + const rows = this.#db.query("SELECT * FROM trials WHERE job_name = ? ORDER BY name").all(jobName) as Array< + Record + >; + return rows.map(r => ({ + jobName: String(r.job_name), + name: String(r.name), + task: String(r.task), + status: String(r.status), + reward: r.reward === null ? null : Number(r.reward), + costUsd: Number(r.cost_usd), + durationMs: Number(r.duration_ms), + detail: String(r.detail), + updatedAt: Number(r.updated_at), + tracePath: r.trace_path === null ? null : String(r.trace_path), + })); + } +} + +function rowToRun(r: Record): RunRow { + return { + benchmark: String(r.benchmark ?? "harbor") as BenchmarkKind, + jobName: String(r.job_name), + dataset: String(r.dataset), + agent: String(r.agent), + models: String(r.models), + downshift: r.downshift === null ? null : String(r.downshift), + config: JSON.parse(String(r.config_json ?? "{}")), + role: String(r.role ?? "") as RunRole, + note: String(r.note ?? ""), + label: String(r.label ?? ""), + status: String(r.status) as RunStatus, + pid: r.pid === null ? null : Number(r.pid), + exitCode: r.exit_code === null ? null : Number(r.exit_code), + createdAt: Number(r.created_at), + finishedAt: r.finished_at === null ? null : Number(r.finished_at), + nTotal: Number(r.n_total), + done: Number(r.done), + pass: Number(r.pass), + fail: Number(r.fail), + error: Number(r.error), + running: Number(r.running), + costUsd: Number(r.cost_usd), + tokIn: Number(r.tok_in), + tokOut: Number(r.tok_out), + tokCache: Number(r.tok_cache), + score: r.score === null ? null : Number(r.score), + metrics: JSON.parse(String(r.metrics_json ?? "{}")), + }; +} + +/** Best-effort launch metadata for historical (CLI-launched) job dirs. */ +function readHarborConfig(jobDir: string): { dataset: string; agent: string; models: string } { + try { + const raw = JSON.parse(fs.readFileSync(path.join(jobDir, "config.json"), "utf8")) as Record; + const dataset = + typeof raw.dataset === "string" + ? raw.dataset + : (((raw.datasets as Array> | undefined)?.[0]?.name as string | undefined) ?? ""); + const agents = raw.agents as Array> | undefined; + const agent = (agents?.[0]?.name as string | undefined) ?? "omp"; + const models = (agents?.[0]?.model_name as string | undefined) ?? ""; + return { dataset: String(dataset), agent, models }; + } catch { + return { dataset: "", agent: "omp", models: "" }; + } +} + +function dirCreatedAt(dir: string): number { + try { + return Math.round(fs.statSync(dir).birthtimeMs || fs.statSync(dir).mtimeMs); + } catch { + return Date.now(); + } +} + +/** Stale threshold for foreign runs without a terminal marker. */ +const JOB_DIR_STALE_MS = 30 * 60 * 1000; + +/** Newest mtime across the job dir and its result.json (cheap freshness probe). */ +function jobDirMtime(dir: string): number { + let newest = 0; + for (const p of [dir, path.join(dir, "result.json")]) { + try { + newest = Math.max(newest, fs.statSync(p).mtimeMs); + } catch {} + } + return Math.round(newest) || Date.now(); +} + +function jobDirFresh(dir: string): boolean { + return Date.now() - jobDirMtime(dir) < JOB_DIR_STALE_MS; +} + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/packages/harbor-manager/src/web/app.tsx b/packages/harbor-manager/src/web/app.tsx new file mode 100644 index 000000000..d951f4719 --- /dev/null +++ b/packages/harbor-manager/src/web/app.tsx @@ -0,0 +1,1972 @@ +/** + * harbor-manager dashboard. + * + * Views (hash-routed): + * #/ experiments index — runs grouped by job-name prefix + * #/exp/ experiment detail — arm table, dithered comparison charts + * (projected values for in-flight arms, dimmed), task matrix + * #/runs flat run list (legacy view) + * #/runs/ run detail — normalized trace grid + live trace viewer + */ +import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; + +// ── api types (mirrors server modules) ────────────────────────────────────── + +/** How a run relates to its experiment's question. */ +type RunRole = "baseline" | "variant" | ""; + +interface RunRow { + benchmark: "harbor" | "edit" | "snapcompact"; + jobName: string; + dataset: string; + agent: string; + models: string; + downshift: string | null; + config: Record; + role: RunRole; + note: string; + label: string; + status: "running" | "complete" | "failed" | "cancelled"; + pid: number | null; + createdAt: number; + finishedAt: number | null; + nTotal: number; + done: number; + pass: number; + fail: number; + error: number; + running: number; + costUsd: number; + score: number | null; + metrics: Record; +} + +interface TraceRow { + name: string; + task: string; + status: string; + reward: number | null; + costUsd: number; + durationMs: number; + detail: string; +} + +interface ArmProjection { + etaMs: number | null; + passPct: number; + costPerTask: number; + totalCostUsd: number; + meanTrialMs: number; +} + +interface ArmSummary { + run: RunRow; + arm: string; + config: string; + passPct: number | null; + costPerTask: number | null; + meanTrialMs: number | null; + projected: ArmProjection | null; +} + +interface ExperimentSummary { + id: string; + goal: string; + arms: number; + runningArms: number; + datasets: string[]; + nTotal: number; + done: number; + pass: number; + fail: number; + error: number; + costUsd: number; + updatedAt: number; +} + +interface ExperimentDetail { + id: string; + goal: string; + arms: ArmSummary[]; + tasks: string[]; + matrix: Record>; +} + +interface TranscriptEntry { + kind: string; + model?: string; + tool?: string; + isError?: boolean; + text?: string; + tools?: string[]; +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +const fmtUsd = (v: number) => (v >= 100 ? `$${v.toFixed(0)}` : v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(3)}`); +const fmtMin = (ms: number) => `${(ms / 60000).toFixed(1)}m`; +const fmtEta = (etaMs: number | null) => { + if (etaMs === null) return "—"; + const mins = Math.max(0, Math.round((etaMs - Date.now()) / 60000)); + return mins >= 90 ? `~${(mins / 60).toFixed(1)}h` : `~${mins}m`; +}; + +async function getJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`${url}: ${res.status}`); + return (await res.json()) as T; +} + +function useHashRoute(): string { + const [hash, setHash] = useState(location.hash || "#/"); + useEffect(() => { + const onChange = () => setHash(location.hash || "#/"); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + return hash; +} + +/** Poll a JSON endpoint on an interval (SSE covers the run list; details poll). + * Returns the latest payload plus a manual refresh for after mutations. */ +function usePolled(url: string | null, intervalMs: number): [T | null, () => void] { + const [data, setData] = useState(null); + const [nonce, setNonce] = useState(0); + useEffect(() => { + void nonce; // manual refresh dependency + if (!url) return; + let live = true; + const load = () => + getJson(url) + .then(d => live && setData(d)) + .catch(() => {}); + load(); + const timer = setInterval(load, intervalMs); + return () => { + live = false; + clearInterval(timer); + }; + }, [url, intervalMs, nonce]); + const refresh = useCallback(() => setNonce(n => n + 1), []); + return [data, refresh]; +} + +const INPUT_CLASS = "rounded border border-zinc-700 bg-zinc-950 px-2 py-1 text-sm"; + +const STATUS_CLASS: Record = { + running: "text-sky-400 border-sky-400", + complete: "text-emerald-400 border-emerald-400", + failed: "text-red-400 border-red-400", + cancelled: "text-zinc-500 border-zinc-500", + pass: "text-emerald-400 border-emerald-400", + fail: "text-red-400 border-red-400", + error: "text-amber-400 border-amber-400", +}; + +function Chip({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function Progress({ + run, +}: { + run: RunRow | { pass: number; fail: number; error: number; running: number; done: number; nTotal: number }; +}) { + const total = Math.max(run.nTotal, run.done + run.running, 1); + const seg = (n: number) => `${(100 * n) / total}%`; + return ( + + + + + + + + + {run.done}/{run.nTotal || "?"} + + + ); +} + +// ── experiments index ──────────────────────────────────────────────────────── + +function ExperimentsIndex() { + const [experiments] = usePolled("/api/experiments", 3000); + if (!experiments) return

      loading…
      ; + return ( +
      + ); +} + +// ── experiment detail ──────────────────────────────────────────────────────── + +/** Display name for an arm: user-set label when present, else the jobName-derived arm. */ +const armName = (a: ArmSummary) => a.run.label || a.arm; + +/** Short task id for chips and the matrix: drops the dataset prefix and the `repo__` stutter. */ +function shortTask(task: string): string { + const base = task.slice(task.lastIndexOf("/") + 1); + const us = base.lastIndexOf("__"); + return us >= 0 ? base.slice(us + 2) : base; +} + +/** PUT experiment metadata: goal and/or per-run label/note/role. */ +async function putExperimentMeta( + id: string, + body: { goal?: string; runs?: Record }, +): Promise { + const res = await fetch(`/api/experiments/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const out = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(out?.error ?? `save failed (${res.status})`); + } +} + +function RoleTag({ role }: { role: RunRole }) { + return ( + + {role} + + ); +} + +// ── arm table sorting ──────────────────────────────────────────────────────── + +type SortKey = "arm" | "note" | "status" | "progress" | "eta" | "pass" | "cost" | "time"; + +interface SortSpec { + key: SortKey; + dir: 1 | -1; +} + +/** First-click direction per column: metrics start at "best first". */ +const SORT_START_DIR: Record = { + arm: 1, + note: 1, + status: 1, + progress: -1, + eta: 1, + pass: -1, + cost: 1, + time: 1, +}; + +const STATUS_RANK: Record = { running: 0, complete: 1, failed: 2, cancelled: 3 }; + +function armSortValue(a: ArmSummary, key: SortKey): string | number | null { + switch (key) { + case "arm": + return armName(a).toLowerCase(); + case "note": + return (a.run.note || a.config).toLowerCase(); + case "status": + return STATUS_RANK[a.run.status] ?? 9; + case "progress": + return a.run.nTotal > 0 ? a.run.done / a.run.nTotal : a.run.done > 0 ? 0 : null; + case "eta": + return a.projected?.etaMs ?? null; + case "pass": + return a.passPct; + case "cost": + return a.costPerTask; + case "time": + return a.meanTrialMs; + } +} + +/** Sorted copy of the arms; null metric values always sink to the bottom. */ +function sortedArms(arms: ArmSummary[], sort: SortSpec | null): ArmSummary[] { + if (!sort) return arms; + return [...arms].sort((x, y) => { + const vx = armSortValue(x, sort.key); + const vy = armSortValue(y, sort.key); + if (vx === null || vy === null) return vx === null ? (vy === null ? 0 : 1) : -1; + const cmp = typeof vx === "string" && typeof vy === "string" ? vx.localeCompare(vy) : Number(vx) - Number(vy); + return sort.dir * cmp; + }); +} + +/** Sortable column header; click cycles best-first → reversed → default order. */ +function SortHeader({ + label, + col, + sort, + onSort, +}: { + label: string; + col: SortKey; + sort: SortSpec | null; + onSort: (col: SortKey) => void; +}) { + const dir = sort !== null && sort.key === col ? sort.dir : null; + return ( + + + + ); +} + +// ── arm comparison (focus mode) ────────────────────────────────────────────── + +/** Trial statuses that count as decided when comparing arms (error = fail). */ +function isDecided(s: string | undefined): s is string { + return s === "pass" || s === "fail" || s === "error"; +} + +interface TaskStat { + passes: number; + decided: number; + /** Display names of the arms that passed the task. */ + passedBy: string[]; +} + +function computeTaskStats( + arms: ArmSummary[], + matrix: ExperimentDetail["matrix"], + tasks: string[], +): Map { + const stats = new Map(); + for (const task of tasks) stats.set(task, { passes: 0, decided: 0, passedBy: [] }); + for (const arm of arms) { + const cells = matrix[arm.arm] ?? {}; + for (const task of tasks) { + const status = cells[task]?.status; + if (!isDecided(status)) continue; + const stat = stats.get(task); + if (!stat) continue; + stat.decided++; + if (status === "pass") { + stat.passes++; + stat.passedBy.push(armName(arm)); + } + } + } + return stats; +} + +interface HeadToHead { + arm: ArmSummary; + /** Tasks the focused arm passed that this arm decided and failed. */ + focusWins: number; + /** Tasks this arm passed that the focused arm decided and failed. */ + armWins: number; + bothPass: number; + bothFail: number; + shared: number; +} + +function headToHead( + focusCells: Record, + arm: ArmSummary, + cells: Record, + tasks: string[], +): HeadToHead { + let focusWins = 0; + let armWins = 0; + let bothPass = 0; + let bothFail = 0; + for (const task of tasks) { + const f = focusCells[task]?.status; + const o = cells[task]?.status; + if (!isDecided(f) || !isDecided(o)) continue; + const fPass = f === "pass"; + const oPass = o === "pass"; + if (fPass && oPass) bothPass++; + else if (fPass) focusWins++; + else if (oPass) armWins++; + else bothFail++; + } + return { arm, focusWins, armWins, bothPass, bothFail, shared: focusWins + armWins + bothPass + bothFail }; +} + +// ── charts ─────────────────────────────────────────────────────────────────── + +/** One horizontal bar per arm; running arms chart their projected value. */ +interface MetricBar { + key: string; + label: string; + role: RunRole; + /** Value is a projection (arm still running) rather than a final observation. */ + projected: boolean; + value: number; +} + +function metricBars( + arms: ArmSummary[], + actual: (arm: ArmSummary) => number | null, + projected: (proj: ArmProjection) => number, +): MetricBar[] { + const bars: MetricBar[] = []; + for (const arm of arms) { + const proj = arm.run.status === "running" ? arm.projected : null; + const value = proj ? projected(proj) : actual(arm); + if (value === null) continue; + bars.push({ key: arm.arm, label: armName(arm), role: arm.run.role, projected: proj !== null, value }); + } + return bars; +} + +const BAR_FILL: Record = { + baseline: "bg-sky-500/85", + variant: "bg-emerald-500/85", + "": "bg-zinc-500/85", +}; + +const BAR_PROJECTED: Record = { + baseline: "border border-dashed border-sky-400/70 bg-sky-400/15", + variant: "border border-dashed border-emerald-400/70 bg-emerald-400/15", + "": "border border-dashed border-zinc-400/70 bg-zinc-400/15", +}; + +/** Named horizontal bars, best value first, with a dashed tick at the anchor value. */ +function BarChart({ + title, + bars, + best, + format, + anchor, + focus, + onFocus, +}: { + title: string; + bars: MetricBar[]; + best: "high" | "low"; + format: (v: number) => string; + anchor: number | null; + focus: string | null; + onFocus: (key: string) => void; +}) { + const sorted = [...bars].sort((a, b) => (best === "high" ? b.value - a.value : a.value - b.value)); + const max = Math.max(...bars.map(b => b.value), anchor ?? 0); + const anchorLeft = anchor !== null && max > 0 ? Math.min((100 * anchor) / max, 100) : null; + return ( +
      +
      +

      {title}

      + + {best === "high" ? "higher is better" : "lower is better"} · best first + +
      + {sorted.length === 0 ? ( +
      no decided trials yet
      + ) : ( +
      + {sorted.map(b => { + const focusedBar = focus === b.key; + const dim = focus !== null && !focusedBar; + return ( + + ); + })} +
      + )} +
      + ); +} + +/** Width of a container tracked through resizes; charts render in pixel space. */ +function useMeasuredWidth(): [RefObject, number] { + const ref = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new ResizeObserver(entries => setWidth(entries[0].contentRect.width)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + return [ref, width]; +} + +interface ScatterPt { + key: string; + label: string; + role: RunRole; + /** Values are linear projections (arm still running). */ + projected: boolean; + cost: number; + pass: number; +} + +const DOT_COLOR: Record = { baseline: "#38bdf8", variant: "#34d399", "": "#a1a1aa" }; + +interface LabelBox { + x: number; + y: number; + w: number; + h: number; +} + +const boxesOverlap = (a: LabelBox, b: LabelBox): boolean => + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + +/** Greedy point-label placement: right → left → above → below; the first spot + * that stays inside the plot and clears every earlier label + dot wins. */ +function placeLabels( + pts: Array<{ key: string; label: string; px: number; py: number }>, + plot: { x0: number; y0: number; x1: number; y1: number }, +): Map { + const placed: LabelBox[] = pts.map(p => ({ x: p.px - 6, y: p.py - 6, w: 12, h: 12 })); + const out = new Map(); + for (const p of [...pts].sort((a, b) => a.py - b.py)) { + const w = p.label.length * 5.8 + 4; + const h = 11; + const candidates: Array<{ lx: number; ly: number; anchor: "start" | "middle" | "end"; box: LabelBox }> = [ + { lx: p.px + 8, ly: p.py + 3, anchor: "start", box: { x: p.px + 8, y: p.py - 5.5, w, h } }, + { lx: p.px - 8, ly: p.py + 3, anchor: "end", box: { x: p.px - 8 - w, y: p.py - 5.5, w, h } }, + { lx: p.px, ly: p.py - 9, anchor: "middle", box: { x: p.px - w / 2, y: p.py - 17, w, h } }, + { lx: p.px, ly: p.py + 15, anchor: "middle", box: { x: p.px - w / 2, y: p.py + 7, w, h } }, + ]; + let chosen = candidates[0]; + for (const c of candidates) { + if (c.box.x < plot.x0 || c.box.x + c.box.w > plot.x1 || c.box.y < plot.y0 || c.box.y + c.box.h > plot.y1) + continue; + if (placed.some(b => boxesOverlap(b, c.box))) continue; + chosen = c; + break; + } + placed.push(chosen.box); + out.set(p.key, chosen); + } + return out; +} + +/** Cost-vs-success tradeoff, one labelled point per arm; the anchor arm gets + * crosshairs so "cheaper & better" reads as a quadrant. */ +function ScatterChart({ + pts, + anchor, + focus, + onFocus, +}: { + pts: ScatterPt[]; + anchor: ScatterPt | null; + focus: string | null; + onFocus: (key: string) => void; +}) { + const [ref, width] = useMeasuredWidth(); + const H = 268; + const m = { l: 46, r: 14, t: 12, b: 30 }; + const maxCost = Math.max(...pts.map(p => p.cost), 0.01) * 1.12; + const passVals = pts.length > 0 ? pts.map(p => p.pass) : [0, 100]; + const passLo = Math.max(0, Math.min(...passVals) - 8); + const passHi = Math.min(100, Math.max(...passVals) + 8); + const x = (c: number) => m.l + (c / maxCost) * Math.max(width - m.l - m.r, 1); + const y = (p: number) => m.t + (1 - (p - passLo) / Math.max(passHi - passLo, 1e-9)) * (H - m.t - m.b); + const labels = placeLabels( + pts.map(p => ({ key: p.key, label: p.label, px: x(p.cost), py: y(p.pass) })), + { x0: 2, y0: 2, x1: Math.max(width - 2, 4), y1: H - 16 }, + ); + return ( +
      +
      +

      cost vs success

      + ↖ cheaper & better +
      + {pts.length === 0 || width === 0 ? ( +
      + no decided trials yet +
      + ) : ( + + {[0, 1 / 3, 2 / 3, 1].map(f => { + const v = passLo + f * (passHi - passLo); + return ( + + + + {v.toFixed(0)}% + + + ); + })} + {[0.25, 0.5, 0.75, 1].map(f => { + const v = f * maxCost; + return ( + + + + {fmtUsd(v)} + + + ); + })} + {anchor && ( + + + + + )} + {pts.map(p => { + const px = x(p.cost); + const py = y(p.pass); + const focusedPt = focus === p.key; + const dim = focus !== null && !focusedPt; + const color = DOT_COLOR[p.role]; + const lab = labels.get(p.key); + return ( + onFocus(p.key)} + onKeyDown={ev => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + onFocus(p.key); + } + }} + > + {`${p.label} · ${p.pass.toFixed(0)}% · ${fmtUsd(p.cost)}/task${p.projected ? " (projected)" : ""}`} + + {focusedPt && } + {p.projected ? ( + + ) : ( + + )} + {lab && ( + + {p.label} + + )} + + ); + })} + + )} +
      + ); +} + +const CELL_CLASS: Record = { + pass: "bg-emerald-500", + fail: "bg-red-500", + error: "bg-amber-500", + running: "bg-sky-500 animate-pulse", +}; + +/** + * The comparison anchor for an experiment: the completed baseline arm with the + * highest pass rate (the "ceiling" a downshift arm tries to preserve). Ties + * break toward the cheaper arm. Returns null when no baseline has finished data. + */ +function pickReferenceArm(arms: ArmSummary[]): ArmSummary | null { + let ref: ArmSummary | null = null; + for (const a of arms) { + if (a.run.role !== "baseline" || a.passPct === null) continue; + if ( + ref === null || + a.passPct > (ref.passPct ?? -1) || + (a.passPct === ref.passPct && (a.costPerTask ?? Infinity) < (ref.costPerTask ?? Infinity)) + ) { + ref = a; + } + } + return ref; +} + +/** + * Signed, colour-coded offset of a metric from the reference arm. `points` + * shows absolute percentage-point difference (pass rate); `relative` shows a + * percentage change (cost, time). `higherBetter` decides which direction is green. + */ +function Delta({ + value, + reference, + mode, + higherBetter, +}: { + value: number | null; + reference: number | null; + mode: "points" | "relative"; + higherBetter: boolean; +}) { + if (value === null || reference === null) return null; + const raw = + mode === "points" ? value - reference : reference === 0 ? Number.NaN : ((value - reference) / reference) * 100; + if (!Number.isFinite(raw) || Math.abs(raw) < 0.5) { + return ≈; + } + const good = higherBetter ? raw > 0 : raw < 0; + const body = `${raw > 0 ? "+" : "−"}${Math.abs(raw).toFixed(0)}${mode === "relative" ? "%" : ""}`; + return ( + + ({body}) + + ); +} + +/** + * Launch a new arm into an existing experiment. The server inherits the + * experiment's dataset and exact task sample from a sibling arm, so only the + * arm-specific knobs (name, model, role, note, optional downshift) are collected here. + */ +function AddArmForm({ experimentId, onDone }: { experimentId: string; onDone: () => void }) { + const [msg, setMsg] = useState(""); + const submit = useCallback( + async (ev: React.FormEvent) => { + ev.preventDefault(); + const f = new FormData(ev.currentTarget); + const body: Record = { arm: f.get("arm"), model: f.get("model") }; + if (f.get("role")) body.role = f.get("role"); + if (f.get("note")) body.note = f.get("note"); + if (f.get("downshiftInto") || f.get("downshift")) { + body.downshift = f.get("downshiftInto") ? { into: f.get("downshiftInto") } : {}; + } + setMsg("launching…"); + const res = await fetch(`/api/experiments/${encodeURIComponent(experimentId)}/arms`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const out = (await res.json()) as { jobName?: string; error?: string }; + setMsg(res.ok ? `launched ${out.jobName}` : `error: ${out.error}`); + if (res.ok) setTimeout(onDone, 900); + }, + [experimentId, onDone], + ); + return ( +
      + + + + + + +
      + + inherits dataset + task sample from existing arms · {msg} +
      +
      + ); +} + +/** Inline editor for the experiment's goal/description. */ +function GoalEditor({ id, goal, onSaved }: { id: string; goal: string; onSaved: () => void }) { + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + if (!editing) { + return ( +
      +

      {goal || "no description"}

      + +
      + ); + } + const save = async (form: HTMLFormElement) => { + const f = new FormData(form); + setBusy(true); + setErr(""); + try { + await putExperimentMeta(id, { goal: String(f.get("goal") ?? "").trim() }); + onSaved(); + setEditing(false); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + return ( +
      { + ev.preventDefault(); + void save(ev.currentTarget); + }} + onKeyDown={ev => { + if (ev.key === "Escape") setEditing(false); + if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) { + ev.preventDefault(); + void save(ev.currentTarget); + } + }} + > +