Merge branch 'main' into bigrefactor

# Conflicts:
#	package-lock.json
#	packages/agent/package.json
This commit is contained in:
Mario Zechner
2026-05-07 11:17:36 +02:00
50 changed files with 1583 additions and 421 deletions

View File

@@ -198,3 +198,5 @@ louis030195 pr
technocidal pr
pandada8 pr
npupko issue

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ packages/*/dist-firefox/
.vscode/
.zed/
.idea/
.claude/
*.swp
*.swo
*~

447
package-lock.json generated
View File

@@ -16,7 +16,6 @@
"packages/coding-agent/examples/extensions/sandbox"
],
"dependencies": {
"@mariozechner/jiti": "^2.6.5",
"@mariozechner/pi-coding-agent": "^0.30.2",
"get-east-asian-width": "^1.4.0"
},
@@ -27,6 +26,7 @@
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"shx": "^0.4.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2"
@@ -55,15 +55,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@anthropic-ai/sandbox-runtime/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@anthropic-ai/sandbox-runtime/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -923,6 +914,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -940,6 +934,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -957,6 +954,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -974,6 +974,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -1660,6 +1663,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1676,6 +1682,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1692,6 +1701,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1708,6 +1720,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1724,6 +1739,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1765,19 +1783,6 @@
"node": ">= 10"
}
},
"node_modules/@mariozechner/jiti": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz",
"integrity": "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==",
"license": "MIT",
"dependencies": {
"std-env": "^3.10.0",
"yoctocolors": "^2.1.2"
},
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/@mariozechner/mini-lit": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.2.1.tgz",
@@ -1968,6 +1973,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1988,6 +1996,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2008,6 +2019,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2028,6 +2042,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2048,6 +2065,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2279,6 +2299,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2300,6 +2323,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2321,6 +2347,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2342,6 +2371,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2363,6 +2395,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2384,6 +2419,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2554,9 +2592,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
"cpu": [
"arm"
],
@@ -2567,9 +2605,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
"cpu": [
"arm64"
],
@@ -2580,9 +2618,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
"cpu": [
"arm64"
],
@@ -2593,9 +2631,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
"cpu": [
"x64"
],
@@ -2606,9 +2644,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
"cpu": [
"arm64"
],
@@ -2619,9 +2657,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
"cpu": [
"x64"
],
@@ -2632,12 +2670,15 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2645,12 +2686,15 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2658,12 +2702,15 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2671,12 +2718,15 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2684,12 +2734,15 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2697,12 +2750,15 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2710,12 +2766,15 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2723,12 +2782,15 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2736,12 +2798,15 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2749,12 +2814,15 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2762,12 +2830,15 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2775,12 +2846,15 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2788,12 +2862,15 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2801,9 +2878,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
"cpu": [
"x64"
],
@@ -2814,9 +2891,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
"cpu": [
"arm64"
],
@@ -2827,9 +2904,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
"cpu": [
"arm64"
],
@@ -2840,9 +2917,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
"cpu": [
"ia32"
],
@@ -2853,9 +2930,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
"cpu": [
"x64"
],
@@ -2866,9 +2943,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
"cpu": [
"x64"
],
@@ -3666,6 +3743,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3682,6 +3762,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3698,6 +3781,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3714,6 +3800,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4582,12 +4671,12 @@
"license": "MIT"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">= 12"
"node": ">=18"
}
},
"node_modules/concurrently": {
@@ -5110,9 +5199,9 @@
}
},
"node_modules/fast-xml-builder": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.7.tgz",
"integrity": "sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==",
"funding": [
{
"type": "github",
@@ -5670,9 +5759,9 @@
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -5795,6 +5884,15 @@
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/koffi": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz",
@@ -5951,6 +6049,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5971,6 +6072,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5991,6 +6095,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6011,6 +6118,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6115,9 +6225,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -6657,9 +6767,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -6932,9 +7042,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -6947,31 +7057,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"@rollup/rollup-android-arm-eabi": "4.60.3",
"@rollup/rollup-android-arm64": "4.60.3",
"@rollup/rollup-darwin-arm64": "4.60.3",
"@rollup/rollup-darwin-x64": "4.60.3",
"@rollup/rollup-freebsd-arm64": "4.60.3",
"@rollup/rollup-freebsd-x64": "4.60.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
"@rollup/rollup-linux-arm64-musl": "4.60.3",
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
"@rollup/rollup-linux-loong64-musl": "4.60.3",
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
"@rollup/rollup-linux-x64-gnu": "4.60.3",
"@rollup/rollup-linux-x64-musl": "4.60.3",
"@rollup/rollup-openbsd-x64": "4.60.3",
"@rollup/rollup-openharmony-arm64": "4.60.3",
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
"@rollup/rollup-win32-x64-gnu": "4.60.3",
"@rollup/rollup-win32-x64-msvc": "4.60.3",
"fsevents": "~2.3.2"
}
},
@@ -7247,6 +7357,7 @@
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
@@ -8143,22 +8254,10 @@
"fd-slicer": "~1.1.0"
}
},
"node_modules/yoctocolors": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
"integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -8175,10 +8274,10 @@
},
"packages/agent": {
"name": "@mariozechner/pi-agent-core",
"version": "0.72.1",
"version": "0.73.0",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-ai": "^0.73.0",
"ignore": "^7.0.5",
"typebox": "^1.1.24",
"yaml": "^2.8.2"
@@ -8211,7 +8310,7 @@
},
"packages/ai": {
"name": "@mariozechner/pi-ai",
"version": "0.72.1",
"version": "0.73.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.91.1",
@@ -8257,13 +8356,12 @@
},
"packages/coding-agent": {
"name": "@mariozechner/pi-coding-agent",
"version": "0.72.1",
"version": "0.73.0",
"license": "MIT",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.72.1",
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-tui": "^0.72.1",
"@mariozechner/pi-agent-core": "^0.73.0",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-tui": "^0.73.0",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
@@ -8273,6 +8371,7 @@
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",
@@ -8304,25 +8403,25 @@
},
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
"name": "pi-extension-custom-provider-anthropic",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}
},
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
"name": "pi-extension-custom-provider-gitlab-duo",
"version": "0.72.1"
"version": "0.73.0"
},
"packages/coding-agent/examples/extensions/sandbox": {
"name": "pi-extension-sandbox",
"version": "1.2.1",
"version": "1.3.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
},
"packages/coding-agent/examples/extensions/with-deps": {
"name": "pi-extension-with-deps",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"ms": "^2.1.3"
},
@@ -8358,7 +8457,7 @@
},
"packages/tui": {
"name": "@mariozechner/pi-tui",
"version": "0.72.1",
"version": "0.73.0",
"license": "MIT",
"dependencies": {
"@types/mime-types": "^2.1.4",
@@ -8380,12 +8479,12 @@
},
"packages/web-ui": {
"name": "@mariozechner/pi-web-ui",
"version": "0.72.1",
"version": "0.73.0",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-tui": "^0.72.1",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-tui": "^0.73.0",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lucide": "^0.544.0",
@@ -8407,7 +8506,7 @@
},
"packages/web-ui/example": {
"name": "pi-web-ui-example",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@mariozechner/pi-ai": "file:../../ai",

View File

@@ -39,6 +39,7 @@
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"shx": "^0.4.0"
@@ -48,7 +49,6 @@
},
"version": "0.0.3",
"dependencies": {
"@mariozechner/jiti": "^2.6.5",
"@mariozechner/pi-coding-agent": "^0.30.2",
"get-east-asian-width": "^1.4.0"
},

View File

@@ -2,6 +2,8 @@
## [Unreleased]
## [0.73.0] - 2026-05-04
## [0.72.1] - 2026-05-02
### Changed

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-agent-core",
"version": "0.72.1",
"version": "0.73.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@@ -17,7 +17,7 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-ai": "^0.73.0",
"ignore": "^7.0.5",
"typebox": "^1.1.24",
"yaml": "^2.8.2"

View File

@@ -2,13 +2,27 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses reasoning text streaming for LM Studio and other compatible providers that emit `response.reasoning_text.delta` events ([#4191](https://github.com/badlogic/pi-mono/pull/4191) by [@yaanfpv](https://github.com/yaanfpv)).
- Fixed OpenAI Codex OAuth refresh failures writing directly to stderr while the TUI is active ([#4141](https://github.com/badlogic/pi-mono/issues/4141)).
## [0.73.0] - 2026-05-04
### Breaking Changes
- Switched the built-in `xiaomi` provider endpoint from Token Plan AMS (`https://token-plan-ams.xiaomimimo.com/anthropic`) to API billing (`https://api.xiaomimimo.com/anthropic`). `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users still on Token Plan must move to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var.
- Switched the built-in `xiaomi` provider endpoint from Token Plan AMS (`https://token-plan-ams.xiaomimimo.com/anthropic`) to API billing (`https://api.xiaomimimo.com/anthropic`). `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users still on Token Plan must move to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
### Added
- Added Xiaomi MiMo Token Plan regional providers with per-region env vars: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), and `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`).
- Added Xiaomi MiMo Token Plan regional providers with per-region env vars: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), and `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`) ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
- Added `registerSessionResourceCleanup()` and `cleanupSessionResources()` so providers can register cleanup hooks for session-scoped resources.
### Fixed
- Fixed generated OpenAI-compatible model metadata for Qwen 3.5/3.6 and MiniMax M2.7 to match models.dev and OpenCode Go ([#4110](https://github.com/badlogic/pi-mono/pull/4110) by [@jsynowiec](https://github.com/jsynowiec)).
- Fixed Bedrock Converse thinking effort mapping to preserve native `xhigh` for Claude Opus 4.7.
- Fixed OpenAI Codex Responses WebSocket transport to fall back to SSE when setup fails before streaming starts, and attach transport diagnostics to the assistant message ([#4133](https://github.com/badlogic/pi-mono/issues/4133)).
## [0.72.1] - 2026-05-02

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-ai",
"version": "0.72.1",
"version": "0.73.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",

View File

@@ -857,15 +857,17 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const kimiModels = data["kimi-for-coding"].models as Record<string, ModelsDevModel>;
const hasCanonicalModel = Object.prototype.hasOwnProperty.call(kimiModels, "kimi-for-coding");
const kimiAliases = new Set(["k2p5", "k2p6"]);
for (const [modelId, model] of Object.entries(kimiModels)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
// models.dev still exposes deprecated "k2p5" in some snapshots.
// Normalize to the canonical model id and drop duplicates when canonical exists.
if (modelId === "k2p5" && hasCanonicalModel) continue;
// models.dev may expose versioned aliases (e.g. k2p5/k2p6).
// Normalize aliases to the canonical model id and drop duplicates when canonical exists.
if (kimiAliases.has(modelId) && hasCanonicalModel) continue;
const normalizedId = modelId === "k2p5" ? "kimi-for-coding" : modelId;
const normalizedName = modelId === "k2p5" ? "Kimi For Coding" : m.name || normalizedId;
const normalizedId = kimiAliases.has(modelId) ? "kimi-for-coding" : modelId;
const normalizedName = kimiAliases.has(modelId) ? "Kimi For Coding" : m.name || normalizedId;
models.push({
id: normalizedId,

View File

@@ -34,6 +34,8 @@ export type {
OAuthProviderId,
OAuthProviderInfo,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.js";
export * from "./utils/overflow.js";
export * from "./utils/typebox-helpers.js";

View File

@@ -5819,24 +5819,6 @@ export const MODELS = {
} satisfies Model<"openai-completions">,
},
"kimi-coding": {
"k2p6": {
id: "k2p6",
name: "Kimi K2.6",
api: "anthropic-messages",
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding",
headers: {"User-Agent":"KimiCLI/1.5"},
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 32768,
} satisfies Model<"anthropic-messages">,
"kimi-for-coding": {
id: "kimi-for-coding",
name: "Kimi For Coding",
@@ -9207,8 +9189,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.21,
output: 0.7899999999999999,
input: 0.27,
output: 0.95,
cacheRead: 0.13,
cacheWrite: 0,
},
@@ -9279,13 +9261,13 @@ export const MODELS = {
thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"},
input: ["text"],
cost: {
input: 0.435,
output: 0.87,
cacheRead: 0.003625,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 384000,
contextWindow: 131000,
maxTokens: 131000,
} satisfies Model<"openai-completions">,
"essentialai/rnj-1-instruct": {
id: "essentialai/rnj-1-instruct",
@@ -9318,7 +9300,7 @@ export const MODELS = {
cacheRead: 0.024999999999999998,
cacheWrite: 0.08333333333333334,
},
contextWindow: 1000000,
contextWindow: 1048576,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"google/gemini-2.0-flash-lite-001": {
@@ -11807,13 +11789,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.08,
output: 0.28,
input: 0.09,
output: 0.44999999999999996,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 40960,
maxTokens: 16384,
maxTokens: 20000,
} satisfies Model<"openai-completions">,
"qwen/qwen3-30b-a3b-instruct-2507": {
id: "qwen/qwen3-30b-a3b-instruct-2507",
@@ -12232,13 +12214,13 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.1625,
output: 1.3,
cacheRead: 0,
input: 0.15,
output: 1,
cacheRead: 0.049999999999999996,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"qwen/qwen3.5-397b-a17b": {
id: "qwen/qwen3.5-397b-a17b",
@@ -12342,6 +12324,23 @@ export const MODELS = {
contextWindow: 262144,
maxTokens: 81920,
} satisfies Model<"openai-completions">,
"qwen/qwen3.6-35b-a3b": {
id: "qwen/qwen3.6-35b-a3b",
name: "Qwen: Qwen3.6 35B A3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.15,
output: 1,
cacheRead: 0.049999999999999996,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"qwen/qwen3.6-flash": {
id: "qwen/qwen3.6-flash",
name: "Qwen: Qwen3.6 Flash",
@@ -14573,23 +14572,6 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"moonshotai/kimi-k2-0905": {
id: "moonshotai/kimi-k2-0905",
name: "Kimi K2 0905",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0.6,
output: 2.5,
cacheRead: 0.3,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"moonshotai/kimi-k2-thinking": {
id: "moonshotai/kimi-k2-thinking",
name: "Kimi K2 Thinking",
@@ -16813,7 +16795,7 @@ export const MODELS = {
} satisfies Model<"openai-completions">,
"glm-5v-turbo": {
id: "glm-5v-turbo",
name: "glm-5v-turbo",
name: "GLM-5V-Turbo",
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",

View File

@@ -352,7 +352,7 @@ function buildRequestBody(
model: model.id,
store: false,
stream: true,
instructions: context.systemPrompt,
instructions: context.systemPrompt || "You are a helpful assistant.",
input: messages,
text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"],

View File

@@ -354,6 +354,16 @@ export async function processResponsesStream<TApi extends Api>(
});
}
}
} else if (event.type === "response.reasoning_text.delta") {
if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
currentBlock.thinking += event.delta;
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: event.delta,
partial: output,
});
}
} else if (event.type === "response.content_part.added") {
if (currentItem?.type === "message") {
currentItem.content = currentItem.content || [];
@@ -429,7 +439,9 @@ export async function processResponsesStream<TApi extends Api>(
const item = event.item;
if (item.type === "reasoning" && currentBlock?.type === "thinking") {
currentBlock.thinking = item.summary?.map((s) => s.text).join("\n\n") || "";
const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";
const contentText = item.content?.map((c) => c.text).join("\n\n") || "";
currentBlock.thinking = summaryText || contentText || currentBlock.thinking;
currentBlock.thinkingSignature = JSON.stringify(item);
stream.push({
type: "thinking_end",

View File

@@ -30,7 +30,7 @@ const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed" };
type TokenFailure = { type: "failed"; message: string; status?: number };
type TokenResult = TokenSuccess | TokenFailure;
type JwtPayload = {
@@ -108,8 +108,11 @@ async function exchangeAuthorizationCode(
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] code->token failed:", response.status, text);
return { type: "failed" };
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as {
@@ -119,8 +122,10 @@ async function exchangeAuthorizationCode(
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
console.error("[openai-codex] token response missing fields:", json);
return { type: "failed" };
return {
type: "failed",
message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`,
};
}
return {
@@ -145,8 +150,11 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] Token refresh failed:", response.status, text);
return { type: "failed" };
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as {
@@ -156,8 +164,10 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
console.error("[openai-codex] Token refresh response missing fields:", json);
return { type: "failed" };
return {
type: "failed",
message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`,
};
}
return {
@@ -167,8 +177,10 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
expires: Date.now() + json.expires_in * 1000,
};
} catch (error) {
console.error("[openai-codex] Token refresh error:", error);
return { type: "failed" };
return {
type: "failed",
message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
@@ -258,12 +270,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
waitForCode: () => waitForCodePromise,
});
})
.on("error", (err: NodeJS.ErrnoException) => {
console.error(
`[openai-codex] Failed to bind http://${CALLBACK_HOST}:1455 (`,
err.code,
") Falling back to manual paste.",
);
.on("error", (_err: NodeJS.ErrnoException) => {
settleWait?.(null);
resolve({
close: () => {
@@ -386,7 +393,7 @@ export async function loginOpenAICodex(options: {
const tokenResult = await exchangeAuthorizationCode(code, verifier);
if (tokenResult.type !== "success") {
throw new Error("Token exchange failed");
throw new Error(tokenResult.message);
}
const accountId = getAccountId(tokenResult.access);
@@ -411,7 +418,7 @@ export async function loginOpenAICodex(options: {
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
const result = await refreshAccessToken(refreshToken);
if (result.type !== "success") {
throw new Error("Failed to refresh OpenAI Codex token");
throw new Error(result.message);
}
const accountId = getAccountId(result.access);

View File

@@ -23,11 +23,23 @@ export type OAuthAuthInfo = {
instructions?: string;
};
export type OAuthSelectOption = {
id: string;
label: string;
};
export type OAuthSelectPrompt = {
message: string;
options: OAuthSelectOption[];
};
export interface OAuthLoginCallbacks {
onAuth: (info: OAuthAuthInfo) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
/** Show an interactive selector and return the selected option id, or undefined on cancel. */
onSelect?: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
signal?: AbortSignal;
}

View File

@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.js";
describe("OpenAI Codex OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("does not write token refresh failures to stderr", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => {
return new Response(
JSON.stringify({
error: {
message: "Could not validate your token. Please try signing in again.",
type: "invalid_request_error",
},
}),
{ status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" } },
);
}),
);
await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow(
/OpenAI Codex token refresh failed \(401\).*Could not validate your token/,
);
expect(consoleError).not.toHaveBeenCalled();
});
});

View File

@@ -2,13 +2,27 @@
## [Unreleased]
### Fixed
- Fixed `pi -p` treating prompts that start with YAML frontmatter as extension flags instead of user messages ([#4163](https://github.com/badlogic/pi-mono/issues/4163)).
- Fixed pending tool results not updating in the live TUI after toggling thinking block visibility while the tool is running ([#4167](https://github.com/badlogic/pi-mono/issues/4167)).
- Fixed `/copy` reporting success on Linux without writing the clipboard on Wayland-only compositors (Hyprland, Niri, ...) by skipping the X11-only native addon on Linux and routing through `wl-copy`/`xclip`/`xsel` instead ([#4177](https://github.com/badlogic/pi-mono/issues/4177)).
## [0.73.0] - 2026-05-04
### New Features
- **Xiaomi MiMo API billing and regional Token Plan providers** - `xiaomi` now uses API billing, with separate `xiaomi-token-plan-{cn,ams,sgp}` providers. See [docs/providers.md#api-keys](docs/providers.md#api-keys) and [README.md#providers--models](README.md#providers--models). ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode))
- **Incremental bash output streaming** - Bash tool output now appears while commands run instead of only after completion. ([#4145](https://github.com/badlogic/pi-mono/issues/4145))
- **Compact read rendering** - Interactive `read` output for Pi docs, context files, and skills is collapsed by default and shows selected line ranges.
### Breaking Changes
- Switched the built-in `xiaomi` provider from Token Plan AMS to Xiaomi's API billing endpoint, and renamed its `/login` display from "Xiaomi MiMo Token Plan" to "Xiaomi MiMo". `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users on Token Plan should switch to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var.
- Switched the built-in `xiaomi` provider from Token Plan AMS to Xiaomi's API billing endpoint, and renamed its `/login` display from "Xiaomi MiMo Token Plan" to "Xiaomi MiMo". `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users on Token Plan should switch to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
### Added
- Added three Xiaomi MiMo Token Plan regional providers visible in `/login`: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`). Each defaults to `mimo-v2.5-pro`.
- Added three Xiaomi MiMo Token Plan regional providers visible in `/login`: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`). Each defaults to `mimo-v2.5-pro` ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
### Changed
@@ -16,7 +30,14 @@
### Fixed
- Fixed generated OpenAI-compatible model metadata for Qwen 3.5/3.6 and MiniMax M2.7, so those models work through the built-in provider catalog ([#4110](https://github.com/badlogic/pi-mono/pull/4110) by [@jsynowiec](https://github.com/jsynowiec)).
- Fixed Bedrock Claude Opus 4.7 `xhigh` thinking requests by preserving the provider's native effort value.
- Fixed OpenAI Codex WebSocket transport to fall back to SSE when setup fails before streaming starts, and surface transport diagnostics in the assistant message ([#4133](https://github.com/badlogic/pi-mono/issues/4133)).
- Fixed OpenAI Codex WebSocket transport keeping `--print` and JSON mode processes alive after the response by closing cached WebSocket sessions during session shutdown ([#4103](https://github.com/badlogic/pi-mono/issues/4103)).
- Fixed compact `read` tool calls to render directly and include selected line ranges in interactive output.
- Fixed interactive sessions to exit when terminal input is lost instead of continuing in a broken state.
- Fixed bash tool output to stream incrementally while commands run instead of waiting for command completion ([#4145](https://github.com/badlogic/pi-mono/issues/4145)).
- Fixed selector and autocomplete fuzzy ranking to prioritize exact matches.
## [0.72.1] - 2026-05-02

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.72.1",
"version": "0.73.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.72.1",
"version": "0.73.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.72.1",
"version": "0.73.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.2.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.2.1",
"version": "1.3.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.2.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.72.1",
"version": "0.73.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"ms": "^2.1.3"
},

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.72.1",
"version": "0.73.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-coding-agent",
"version": "0.72.1",
"version": "0.73.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -38,10 +38,9 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.72.1",
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-tui": "^0.72.1",
"@mariozechner/pi-agent-core": "^0.73.0",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-tui": "^0.73.0",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
@@ -51,6 +50,7 @@
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",

View File

@@ -122,6 +122,11 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg === "--print" || arg === "-p") {
result.print = true;
const next = args[i + 1];
if (next !== undefined && !next.startsWith("@") && (!next.startsWith("-") || next.startsWith("---"))) {
result.messages.push(next);
i++;
}
} else if (arg === "--export" && i + 1 < args.length) {
result.export = args[++i];
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {

View File

@@ -204,6 +204,10 @@
color: var(--accent);
}
.tree-role-skill {
color: var(--customMessageLabel);
}
.tree-role-assistant {
color: var(--success);
}
@@ -383,7 +387,8 @@
}
.user-message:hover .copy-link-btn,
.assistant-message:hover .copy-link-btn {
.assistant-message:hover .copy-link-btn,
.skill-user-entry:hover .copy-link-btn {
opacity: 1;
}
@@ -786,6 +791,45 @@
font-weight: bold;
}
/* Skill invocation - matches compaction style (clickable, collapsed by default) */
.skill-invocation {
background: var(--customMessageBg);
border-radius: 4px;
padding: var(--line-height);
cursor: pointer;
}
.skill-invocation-label {
color: var(--customMessageLabel);
font-weight: bold;
}
.skill-invocation-collapsed {
color: var(--customMessageText);
}
.skill-invocation-content {
display: none;
color: var(--customMessageText);
margin-top: var(--line-height);
}
.skill-invocation.expanded .skill-invocation-collapsed {
display: none;
}
.skill-invocation.expanded .skill-invocation-content {
display: block;
}
.skill-invocation + .user-message {
margin-top: var(--line-height);
}
.skill-user-entry {
position: relative;
}
/* Branch summary */
.branch-summary {
background: var(--customMessageBg);

View File

@@ -313,6 +313,22 @@
return '';
}
/**
* Parse a skill block from message text.
* Returns null if the text doesn't contain a skill block.
* Matches the format: <skill name="..." location="...">\n...\n</skill>\n\nuser message
*/
function parseSkillBlock(text) {
const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
if (!match) return null;
return {
name: match[1],
location: match[2],
content: match[3],
userMessage: match[4]?.trim() || undefined,
};
}
function getSearchableText(entry, label) {
const parts = [];
if (label) parts.push(label);
@@ -613,7 +629,16 @@
case 'message': {
const msg = entry.message;
if (msg.role === 'user') {
const content = truncate(normalize(extractContent(msg.content)));
const rawContent = extractContent(msg.content);
const skillBlock = parseSkillBlock(rawContent);
if (skillBlock) {
let treeHtml = labelHtml + `<span class="tree-role-skill">skill:</span> ${escapeHtml(skillBlock.name)}`;
if (skillBlock.userMessage) {
treeHtml += ` · <span class="tree-role-user">user:</span> ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`;
}
return treeHtml;
}
const content = truncate(normalize(rawContent));
return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
}
if (msg.role === 'assistant') {
@@ -1140,8 +1165,46 @@
const msg = entry.message;
if (msg.role === 'user') {
let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
const content = msg.content;
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
const skillBlock = parseSkillBlock(text);
if (skillBlock) {
// Collect images from content array
const images = Array.isArray(content) ? content.filter(c => c.type === 'image') : [];
const hasUserContent = skillBlock.userMessage || images.length > 0;
let html = `<div class="skill-user-entry" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
// Skill invocation (collapsed by default, click to expand)
html += `<div class="skill-invocation" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
<div class="skill-invocation-label">[skill] ${escapeHtml(skillBlock.name)}</div>
<div class="skill-invocation-collapsed">${escapeHtml(skillBlock.name)} (click to expand)</div>
<div class="skill-invocation-content markdown-content">${safeMarkedParse(skillBlock.content)}</div>
</div>`;
// User message (separate block if present)
if (hasUserContent) {
html += '<div class="user-message">';
if (images.length > 0) {
html += '<div class="message-images">';
for (const img of images) {
html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`;
}
html += '</div>';
}
if (skillBlock.userMessage) {
html += `<div class="markdown-content">${safeMarkedParse(skillBlock.userMessage)}</div>`;
}
html += '</div>';
}
html += '</div>';
return html;
}
// No skill block - normal user message
let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
if (Array.isArray(content)) {
const images = content.filter(c => c.type === 'image');
@@ -1154,8 +1217,6 @@
}
}
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
if (text.trim()) {
html += `<div class="markdown-content">${safeMarkedParse(text)}</div>`;
}
@@ -1716,6 +1777,9 @@
document.querySelectorAll('.compaction').forEach(el => {
el.classList.toggle('expanded', toolOutputsExpanded);
});
document.querySelectorAll('.skill-invocation').forEach(el => {
el.classList.toggle('expanded', toolOutputsExpanded);
});
};
const attachHeaderHandlers = () => {

View File

@@ -1,7 +1,6 @@
/**
* Extension loader - loads TypeScript extension modules using jiti.
*
* Uses @mariozechner/jiti fork with virtualModules support for compiled Bun binaries.
*/
import * as fs from "node:fs";
@@ -9,12 +8,12 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "@mariozechner/jiti";
import * as _bundledPiAgentCore from "@mariozechner/pi-agent-core";
import * as _bundledPiAi from "@mariozechner/pi-ai";
import * as _bundledPiAiOauth from "@mariozechner/pi-ai/oauth";
import type { KeyId } from "@mariozechner/pi-tui";
import * as _bundledPiTui from "@mariozechner/pi-tui";
import { createJiti } from "jiti/static";
// Static imports of packages that extensions may use.
// These MUST be static so Bun bundles them into the compiled binary.
// The virtualModules option then makes them available to extensions.

View File

@@ -213,6 +213,13 @@ function formatValidationPath(error: TLocalizedValidationError): string {
return path || "root";
}
/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
function stripJsonComments(input: string): string {
return input
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
}
/** Provider override config (baseUrl, compat) without request auth/headers */
interface ProviderOverride {
baseUrl?: string;
@@ -450,7 +457,7 @@ export class ModelRegistry {
try {
const content = readFileSync(modelsJsonPath, "utf-8");
const parsed = JSON.parse(content) as unknown;
const parsed = JSON.parse(stripJsonComments(content)) as unknown;
if (!validateModelsConfig.Check(parsed)) {
const errors =

View File

@@ -1,7 +1,4 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";
@@ -18,17 +15,10 @@ import {
untrackDetachedChildPid,
} from "../../utils/shell.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { OutputAccumulator } from "./output-accumulator.js";
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
* Generate a unique temp file path for bash output.
*/
function getTempFilePath(): string {
const id = randomBytes(8).toString("hex");
return join(tmpdir(), `pi-bash-${id}.log`);
}
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.js";
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
@@ -161,6 +151,7 @@ export interface BashToolOptions {
}
const BASH_PREVIEW_LINES = 5;
const BASH_UPDATE_THROTTLE_MS = 100;
type BashRenderState = {
startedAt: number | undefined;
@@ -292,118 +283,119 @@ export function createBashToolDefinition(
) {
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
let updateTimer: NodeJS.Timeout | undefined;
let updateDirty = false;
let lastUpdateAt = 0;
const emitOutputUpdate = () => {
if (!onUpdate || !updateDirty) return;
updateDirty = false;
lastUpdateAt = Date.now();
const snapshot = output.snapshot({ persistIfTruncated: true });
onUpdate({
content: [{ type: "text", text: snapshot.content || "" }],
details: {
truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined,
fullOutputPath: snapshot.fullOutputPath,
},
});
};
const clearUpdateTimer = () => {
if (updateTimer) {
clearTimeout(updateTimer);
updateTimer = undefined;
}
};
const scheduleOutputUpdate = () => {
if (!onUpdate) return;
updateDirty = true;
const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
if (delay <= 0) {
clearUpdateTimer();
emitOutputUpdate();
return;
}
updateTimer ??= setTimeout(() => {
updateTimer = undefined;
emitOutputUpdate();
}, delay);
};
if (onUpdate) {
onUpdate({ content: [], details: undefined });
}
return new Promise((resolve, reject) => {
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
const chunks: Buffer[] = [];
let chunksBytes = 0;
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
const ensureTempFile = () => {
if (tempFilePath) return;
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of chunks) tempFileStream.write(chunk);
};
const handleData = (data: Buffer) => {
output.append(data);
scheduleOutputUpdate();
};
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Start writing to a temp file once output exceeds the in-memory threshold.
if (totalBytes > DEFAULT_MAX_BYTES) {
ensureTempFile();
}
// Write to temp file if we have one.
if (tempFileStream) tempFileStream.write(data);
// Keep a rolling buffer of recent output for tail truncation.
chunks.push(data);
chunksBytes += data.length;
// Trim old chunks if the rolling buffer grows too large.
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
// Stream partial output using the rolling tail buffer.
if (onUpdate) {
const fullBuffer = Buffer.concat(chunks);
const fullText = fullBuffer.toString("utf-8");
const truncation = truncateTail(fullText);
if (truncation.truncated) {
ensureTempFile();
}
onUpdate({
content: [{ type: "text", text: truncation.content || "" }],
details: {
truncation: truncation.truncated ? truncation : undefined,
fullOutputPath: tempFilePath,
},
});
}
};
const finishOutput = async () => {
output.finish();
clearUpdateTimer();
emitOutputUpdate();
const snapshot = output.snapshot({ persistIfTruncated: true });
await output.closeTempFile();
return snapshot;
};
ops.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
env: spawnContext.env,
})
.then(({ exitCode }) => {
// Combine the rolling buffer chunks.
const fullBuffer = Buffer.concat(chunks);
const fullOutput = fullBuffer.toString("utf-8");
// Apply tail truncation for the final display payload.
const truncation = truncateTail(fullOutput);
if (truncation.truncated) {
ensureTempFile();
}
// Close temp file stream before building the final result.
if (tempFileStream) tempFileStream.end();
let outputText = truncation.content || "(no output)";
let details: BashToolDetails | undefined;
if (truncation.truncated) {
// Build truncation details and an actionable notice.
details = { truncation, fullOutputPath: tempFilePath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
// Edge case: the last line alone is larger than the byte limit.
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
} else if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
} else {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
}
}
if (exitCode !== 0 && exitCode !== null) {
outputText += `\n\nCommand exited with code ${exitCode}`;
reject(new Error(outputText));
} else {
resolve({ content: [{ type: "text", text: outputText }], details });
}
})
.catch((err: Error) => {
// Close temp file stream and include buffered output in the error message.
if (tempFileStream) tempFileStream.end();
const fullBuffer = Buffer.concat(chunks);
let output = fullBuffer.toString("utf-8");
if (err.message === "aborted") {
if (output) output += "\n\n";
output += "Command aborted";
reject(new Error(output));
} else if (err.message.startsWith("timeout:")) {
const timeoutSecs = err.message.split(":")[1];
if (output) output += "\n\n";
output += `Command timed out after ${timeoutSecs} seconds`;
reject(new Error(output));
} else {
reject(err);
}
const formatOutput = (snapshot: Awaited<ReturnType<typeof finishOutput>>, emptyText = "(no output)") => {
const truncation = snapshot.truncation;
let text = snapshot.content || emptyText;
let details: BashToolDetails | undefined;
if (truncation.truncated) {
details = { truncation, fullOutputPath: snapshot.fullOutputPath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
const lastLineSize = formatSize(output.getLastLineBytes());
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
} else if (truncation.truncatedBy === "lines") {
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
} else {
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`;
}
}
return { text, details };
};
const appendStatus = (text: string, status: string) => `${text ? `${text}\n\n` : ""}${status}`;
try {
let exitCode: number | null;
try {
const result = await ops.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
env: spawnContext.env,
});
});
exitCode = result.exitCode;
} catch (err) {
const snapshot = await finishOutput();
const { text } = formatOutput(snapshot, "");
if (err instanceof Error && err.message === "aborted") {
throw new Error(appendStatus(text, "Command aborted"));
}
if (err instanceof Error && err.message.startsWith("timeout:")) {
const timeoutSecs = err.message.split(":")[1];
throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`));
}
throw err;
}
const snapshot = await finishOutput();
const { text: outputText, details } = formatOutput(snapshot);
if (exitCode !== 0 && exitCode !== null) {
throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
}
return { content: [{ type: "text", text: outputText }], details };
} finally {
clearUpdateTimer();
}
},
renderCall(args, _theme, context) {
const state = context.state;

View File

@@ -0,0 +1,216 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js";
export interface OutputAccumulatorOptions {
maxLines?: number;
maxBytes?: number;
tempFilePrefix?: string;
}
export interface OutputSnapshot {
content: string;
truncation: TruncationResult;
fullOutputPath?: string;
}
function defaultTempFilePath(prefix: string): string {
const id = randomBytes(8).toString("hex");
return join(tmpdir(), `${prefix}-${id}.log`);
}
function byteLength(text: string): number {
return Buffer.byteLength(text, "utf-8");
}
/**
* Incrementally tracks streaming output with bounded memory.
*
* Appends decode chunks with a streaming UTF-8 decoder, keeps only a decoded
* tail for display snapshots, and opens a temp file when the full output needs
* to be preserved.
*/
export class OutputAccumulator {
private readonly maxLines: number;
private readonly maxBytes: number;
private readonly maxRollingBytes: number;
private readonly tempFilePrefix: string;
private readonly decoder = new TextDecoder();
private rawChunks: Buffer[] = [];
private tailText = "";
private tailBytes = 0;
private tailStartsAtLineBoundary = true;
private totalRawBytes = 0;
private totalDecodedBytes = 0;
private totalLines = 1;
private currentLineBytes = 0;
private finished = false;
private tempFilePath: string | undefined;
private tempFileStream: WriteStream | undefined;
constructor(options: OutputAccumulatorOptions = {}) {
this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
this.tempFilePrefix = options.tempFilePrefix ?? "pi-output";
}
append(data: Buffer): void {
if (this.finished) {
throw new Error("Cannot append to a finished output accumulator");
}
this.totalRawBytes += data.length;
this.appendDecodedText(this.decoder.decode(data, { stream: true }));
if (this.tempFileStream || this.shouldUseTempFile()) {
this.ensureTempFile();
this.tempFileStream?.write(data);
} else if (data.length > 0) {
this.rawChunks.push(data);
}
}
finish(): void {
if (this.finished) {
return;
}
this.finished = true;
this.appendDecodedText(this.decoder.decode());
if (this.shouldUseTempFile()) {
this.ensureTempFile();
}
}
snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot {
const tailTruncation = truncateTail(this.getSnapshotText(), {
maxLines: this.maxLines,
maxBytes: this.maxBytes,
});
const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
const truncatedBy = truncated
? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines"))
: null;
const truncation: TruncationResult = {
...tailTruncation,
truncated,
truncatedBy,
totalLines: this.totalLines,
totalBytes: this.totalDecodedBytes,
maxLines: this.maxLines,
maxBytes: this.maxBytes,
};
if (options.persistIfTruncated && truncation.truncated) {
this.ensureTempFile();
}
return {
content: truncation.content,
truncation,
fullOutputPath: this.tempFilePath,
};
}
async closeTempFile(): Promise<void> {
if (!this.tempFileStream) {
return;
}
const stream = this.tempFileStream;
this.tempFileStream = undefined;
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
stream.off("finish", onFinish);
reject(error);
};
const onFinish = () => {
stream.off("error", onError);
resolve();
};
stream.once("error", onError);
stream.once("finish", onFinish);
stream.end();
});
}
getLastLineBytes(): number {
return this.currentLineBytes;
}
private appendDecodedText(text: string): void {
if (text.length === 0) {
return;
}
const bytes = byteLength(text);
this.totalDecodedBytes += bytes;
this.tailText += text;
this.tailBytes += bytes;
if (this.tailBytes > this.maxRollingBytes * 2) {
this.trimTail();
}
let newlines = 0;
let lastNewline = -1;
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
newlines++;
lastNewline = i;
}
if (newlines === 0) {
this.currentLineBytes += bytes;
} else {
this.totalLines += newlines;
this.currentLineBytes = byteLength(text.slice(lastNewline + 1));
}
}
private trimTail(): void {
const buffer = Buffer.from(this.tailText, "utf-8");
if (buffer.length <= this.maxRollingBytes) {
this.tailBytes = buffer.length;
return;
}
let start = buffer.length - this.maxRollingBytes;
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) {
start++;
}
this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a;
this.tailText = buffer.subarray(start).toString("utf-8");
this.tailBytes = byteLength(this.tailText);
}
private getSnapshotText(): string {
if (this.tailStartsAtLineBoundary) {
return this.tailText;
}
const firstNewline = this.tailText.indexOf("\n");
return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
}
private shouldUseTempFile(): boolean {
return (
this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines
);
}
private ensureTempFile(): void {
if (this.tempFilePath) {
return;
}
this.tempFilePath = defaultTempFilePath(this.tempFilePrefix);
this.tempFileStream = createWriteStream(this.tempFilePath);
for (const chunk of this.rawChunks) {
this.tempFileStream.write(chunk);
}
this.rawChunks = [];
}
}

View File

@@ -87,7 +87,8 @@ export class LoginDialogComponent extends Container implements Focusable {
showAuth(url: string, instructions?: string): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`;

View File

@@ -15,6 +15,7 @@ import {
type Message,
type Model,
type OAuthProviderId,
type OAuthSelectPrompt,
} from "@mariozechner/pi-ai";
import type {
AutocompleteItem,
@@ -2632,6 +2633,7 @@ export class InteractiveMode {
switch (event.type) {
case "agent_start":
this.pendingTools.clear();
if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(true);
}
@@ -3099,6 +3101,7 @@ export class InteractiveMode {
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
this.pendingTools.clear();
const renderedPendingTools = new Map<string, ToolExecutionComponent>();
if (options.updateFooter) {
this.footer.invalidate();
@@ -3140,16 +3143,16 @@ export class InteractiveMode {
}
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
} else {
this.pendingTools.set(content.id, component);
renderedPendingTools.set(content.id, component);
}
}
}
} else if (message.role === "toolResult") {
// Match tool results to pending tool components
const component = this.pendingTools.get(message.toolCallId);
const component = renderedPendingTools.get(message.toolCallId);
if (component) {
component.updateResult(message);
this.pendingTools.delete(message.toolCallId);
renderedPendingTools.delete(message.toolCallId);
}
} else {
// All other messages use standard rendering
@@ -3157,7 +3160,9 @@ export class InteractiveMode {
}
}
this.pendingTools.clear();
for (const [toolCallId, component] of renderedPendingTools) {
this.pendingTools.set(toolCallId, component);
}
this.ui.requestRender();
}
@@ -4645,6 +4650,34 @@ export class InteractiveMode {
}
}
private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise<string | undefined> {
return new Promise((resolve) => {
const restoreDialog = () => {
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
};
const labels = prompt.options.map((option) => option.label);
const selector = new ExtensionSelectorComponent(
prompt.message,
labels,
(optionLabel) => {
restoreDialog();
resolve(prompt.options.find((option) => option.label === optionLabel)?.id);
},
() => {
restoreDialog();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(selector);
this.ui.setFocus(selector);
this.ui.requestRender();
});
}
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
const providerInfo = this.session.modelRegistry.authStorage
.getOAuthProviders()
@@ -4722,6 +4755,8 @@ export class InteractiveMode {
dialog.showProgress(message);
},
onSelect: (prompt: OAuthSelectPrompt) => this.showOAuthLoginSelect(dialog, prompt),
onManualCodeInput: () => manualCodePromise,
signal: dialog.signal,

View File

@@ -35,11 +35,20 @@ function emitOsc52(text: string): boolean {
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;
const p = platform();
// Prefer direct clipboard writes. Emitting OSC 52 first can make terminals
// write the same native clipboard concurrently with the addon, and very large
// OSC 52 payloads can desynchronize terminal rendering.
//
// On Linux, skip the native addon. The underlying `clipboard-rs` crate is
// X11-only and does not retain selection ownership after `set_text`
// resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even
// some X11 sessions the call resolves successfully without populating the
// clipboard. The platform tools below (wl-copy, xclip, xsel) properly
// daemonize and keep ownership.
try {
if (clipboard) {
if (clipboard && p !== "linux") {
await clipboard.setText(text);
copied = true;
}
@@ -52,7 +61,6 @@ export async function copyToClipboard(text: string): Promise<void> {
return;
}
const p = platform();
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
if (!copied) {

View File

@@ -43,6 +43,21 @@ describe("parseArgs", () => {
const result = parseArgs(["-p"]);
expect(result.print).toBe(true);
});
test("parses prompt after -p even when it starts with YAML frontmatter", () => {
const prompt = "---\ntitle: hello\n---\nSay hi.";
const result = parseArgs(["-p", prompt]);
expect(result.print).toBe(true);
expect(result.messages).toEqual([prompt]);
expect(result.unknownFlags.size).toBe(0);
});
test("does not consume options after -p as prompts", () => {
const result = parseArgs(["-p", "--provider", "openai", "Say hi."]);
expect(result.print).toBe(true);
expect(result.provider).toBe("openai");
expect(result.messages).toEqual(["Say hi."]);
});
});
describe("--continue flag", () => {

View File

@@ -0,0 +1,40 @@
import { readFileSync } from "fs";
import { describe, expect, it } from "vitest";
describe("export HTML skill block rendering", () => {
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
it("strips skill wrapper XML from user message rendering", () => {
// Skill commands store a structural wrapper in the raw user message:
// <skill name="..." location="...">\n...\n</skill>\n\nactual prompt
// The export renderer must detect that wrapper and render only the user-visible prompt,
// not the Pi-generated <skill>...</skill> XML tags.
expect(templateJs).toMatch(/parseSkillBlock/);
expect(templateJs).toMatch(/skillBlock\.userMessage/);
});
it("renders skill invocation and user message as separate sibling blocks", () => {
// The skill block and user message should render as separate entry-level elements,
// matching the TUI layout where SkillInvocationMessageComponent and
// UserMessageComponent are siblings, not nested.
expect(templateJs).toMatch(/skill-invocation/);
// When a skill block has a userMessage, the user-message div must be emitted
// as a separate block after the skill-invocation div, containing the user-authored text.
// Verify the code checks hasUserContent so the user-message div is only omitted
// when the skill block has no user prompt and no images.
expect(templateJs).toMatch(/hasUserContent/);
});
it("renders skill content as markdown, not raw text", () => {
// The skill block body is markdown (from the SKILL.md file).
// It should be rendered through safeMarkedParse, not escaped as raw text.
expect(templateJs).toMatch(/safeMarkedParse\(skillBlock\.content\)/);
});
it("shows skill name and user message in the sidebar tree", () => {
// The sidebar tree should display both the skill name and the user prompt,
// not just one or the other.
expect(templateJs).toMatch(/tree-role-skill/);
});
});

View File

@@ -0,0 +1,164 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@mariozechner/pi-ai";
import { Container, Text, type TUI } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { beforeAll, describe, expect, test, vi } from "vitest";
import type { AgentSessionEvent } from "../../../src/core/agent-session.js";
import type { SessionContext } from "../../../src/core/session-manager.js";
import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.js";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js";
import { initTheme } from "../../../src/modes/interactive/theme/theme.js";
const TOOL_CALL_ID = "tool-4167";
const TOOL_NAME = "slow_tool";
const EMPTY_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
type RenderSessionContextThis = {
pendingTools: Map<string, ToolExecutionComponent>;
chatContainer: Container;
footer: { invalidate(): void };
ui: TUI;
settingsManager: {
getShowImages(): boolean;
getImageWidthCells(): number;
};
sessionManager: { getCwd(): string };
session: { retryAttempt: number };
toolOutputExpanded: boolean;
isInitialized: boolean;
updateEditorBorderColor(): void;
getRegisteredToolDefinition(toolName: string): undefined;
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
};
type RenderSessionContext = (
this: RenderSessionContextThis,
sessionContext: SessionContext,
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => void;
type HandleEvent = (this: RenderSessionContextThis, event: AgentSessionEvent) => Promise<void>;
function createFakeInteractiveModeThis(): RenderSessionContextThis {
const chatContainer = new Container();
return {
pendingTools: new Map<string, ToolExecutionComponent>(),
chatContainer,
footer: { invalidate: vi.fn() },
ui: { requestRender: vi.fn() } as unknown as TUI,
settingsManager: {
getShowImages: () => false,
getImageWidthCells: () => 60,
},
sessionManager: { getCwd: () => process.cwd() },
session: { retryAttempt: 0 },
toolOutputExpanded: false,
isInitialized: true,
updateEditorBorderColor: vi.fn(),
getRegisteredToolDefinition: (_toolName: string) => undefined,
addMessageToChat(message: AgentMessage) {
chatContainer.addChild(new Text(message.role, 0, 0));
},
};
}
function createAssistantToolCallMessage(): AssistantMessage {
return {
role: "assistant",
content: [
{
type: "toolCall",
id: TOOL_CALL_ID,
name: TOOL_NAME,
arguments: { delayMs: 10_000 },
},
],
api: "test-api",
provider: "test-provider",
model: "test-model",
usage: EMPTY_USAGE,
stopReason: "toolUse",
timestamp: Date.now(),
};
}
function createToolResultMessage(text: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
};
}
function createSessionContext(messages: AgentMessage[]): SessionContext {
return {
messages,
thinkingLevel: "off",
model: null,
};
}
function renderChat(container: Container): string {
return stripAnsi(container.render(120).join("\n"));
}
describe("InteractiveMode.renderSessionContext", () => {
beforeAll(() => {
initTheme("dark");
});
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionContext = (
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
).renderSessionContext;
const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent;
renderSessionContext.call(fakeThis, createSessionContext([createAssistantToolCallMessage()]));
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true);
await handleEvent.call(fakeThis, {
type: "tool_execution_end",
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
result: { content: [{ type: "text", text: "FINAL_RESULT" }], details: undefined },
isError: false,
});
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(false);
expect(renderChat(fakeThis.chatContainer)).toContain("FINAL_RESULT");
});
test("does not keep completed historical tool calls registered as pending", () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionContext = (
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
).renderSessionContext;
renderSessionContext.call(
fakeThis,
createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
);
expect(fakeThis.pendingTools.size).toBe(0);
expect(renderChat(fakeThis.chatContainer)).toContain("HISTORICAL_RESULT");
});
});

View File

@@ -3,7 +3,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { type BashOperations, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { computeEditsDiff } from "../src/core/tools/edit-diff.js";
import {
createEditTool,
@@ -449,6 +449,42 @@ describe("Coding Agent Tools", () => {
);
});
it("should include full output path for truncated timeout and abort errors", async () => {
for (const testCase of [
{ error: "timeout:5", expected: "Command timed out after 5 seconds" },
{ error: "aborted", expected: "Command aborted" },
]) {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 1; i <= 3000; i++) {
onData(Buffer.from(`${i}\n`, "utf-8"));
}
throw new Error(testCase.error);
},
};
const bash = createBashTool(testDir, { operations });
let error: unknown;
try {
await bash.execute(`test-call-${testCase.error}`, { command: "chatty-fail" });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain(testCase.expected);
expect(message).toMatch(/\[Showing lines \d+-\d+ of \d+\. Full output: /);
expect(message).not.toContain("Full output: undefined");
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
expect(fullOutputPath).toBeDefined();
expect(existsSync(fullOutputPath!)).toBe(true);
const fullOutput = readFileSync(fullOutputPath!, "utf-8");
expect(fullOutput).toContain("1\n2\n3");
expect(fullOutput).toContain("2998\n2999\n3000");
}
});
it("should throw error when cwd does not exist", async () => {
const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
@@ -517,6 +553,42 @@ describe("Coding Agent Tools", () => {
expect(getTextOutput(result).trim()).toBe("no-prefix");
});
it("should coalesce streaming updates for chatty output", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 0; i < 5000; i++) {
onData(Buffer.from(`line ${i}\n`, "utf-8"));
}
return { exitCode: 0 };
},
};
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-chatty-updates", { command: "chatty" }, undefined, (update) =>
updates.push(update),
);
expect(updates.length).toBeLessThan(25);
expect(getTextOutput(result)).toContain("line 4999");
});
it("should decode UTF-8 characters split across output chunks", async () => {
const euro = Buffer.from("€\n", "utf-8");
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(euro.subarray(0, 1));
onData(euro.subarray(1));
return { exitCode: 0 };
},
};
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-split-utf8", { command: "split-utf8" });
expect(getTextOutput(result).trim()).toBe("€");
});
it("should expose local bash operations for extension reuse", async () => {
const ops = createLocalBashOperations();
const chunks: Buffer[] = [];

View File

@@ -2,6 +2,16 @@
## [Unreleased]
### Fixed
- Fixed wrapped OSC 8 hyperlinks to preserve BEL terminators so OAuth login URLs remain clickable on every wrapped line.
## [0.73.0] - 2026-05-04
### Fixed
- Fixed fuzzy ranking to prioritize exact matches in selector and autocomplete results.
## [0.72.1] - 2026-05-02
## [0.72.0] - 2026-05-01

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-tui",
"version": "0.72.1",
"version": "0.73.0",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",

View File

@@ -60,6 +60,10 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
return { matches: false, score: 0 };
}
if (normalizedQuery === textLower) {
score -= 100;
}
return { matches: true, score };
};

View File

@@ -46,7 +46,10 @@ export function detectCapabilities(): TerminalCapabilities {
// sequences differently). Force hyperlinks off whenever we detect them, even
// when the outer terminal would otherwise support OSC 8. Image protocols are
// also unreliable under tmux/screen, so leave `images: null` for safety.
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
// cmux currently also gets this conservative fallback due to image corruption:
// https://github.com/badlogic/pi-mono/issues/4208
const inTmuxOrScreen =
!!process.env.TMUX || !!process.env.CMUX_WORKSPACE_ID || term.startsWith("tmux") || term.startsWith("screen");
if (inTmuxOrScreen) {
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: false };

View File

@@ -312,6 +312,42 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
return null;
}
type Osc8Terminator = "\x07" | "\x1b\\";
interface ActiveHyperlink {
params: string;
url: string;
terminator: Osc8Terminator;
}
function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {
if (!ansiCode.startsWith("\x1b]8;")) {
return undefined;
}
const terminator: Osc8Terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\";
const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2);
const separatorIndex = body.indexOf(";");
if (separatorIndex === -1) {
return undefined;
}
const params = body.slice(0, separatorIndex);
const url = body.slice(separatorIndex + 1);
if (!url) {
return null;
}
return { params, url, terminator };
}
function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {
return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;
}
function formatOsc8Close(terminator: Osc8Terminator): string {
return `\x1b]8;;${terminator}`;
}
/**
* Track active ANSI SGR codes to preserve styling across line breaks.
*/
@@ -327,13 +363,16 @@ class AnsiCodeTracker {
private strikethrough = false;
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
private activeHyperlink: string | null = null; // Active OSC 8 hyperlink URL, or null
private activeHyperlink: ActiveHyperlink | null = null;
process(ansiCode: string): void {
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close)
if (ansiCode.startsWith("\x1b]8;")) {
const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/);
this.activeHyperlink = m?.[1] ? m[1] : null;
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close).
// Preserve the original terminator because some terminals only make BEL-terminated
// links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST
// made only the first physical line clickable in those terminals.
const hyperlink = parseOsc8Hyperlink(ansiCode);
if (hyperlink !== undefined) {
this.activeHyperlink = hyperlink;
return;
}
@@ -495,7 +534,7 @@ class AnsiCodeTracker {
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
if (this.activeHyperlink) {
result += `\x1b]8;;${this.activeHyperlink}\x1b\\`;
result += formatOsc8Hyperlink(this.activeHyperlink);
}
return result;
}
@@ -528,7 +567,7 @@ class AnsiCodeTracker {
result += "\x1b[24m"; // Underline off only
}
if (this.activeHyperlink) {
result += "\x1b]8;;\x1b\\"; // Close hyperlink; re-opened at line start via getActiveCodes()
result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes()
}
return result;
}

View File

@@ -83,6 +83,13 @@ describe("fuzzyFilter", () => {
assert.strictEqual(result[0], "app");
});
it("prioritizes exact matches over longer prefix matches", () => {
const items = ["clone", "cl"];
const result = fuzzyFilter(items, "cl", (x: string) => x);
assert.deepStrictEqual(result, ["cl", "clone"]);
});
it("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },

View File

@@ -191,6 +191,21 @@ describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
}
});
it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => {
const url = `https://example.com/oauth/${"a".repeat(32)}`;
const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
const lines = wrapTextWithAnsi(input, 20);
assert.ok(lines.length > 1);
for (const line of lines) {
assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`);
assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`);
}
for (const line of lines.slice(0, -1)) {
assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`);
}
});
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
const url = "https://example.com";
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;

View File

@@ -2,6 +2,8 @@
## [Unreleased]
## [0.73.0] - 2026-05-04
## [0.72.1] - 2026-05-02
## [0.72.0] - 2026-05-01

View File

@@ -1,6 +1,6 @@
{
"name": "pi-web-ui-example",
"version": "0.72.1",
"version": "0.73.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-web-ui",
"version": "0.72.1",
"version": "0.73.0",
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
"type": "module",
"main": "dist/index.js",
@@ -18,8 +18,8 @@
},
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-tui": "^0.72.1",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-tui": "^0.73.0",
"typebox": "^1.1.24",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",

232
scripts/tool-stats.ts Executable file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env npx tsx
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
interface TextContent { type: "text"; text: string }
interface ImageContent { type: "image"; data: string; mimeType?: string }
interface ToolCallContent { type: "toolCall"; id: string; name: string; arguments?: Record<string, unknown> }
type Content = TextContent | ImageContent | ToolCallContent | { type: string; [key: string]: unknown };
interface Message { role?: string; content?: string | Content[]; toolCallId?: string; toolName?: string; details?: unknown }
interface Entry { type?: string; message?: Message }
interface ToolStats { calls: number; results: number; estimatedTokens: number; samples: number[]; errors: number }
interface BashCommandStats { calls: number; estimatedTokens: number; samples: number[] }
interface ToolCallInfo { toolName: string; bashCommand?: string }
const BUCKETS = [0, 50, 100, 250, 500, 1000, 2000, 4000, 8000, 16000, 32000, Number.POSITIVE_INFINITY];
function parseArgs(): { sessionsDir: string; output: string } {
let sessionsDir = join(homedir(), ".pi", "agent", "sessions");
let output = join(tmpdir(), "pi-tool-stats.html");
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--sessions-dir" && args[i + 1]) sessionsDir = resolve(args[++i]);
else if ((arg === "--output" || arg === "-o") && args[i + 1]) output = resolve(args[++i]);
else if (arg === "--help" || arg === "-h") {
console.log(`Usage: scripts/tool-stats.ts [--sessions-dir <dir>] [--output <file.html>]`);
process.exit(0);
}
}
return { sessionsDir, output };
}
function jsonlFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) out.push(...jsonlFiles(path));
else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
}
return out;
}
function getStats<T>(map: Map<string, T>, key: string, create: () => T): T {
let stats = map.get(key);
if (!stats) {
stats = create();
map.set(key, stats);
}
return stats;
}
function createToolStats(): ToolStats {
return { calls: 0, results: 0, estimatedTokens: 0, samples: [], errors: 0 };
}
function createBashStats(): BashCommandStats {
return { calls: 0, estimatedTokens: 0, samples: [] };
}
function estimateTokenCount(text: string): number {
return Math.ceil(text.length / 4);
}
function contentText(content: Message["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map((block) => {
if (block.type === "text" && "text" in block && typeof block.text === "string") return block.text;
if (block.type === "image" && "data" in block && typeof block.data === "string") return block.data;
return JSON.stringify(block);
}).join("\n");
}
function getBashCommand(args: Record<string, unknown> | undefined): string | undefined {
const command = args?.command;
return typeof command === "string" ? command : undefined;
}
function commandKey(command: string): string {
const first = command.split(/\n|&&|\|\||;|\|/)[0]?.trim() ?? command.trim();
const match = first.match(/^(?:\w+=\S+\s+)*(?:sudo\s+)?([^\s]+)(?:\s+([^\s]+))?/);
if (!match) return "unknown";
const bin = match[1] ?? "unknown";
const sub = match[2] && !match[2].startsWith("-") ? ` ${match[2]}` : "";
return `${bin}${sub}`;
}
function bucketCounts(samples: number[]): number[] {
const counts = new Array(BUCKETS.length - 1).fill(0) as number[];
for (const sample of samples) {
const index = BUCKETS.findIndex((max, i) => i > 0 && sample <= max) - 1;
counts[Math.max(0, index)]++;
}
return counts;
}
function bucketLabels(): string[] {
return BUCKETS.slice(0, -1).map((min, i) => {
const max = BUCKETS[i + 1];
return Number.isFinite(max) ? `${min}-${max}` : `${min}+`;
});
}
const { sessionsDir, output } = parseArgs();
if (!existsSync(sessionsDir)) throw new Error(`Sessions directory not found: ${sessionsDir}`);
const tools = new Map<string, ToolStats>();
const bashCommands = new Map<string, BashCommandStats>();
const callsById = new Map<string, ToolCallInfo>();
let parseErrors = 0;
const files = jsonlFiles(sessionsDir);
for (const file of files) {
for (const line of readFileSync(file, "utf8").split("\n")) {
if (!line.trim()) continue;
let entry: Entry;
try { entry = JSON.parse(line) as Entry; } catch { parseErrors++; continue; }
if (entry.type !== "message") continue;
const message = entry.message;
if (!message) continue;
if (message.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type !== "toolCall" || !("name" in block) || typeof block.name !== "string") continue;
const stats = getStats(tools, block.name, createToolStats);
stats.calls++;
const bashCommand = block.name === "bash" ? getBashCommand(block.arguments) : undefined;
callsById.set(block.id, { toolName: block.name, bashCommand });
if (bashCommand) getStats(bashCommands, commandKey(bashCommand), createBashStats).calls++;
}
} else if (message.role === "toolResult" && message.toolName) {
const text = contentText(message.content);
const tokens = estimateTokenCount(text);
const stats = getStats(tools, message.toolName, createToolStats);
stats.results++;
stats.estimatedTokens += tokens;
stats.samples.push(tokens);
if ("isError" in message && message.isError === true) stats.errors++;
const call = message.toolCallId ? callsById.get(message.toolCallId) : undefined;
if (call?.bashCommand) {
const bash = getStats(bashCommands, commandKey(call.bashCommand), createBashStats);
bash.estimatedTokens += tokens;
bash.samples.push(tokens);
}
}
}
}
const toolRows = [...tools.entries()].map(([name, s]) => ({ name, ...s, avg: s.results ? s.estimatedTokens / s.results : 0, histogram: bucketCounts(s.samples) })).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
const bashRows = [...bashCommands.entries()].map(([name, s]) => ({ name, ...s, avg: s.samples.length ? s.estimatedTokens / s.samples.length : 0, histogram: bucketCounts(s.samples) })).sort((a, b) => b.estimatedTokens - a.estimatedTokens).slice(0, 50);
const data = { generatedAt: new Date().toISOString(), sessionsDir, files: files.length, parseErrors, bucketLabels: bucketLabels(), tools: toolRows, bashCommands: bashRows };
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Pi Tool Stats</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
</head>
<body class="bg-zinc-950 text-zinc-100 p-6">
<main class="max-w-7xl mx-auto space-y-6">
<h1 class="text-3xl font-bold">Pi Tool Stats</h1>
<p class="text-zinc-400">${data.files} session files from <code>${sessionsDir}</code>. Generated ${data.generatedAt}.</p>
<section class="grid md:grid-cols-2 gap-6">
<div class="bg-zinc-900 rounded p-4"><h2 class="font-semibold mb-3">Estimated result tokens by tool</h2><canvas id="tokens"></canvas></div>
<div class="bg-zinc-900 rounded p-4"><h2 class="font-semibold mb-3">Tool calls</h2><canvas id="calls"></canvas></div>
</section>
<section class="grid md:grid-cols-2 gap-6">
<div class="bg-zinc-900 rounded p-4">
<div class="flex items-center justify-between gap-4 mb-3">
<h2 class="font-semibold">Tool result token histogram</h2>
<select id="toolSelect" class="bg-zinc-800 rounded px-2 py-1 text-sm"></select>
</div>
<p id="toolSummary" class="text-sm text-zinc-400 mb-3"></p>
<canvas id="toolHistogram" height="120"></canvas>
</div>
<div class="bg-zinc-900 rounded p-4">
<div class="flex items-center justify-between gap-4 mb-3">
<h2 class="font-semibold">Bash result token histogram</h2>
<select id="bashSelect" class="bg-zinc-800 rounded px-2 py-1 text-sm"></select>
</div>
<p id="bashSummary" class="text-sm text-zinc-400 mb-3"></p>
<canvas id="bashHistogram" height="120"></canvas>
</div>
</section>
<section class="bg-zinc-900 rounded p-4"><h2 class="font-semibold mb-3">Tools</h2><div id="tools"></div></section>
<section class="bg-zinc-900 rounded p-4">
<h2 class="font-semibold mb-3">Bash common commands (best effort)</h2>
<div id="bash" class="mt-4"></div>
</section>
</main>
<script>
const data=${JSON.stringify(data)};
function fmt(n){return Math.round(n).toLocaleString()}
function esc(s){return String(s).replace(/[&<>\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
function table(rows,el){
document.getElementById(el).innerHTML='<table class="w-full text-sm"><thead class="text-zinc-400"><tr><th class="text-left p-2">Name</th><th class="text-right p-2">Calls</th><th class="text-right p-2">Results</th><th class="text-right p-2">Est. tokens</th><th class="text-right p-2">Avg/result</th><th class="text-left p-2 w-64">Histogram</th></tr></thead><tbody>'+rows.map((r,i)=>'<tr class="border-t border-zinc-800 hover:bg-zinc-800/50 cursor-pointer" data-row="'+i+'"><td class="p-2 font-mono">'+esc(r.name)+'</td><td class="p-2 text-right">'+fmt(r.calls)+'</td><td class="p-2 text-right">'+fmt(r.results??r.samples.length)+'</td><td class="p-2 text-right">'+fmt(r.estimatedTokens)+'</td><td class="p-2 text-right">'+fmt(r.avg)+'</td><td class="p-2"><canvas id="'+el+'Hist'+i+'" height="34"></canvas></td></tr>').join('')+'</tbody></table>';
rows.forEach((r,i)=>new Chart(document.getElementById(el+'Hist'+i),{type:'bar',data:{labels:data.bucketLabels,datasets:[{data:r.histogram,label:r.name,backgroundColor:'#60a5fa'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{title:(items)=>data.bucketLabels[items[0].dataIndex]+' tokens'}}},scales:{x:{display:false},y:{display:false}}}}));
document.querySelectorAll('#'+el+' tr[data-row]').forEach(row=>row.addEventListener('click',()=>document.getElementById(el==='tools'?'toolSelect':'bashSelect').value=row.dataset.row,document.getElementById(el==='tools'?'toolSelect':'bashSelect').dispatchEvent(new Event('change'))));
}
function fillSelect(id,rows){document.getElementById(id).innerHTML=rows.map((r,i)=>'<option value="'+i+'">'+esc(r.name)+'</option>').join('')}
function summary(r){return fmt(r.calls)+' calls, '+fmt(r.results??r.samples.length)+' results, '+fmt(r.estimatedTokens)+' estimated result tokens, '+fmt(r.avg)+' avg/result'}
function singleHistogram(canvasId,summaryId,selectId,rows){
let chart;
function update(){
const row=rows[Number(document.getElementById(selectId).value)]??rows[0];
document.getElementById(summaryId).textContent=row?summary(row):'No data';
if(chart)chart.destroy();
chart=new Chart(document.getElementById(canvasId),{type:'bar',data:{labels:data.bucketLabels,datasets:[{label:row?.name??'',data:row?.histogram??[],backgroundColor:'#60a5fa'}]},options:{plugins:{legend:{display:false},tooltip:{callbacks:{title:(items)=>data.bucketLabels[items[0].dataIndex]+' tokens',label:(item)=>fmt(item.raw)+' results'}}},scales:{y:{beginAtZero:true,title:{display:true,text:'result count'}},x:{title:{display:true,text:'estimated tokens/result'}}}}});
}
document.getElementById(selectId).addEventListener('change',update);
update();
}
table(data.tools,'tools');
table(data.bashCommands,'bash');
fillSelect('toolSelect',data.tools);
fillSelect('bashSelect',data.bashCommands);
singleHistogram('toolHistogram','toolSummary','toolSelect',data.tools);
singleHistogram('bashHistogram','bashSummary','bashSelect',data.bashCommands);
new Chart(document.getElementById('tokens'),{type:'bar',data:{labels:data.tools.map(r=>r.name),datasets:[{label:'estimated tokens',data:data.tools.map(r=>r.estimatedTokens)}]},options:{plugins:{legend:{display:false}}}});
new Chart(document.getElementById('calls'),{type:'bar',data:{labels:data.tools.map(r=>r.name),datasets:[{label:'calls',data:data.tools.map(r=>r.calls)}]},options:{plugins:{legend:{display:false}}}});
</script>
</body>
</html>`;
mkdirSync(resolve(output, ".."), { recursive: true });
writeFileSync(output, html);
console.log(`Wrote ${output}`);
spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open", process.platform === "win32" ? ["/c", "start", output] : [output], { detached: true, stdio: "ignore" }).unref();