feat(monorepo): merged codex providers and extensions

This commit is contained in:
can1357
2026-01-05 15:37:44 +01:00
parent 56e760f2bc
commit 15fbe228b3
176 changed files with 16020 additions and 2443 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"linter": {
"enabled": true,
"rules": {
+41 -32
View File
@@ -89,9 +89,10 @@
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/ms": "^2.1.0",
"@types/ndjson": "^2.0.4",
"@types/node": "^24.3.0",
"vitest": "^3.2.4",
"ms": "^2.1.3",
},
},
"packages/git-tool": {
@@ -432,49 +433,55 @@
"@preact/signals-core": ["@preact/signals-core@1.12.1", "", {}, "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.46", "", {}, "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ=="],
@@ -540,6 +547,8 @@
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/ndjson": ["@types/ndjson@2.0.4", "", { "dependencies": { "@types/node": "*", "@types/through": "*" } }, "sha512-ajAl7AjhFstF6waORYNSS49GL5iBKisqJlgvXuprXFKCX9fto4ordlNU3+XMgkMddgeR0WoQQBmKUk0v0dJ4pw=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
@@ -554,21 +563,21 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260104.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260104.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260104.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260104.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260104.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260104.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260104.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260104.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-uUf3nybwgHpDpzPFjreMb6aDnyp1vcVdWqp7Ac4bmiOAtE0TlmvoSI7KTJeTZ8xozFVyHyCoFch5bNyDQr3uHQ=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260105.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260105.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260105.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260105.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260105.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260105.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260105.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260105.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-PjmhqnN/jRDLxG/5EuCe8AlW1QUEOjcDJQsxarQmMbrdW5DbSDnYUvWbYbJescXefeK0v3FUQmp7HAOCFQ7I/w=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260104.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tVPVpozQmSVTpn97Uu+ZDdC5G9YkHt6YPgv/wtzDVLc8CRCpCwIZZF8MvWMar8f4WiA+3uJIMeX+RAsSQknu5w=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260105.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d+CJrdiElzHuckgsXLHlBRLbHsgzWqQuSVOZ/raF6cvBKjylnphNPx+CdtOpZrBCic0M30Q/UfTV6StMDhjIrQ=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260104.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-6fdywq9zx2QJtDpZKt59e+y8P/WDd10KsX6MReDpaKXTtGpqkVq9+Zb7LbNqRlLJU4gpwURepCTqX/AsZjzUwg=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260105.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ntgJZDSNh7tqw2bfBuEEqhwUbuALLTLY4E/pLdCC8vaL/2QrNcTuDZX23hcLa7pQL6ML2OVvEH1MC7A1BUO/WA=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260104.1", "", { "os": "linux", "cpu": "arm" }, "sha512-mi/NiyKyH/CpNrL3FNCL7Vp9Q/c7MW+7La6xNgpG29M7SK4U9LnBnJLSHS0ahpnt2QsZBlCY6NaRZpbKT2pYNw=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260105.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Pv8aEc9OKG++9dPzyWwZRXt1WEHH7saAYpGrYWKnR0mMDt8yRRPX2g9ReCSFLJ/9ji3nvBg3n/wUXDXOdMQDDg=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260104.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-M7vUDJGXDUfIKO7X+DlBIoKaE3uA6dKx8wVky39FHcvjiTJIR+tphPU3uLY7xLpuj3y9PtGQQ2WoGY2wcft4Ug=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260105.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uBj+8EPCZ8bFSosovwmgF16r4NXHgq0Wc3Ddg48KDqUl9njsVjWbg6jv3H5OXm7nnLKgRzmq9B30cHuWhlKsMg=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260104.1", "", { "os": "linux", "cpu": "x64" }, "sha512-58eQFmQLCFHmPxyaLT7p3lJNHjcxqLwxO5IFAujrMp+VPHCUuL9QfxcKWnCJlkjgoKd8c95pT0ZNzBhsq00Hyg=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260105.1", "", { "os": "linux", "cpu": "x64" }, "sha512-vy1IpYseSpIP6X0TKnejwKH75LO06VfI7DRV9ShqgHC6Ybd7AdrTrXlna13sFF6FuD8vd3ZefesbS4n4d0Ywzw=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260104.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-g5pE6oveg7lDsBP8nYnjhikMzoZbMWEVYG1JkVZcNUG9equw1Fg4cZdBa/HbZENj1Afy9xrzrRywpvhAYF0lDw=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260105.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EFSpj3zgVsYTztgeuxhOlOpx0hr2vTVxPyccEHfiseMcAjkAsbdoSeYSLZFZyW2JXqJtq6N3YWsGGKzoyP0f+Q=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260104.1", "", { "os": "win32", "cpu": "x64" }, "sha512-kdufkNpH5DNIeJ5ZHsfcxTC30IfTkvW+ITc0IHHv4plgKiTJ71i/BZDavkTNmpd1zODe4P8PVGrRrGYpdVF06A=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260105.1", "", { "os": "win32", "cpu": "x64" }, "sha512-UK0+xTxu6UZA8isOThWT9LOAl6LB1WyYFLC0ijno4SWwQCIbnJi3wjnAkBxRyM8h46DUaDYpXfHVkpYwhdExhg=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@@ -1136,7 +1145,7 @@
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+215
View File
@@ -0,0 +1,215 @@
# Porting From pi-mono: A Practical Merge Guide
This guide is a repeatable checklist for porting changes from pi-mono into this repo.
Use it for any merge: single file, feature branch, or full release sync.
## 0) Define the scope
- Identify the upstream reference (commit, tag, or PR).
- List the packages or folders you plan to touch.
- Decide which features are in-scope and which are intentionally skipped.
## 1) Bring code over safely
- Prefer a clean, focused diff rather than a wholesale copy.
- Avoid copying built artifacts or generated files.
- If upstream added new files, add them explicitly and review contents.
## 2) Remove `.js` from imports
We use a bundler and strip `.js` from TypeScript imports.
- Remove `.js` extensions from all internal imports.
- Keep real file extensions only when required by tooling (e.g., `.json`, `.css`).
- Example:
- `import { x } from "./foo.js";` -> `import { x } from "./foo";`
## 3) Replace import scopes
Upstream uses different package scopes. Replace them consistently.
- Replace old scopes with the local scope used here.
- Examples (adjust to match the actual packages you are porting):
- `@mariozechner/pi-coding-agent` -> `@oh-my-pi/pi-coding-agent`
- `@mariozechner/pi-ai` -> `@oh-my-pi/pi-ai`
- `@badlogic/pi-tui` -> `@oh-my-pi/pi-tui`
## 4) Use Bun APIs where they improve on Node
We run on Bun. Replace Node APIs only when Bun provides a better alternative.
**DO replace:**
- Process spawning: `child_process.spawn` → `Bun.spawn` / `Bun.spawnSync`
- File I/O: `fs.readFileSync` → `Bun.file().text()` / `Bun.write()`
- HTTP clients: `node-fetch`, `axios` → native `fetch`
- Crypto hashing: `node:crypto` → Web Crypto or `Bun.hash`
- SQLite: `better-sqlite3` → `bun:sqlite`
- Env loading: `dotenv` → Bun loads `.env` automatically
**DO NOT replace (these work fine in Bun):**
- `os.homedir()` — do NOT replace with `process.env.HOME`, `Bun.env.HOME`, or literal `"~"`
- `os.tmpdir()` — do NOT replace with `Bun.env.TMPDIR || "/tmp"` or hardcoded paths
- `fs.mkdtempSync()` — do NOT replace with manual path construction
- `path.join()`, `path.resolve()`, etc. — these are fine
**Import style:** Use `node:` prefix for Node builtins (`import { homedir } from "node:os"`).
**Wrong:**
```typescript
// BROKEN: env vars may be undefined, "~" is not expanded
const home = process.env.HOME || Bun.env.HOME || "~";
const tmp = Bun.env.TMPDIR || "/tmp";
```
**Correct:**
```typescript
import { homedir, tmpdir } from "node:os";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
const configDir = join(homedir(), ".config", "myapp");
const tempDir = mkdtempSync(join(tmpdir(), "myapp-"));
```
## 5) Prefer Bun embeds (no copying)
Do not copy runtime assets or vendor files at build time.
- If upstream copies assets into a dist folder, replace with Bun-friendly embeds.
- Use `import.meta.dir` + `Bun.file` to load adjacent resources.
- Keep assets in-repo and let the bundler include them.
- Eliminate copy scripts unless the user explicitly requests them.
- If upstream reads a bundled fallback file at runtime, replace filesystem reads with a Bun text embed import.
- Example (Codex instructions fallback):
- `const FALLBACK_PROMPT_PATH = join(import.meta.dir, "codex-instructions.md");` -> removed
- `import FALLBACK_INSTRUCTIONS from "./codex-instructions.md" with { type: "text" };`
- Use `return FALLBACK_INSTRUCTIONS;` instead of `readFileSync(FALLBACK_PROMPT_PATH, "utf8")`
## 6) Port `package.json` carefully
Treat `package.json` as a contract. Merge intentionally.
- Keep existing `name`, `version`, `type`, `exports`, and `bin` unless the port requires changes.
- Replace npm/node scripts with Bun equivalents (e.g., `bun run`, `bun test`).
- Ensure dependencies use the correct scope.
- Do not downgrade dependencies to fix type errors; upgrade instead.
- Validate workspace package links and `peerDependencies`.
## 7) Align code style and tooling
- Keep existing formatting conventions.
- Do not introduce `any` unless required.
- Avoid dynamic imports and inline type imports.
- Prefer existing helpers and utilities over new ad-hoc code.
- Preserve Bun-first infrastructure changes already made in this repo:
- Runtime is Bun (no Node entry points).
- Package manager is Bun (no npm lockfiles).
- Heavy Node APIs (`child_process`, `readline`) are replaced with Bun equivalents.
- Lightweight Node APIs (`os.homedir`, `os.tmpdir`, `fs.mkdtempSync`, `path.*`) are kept.
- CLI shebangs use `bun` (not `node`, not `tsx`).
- Packages use source files directly (no TypeScript build step).
- CI workflows run Bun for install/check/test.
## 8) Remove old compatibility layers
Unless requested, remove upstream compatibility shims.
- Delete old APIs that were replaced.
- Update all call sites to the new API directly.
- Do not keep `*_v2` or parallel versions.
## 9) Update docs and references
- Replace pi-mono repo links where appropriate.
- Update examples to use Bun and correct package scopes.
- Ensure README instructions still match the current repo behavior.
## 10) Validate the port
Run the standard checks after changes:
- `bun run check`
If the repo already has failing checks unrelated to your changes, call that out.
Tests use Bun's runner (not Vitest), but only run `bun test` when explicitly requested.
## 11) Protect improved features (regression trap list)
If you already improved behavior locally, treat those as **non‑negotiable**. Before porting, write down
the improvements and add explicit checks so they don’t get lost in the merge.
- **Freeze the expected behavior**: add a short “before/after” note for each improvement (inputs, outputs,
defaults, edge cases). This prevents silent rollback.
- **Map old → new APIs**: if upstream renamed concepts (hooks → extensions, custom tools → tools, etc.),
ensure every old entry point still wires through. One missed flag or export equals lost functionality.
- **Verify exports**: check `package.json` `exports`, public types, and barrel files. Upstream ports often
forget to re-export local additions.
- **Cover non‑happy paths**: if you fixed error handling, timeouts, or fallback logic, add a test or at
least a manual checklist that exercises those paths.
- **Check defaults and config merge order**: improvements often live in defaults. Confirm new defaults
didn’t revert (e.g., new config precedence, disabled features, tool lists).
- **Audit env/shell behavior**: if you fixed execution or sandboxing, verify the new path still uses your
sanitized env and does not reintroduce alias/function overrides.
- **Re-run targeted samples**: keep a minimal set of “known good” examples and run them after the port
(CLI flags, extension registration, tool execution).
## 11) Detect and handle reworked code
Before porting a file, check if upstream significantly refactored it:
```bash
# Compare the file you're about to port against what you have locally
git diff HEAD upstream/main -- path/to/file.ts
```
If the diff shows the file was **reworked** (not just patched):
- New abstractions, renamed concepts, merged modules, changed data flow
Then you must **read the new implementation thoroughly** before porting. Blind merging of reworked code loses functionality because:
1. **Defaults change silently** - A new variable `defaultFoo = [a, b]` may replace an old `getAllFoo()` that returned `[a, b, c, d, e]`.
2. **API options get dropped** - When systems merge (e.g., `hooks` + `customTools` → `extensions`), old options may not wire through to the new implementation.
3. **Code paths go stale** - A renamed concept (e.g., `hookMessage` → `custom`) needs updates in every switch statement, type guard, and handler—not just the definition.
4. **Context/capabilities shrink** - Old APIs may have exposed `{ logger, typebox, pi }` that new APIs forgot to include.
### Semantic porting process
When upstream reworked a module:
1. **Read the old implementation** - Understand what it did, what options it accepted, what it exposed.
2. **Read the new implementation** - Understand the new abstractions and how they map to old behavior.
3. **Verify feature parity** - For each capability in the old code, confirm the new code preserves it or explicitly removes it.
4. **Grep for stragglers** - Search for old names/concepts that may have been missed in switch statements, handlers, UI components.
5. **Test the boundaries** - CLI flags, SDK options, event handlers, default values—these are where regressions hide.
### Quick checks
```bash
# Find all uses of an old concept that may need updating
rg "oldConceptName" --type ts
# Compare default values between versions
git show upstream/main:path/to/file.ts | rg "default|DEFAULT"
# Check if all enum/union values have handlers
rg "case \"" path/to/file.ts
```
## 12) Quick audit checklist
Use this as a final pass before you finish:
- [ ] No `.js` import extensions in TS files
- [ ] No Node-only APIs in new/ported code
- [ ] All package scopes updated
- [ ] `package.json` scripts use Bun
- [ ] Assets load via Bun embed patterns (no copy scripts)
- [ ] Tests or checks run (or explicitly noted as blocked)
- [ ] No functionality regressions (see section 11)
+14 -1
View File
@@ -1,6 +1,19 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Replaced `queueMessage`/`queueMode` with steering + follow-up queues: use `steer`, `setSteeringMode`, and `getSteeringMode` for mid-run interruptions, and `followUp`, `setFollowUpMode`, and `getFollowUpMode` for post-turn messages
- Agent loop callbacks now use `getSteeringMessages` and `getFollowUpMessages` instead of `getQueuedMessages`
### Added
- Added follow-up message queue support so new user messages can continue a run after the agent would otherwise stop
- Added `RenderResultOptions.spinnerFrame` for animated tool-result rendering
### Changed
- `prompt()` and `continue()` now throw when the agent is already streaming; use steering or follow-up queues instead
## [3.15.1] - 2026-01-05
@@ -104,4 +117,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
- `Agent` constructor now has all options optional (empty options use defaults).
- `queueMessage()` is now synchronous (no longer returns a Promise).
- `queueMessage()` is now synchronous (no longer returns a Promise).
+14 -9
View File
@@ -224,26 +224,31 @@ const unsubscribe = agent.subscribe((event) => {
unsubscribe();
```
## Message Queue
## Steering & Follow-up
Queue messages to inject during tool execution (for user interruptions):
Queue messages to inject during tool execution (steering) or after the agent would otherwise stop (follow-up):
```typescript
agent.setQueueMode("one-at-a-time");
agent.setSteeringMode("one-at-a-time");
agent.setInterruptMode("immediate");
// While agent is running tools
agent.queueMessage({
agent.steer({
role: "user",
content: "Stop! Do this instead.",
timestamp: Date.now(),
});
// Queue a follow-up to run after the current turn completes
agent.followUp({
role: "user",
content: "After that, summarize the changes.",
timestamp: Date.now(),
});
```
When queued messages are detected after a tool completes:
1. Remaining tools are skipped with error results
2. Queued message is injected
3. LLM responds to the interruption
Steering messages are checked after each tool call by default. Set `interruptMode` to `"wait"` to defer
steering until the current turn completes.
## Custom Message Types
+119 -91
View File
@@ -109,73 +109,90 @@ async function runLoop(
stream: EventStream<AgentEvent, AgentMessage[]>,
streamFn?: StreamFn,
): Promise<void> {
let hasMoreToolCalls = true;
let firstTurn = true;
let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || [];
let queuedAfterTools: AgentMessage[] | null = null;
// Check for steering messages at start (user may have typed while waiting)
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
while (hasMoreToolCalls || queuedMessages.length > 0) {
if (!firstTurn) {
stream.push({ type: "turn_start" });
} else {
firstTurn = false;
}
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
let steeringAfterTools: AgentMessage[] | null = null;
// Process queued messages (inject before next assistant response)
if (queuedMessages.length > 0) {
for (const message of queuedMessages) {
stream.push({ type: "message_start", message });
stream.push({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (!firstTurn) {
stream.push({ type: "turn_start" });
} else {
firstTurn = false;
}
queuedMessages = [];
}
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
// Process pending messages (inject before next assistant response)
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
stream.push({ type: "message_start", message });
stream.push({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
}
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "turn_end", message, toolResults: [] });
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);
return;
}
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "turn_end", message, toolResults: [] });
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);
return;
}
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(
currentContext.tools,
message,
signal,
stream,
config.getQueuedMessages,
config.getToolContext,
config.interruptMode,
);
toolResults.push(...toolExecution.toolResults);
queuedAfterTools = toolExecution.queuedMessages ?? null;
// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(
currentContext.tools,
message,
signal,
stream,
config.getSteeringMessages,
config.getToolContext,
config.interruptMode,
);
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
}
}
stream.push({ type: "turn_end", message, toolResults });
// Get steering messages after turn completes
if (steeringAfterTools && steeringAfterTools.length > 0) {
pendingMessages = steeringAfterTools;
steeringAfterTools = null;
} else {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
}
stream.push({ type: "turn_end", message, toolResults });
// Get queued messages after turn completes
if (queuedAfterTools && queuedAfterTools.length > 0) {
queuedMessages = queuedAfterTools;
queuedAfterTools = null;
} else {
queuedMessages = (await config.getQueuedMessages?.()) || [];
// Agent would stop here. Check for follow-up messages.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
// Set as pending so inner loop processes them
pendingMessages = followUpMessages;
continue;
}
// No more messages, exit
break;
}
stream.push({ type: "agent_end", messages: newMessages });
@@ -225,9 +242,35 @@ async function streamAssistantResponse(
let addedPartial = false;
for await (const event of response) {
// Check abort early - allows TTSR and other abort sources to break immediately
// Check for abort signal before processing each event
if (signal?.aborted) {
break;
const abortedMessage: AssistantMessage = partialMessage
? { ...partialMessage, stopReason: "aborted" }
: {
role: "assistant",
content: [],
api: config.model.api,
provider: config.model.provider,
model: config.model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "aborted",
timestamp: Date.now(),
};
if (addedPartial) {
context.messages[context.messages.length - 1] = abortedMessage;
} else {
context.messages.push(abortedMessage);
stream.push({ type: "message_start", message: { ...abortedMessage } });
}
stream.push({ type: "message_end", message: abortedMessage });
return abortedMessage;
}
switch (event.type) {
@@ -273,22 +316,6 @@ async function streamAssistantResponse(
return finalMessage;
}
}
// Check abort after processing - allows handlers to abort mid-stream
if (signal?.aborted) {
break;
}
}
// If we broke out due to abort, return an aborted message
if (signal?.aborted && partialMessage) {
const abortedMessage: AssistantMessage = {
...partialMessage,
stopReason: "aborted",
};
context.messages[context.messages.length - 1] = abortedMessage;
stream.push({ type: "message_end", message: abortedMessage });
return abortedMessage;
}
return await response.result();
@@ -302,13 +329,14 @@ async function executeToolCalls(
assistantMessage: AssistantMessage,
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, AgentMessage[]>,
getQueuedMessages?: AgentLoopConfig["getQueuedMessages"],
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
getToolContext?: AgentLoopConfig["getToolContext"],
interruptMode?: AgentLoopConfig["interruptMode"],
): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> {
interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
const results: ToolResultMessage[] = [];
let queuedMessages: AgentMessage[] | undefined;
let steeringMessages: AgentMessage[] | undefined;
const shouldInterruptImmediately = interruptMode !== "wait";
for (let index = 0; index < toolCalls.length; index++) {
const toolCall = toolCalls[index];
@@ -322,17 +350,14 @@ async function executeToolCalls(
});
let result: AgentToolResult<any>;
let isError = false;
const details: { toolCallId: string; toolName: string; isError?: boolean } = {
toolCallId: toolCall.id,
toolName: toolCall.name,
};
try {
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
const validatedArgs = validateToolArguments(tool, toolCall);
const toolContext = getToolContext?.();
const toolContext = getToolContext ? getToolContext() : undefined;
result = await tool.execute(
toolCall.id,
validatedArgs,
@@ -353,33 +378,36 @@ async function executeToolCalls(
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
details: {},
};
details.isError = true;
isError = true;
}
stream.push({
type: "tool_execution_end",
toolCallId: toolCall.id,
toolName: toolCall.name,
result,
...details,
isError,
});
const toolResultMessage: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: result.content,
details: result.details,
isError,
timestamp: Date.now(),
...details,
};
results.push(toolResultMessage);
stream.push({ type: "message_start", message: toolResultMessage });
stream.push({ type: "message_end", message: toolResultMessage });
// Check for queued messages - skip remaining tools if user interrupted
// Only interrupt mid-execution if interruptMode is "immediate" (default)
if (interruptMode !== "wait" && getQueuedMessages) {
const queued = await getQueuedMessages();
if (queued.length > 0) {
queuedMessages = queued;
// Check for steering messages - skip remaining tools if user interrupted
if (shouldInterruptImmediately && getSteeringMessages) {
const steering = await getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
const remainingCalls = toolCalls.slice(index + 1);
for (const skipped of remainingCalls) {
results.push(skipToolCall(skipped, stream));
@@ -389,7 +417,7 @@ async function executeToolCalls(
}
}
return { toolResults: results, queuedMessages };
return { toolResults: results, steeringMessages };
}
function skipToolCall(
+101 -33
View File
@@ -48,13 +48,19 @@ export interface AgentOptions {
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
/**
* Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn
* Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
*/
queueMode?: "all" | "one-at-a-time";
steeringMode?: "all" | "one-at-a-time";
/**
* Interrupt mode: "immediate" = check queue after each tool (interrupt remaining),
* "wait" = only process queue after turn completes
* Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn
*/
followUpMode?: "all" | "one-at-a-time";
/**
* When to interrupt tool execution for steering messages.
* - "immediate": check after each tool call (default)
* - "wait": defer steering until the current turn completes
*/
interruptMode?: "immediate" | "wait";
@@ -71,6 +77,7 @@ export interface AgentOptions {
/**
* Provides tool execution context, resolved per tool call.
* Use for late-bound UI or session state access.
*/
getToolContext?: () => AgentToolContext | undefined;
}
@@ -92,8 +99,10 @@ export class Agent {
private abortController?: AbortController;
private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
private messageQueue: AgentMessage[] = [];
private queueMode: "all" | "one-at-a-time";
private steeringQueue: AgentMessage[] = [];
private followUpQueue: AgentMessage[] = [];
private steeringMode: "all" | "one-at-a-time";
private followUpMode: "all" | "one-at-a-time";
private interruptMode: "immediate" | "wait";
public streamFn: StreamFn;
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
@@ -105,7 +114,8 @@ export class Agent {
this._state = { ...this._state, ...opts.initialState };
this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
this.transformContext = opts.transformContext;
this.queueMode = opts.queueMode || "one-at-a-time";
this.steeringMode = opts.steeringMode || "one-at-a-time";
this.followUpMode = opts.followUpMode || "one-at-a-time";
this.interruptMode = opts.interruptMode || "immediate";
this.streamFn = opts.streamFn || streamSimple;
this.getApiKey = opts.getApiKey;
@@ -134,12 +144,20 @@ export class Agent {
this._state.thinkingLevel = l;
}
setQueueMode(mode: "all" | "one-at-a-time") {
this.queueMode = mode;
setSteeringMode(mode: "all" | "one-at-a-time") {
this.steeringMode = mode;
}
getQueueMode(): "all" | "one-at-a-time" {
return this.queueMode;
getSteeringMode(): "all" | "one-at-a-time" {
return this.steeringMode;
}
setFollowUpMode(mode: "all" | "one-at-a-time") {
this.followUpMode = mode;
}
getFollowUpMode(): "all" | "one-at-a-time" {
return this.followUpMode;
}
setInterruptMode(mode: "immediate" | "wait") {
@@ -162,26 +180,51 @@ export class Agent {
this._state.messages = [...this._state.messages, m];
}
queueMessage(m: AgentMessage) {
this.messageQueue.push(m);
popMessage(): AgentMessage | undefined {
const messages = this._state.messages.slice(0, -1);
const removed = this._state.messages.at(-1);
this._state.messages = messages;
if (removed && this._state.streamMessage === removed) {
this._state.streamMessage = null;
}
return removed;
}
clearMessageQueue() {
this.messageQueue = [];
/**
* Queue a steering message to interrupt the agent mid-run.
* Delivered after current tool execution, skips remaining tools.
*/
steer(m: AgentMessage) {
this.steeringQueue.push(m);
}
/**
* Queue a follow-up message to be processed after the agent finishes.
* Delivered only when agent has no more tool calls or steering messages.
*/
followUp(m: AgentMessage) {
this.followUpQueue.push(m);
}
clearSteeringQueue() {
this.steeringQueue = [];
}
clearFollowUpQueue() {
this.followUpQueue = [];
}
clearAllQueues() {
this.steeringQueue = [];
this.followUpQueue = [];
}
clearMessages() {
this._state.messages = [];
}
/** Remove and return the last message from the message list */
popMessage(): AgentMessage | undefined {
if (this._state.messages.length === 0) return undefined;
const popped = this._state.messages[this._state.messages.length - 1];
this._state.messages = this._state.messages.slice(0, -1);
return popped;
}
abort() {
this.abortController?.abort();
}
@@ -196,13 +239,20 @@ export class Agent {
this._state.streamMessage = null;
this._state.pendingToolCalls = new Set<string>();
this._state.error = undefined;
this.messageQueue = [];
this.steeringQueue = [];
this.followUpQueue = [];
}
/** Send a prompt with an AgentMessage */
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
async prompt(input: string, images?: ImageContent[]): Promise<void>;
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) {
if (this._state.isStreaming) {
throw new Error(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
}
const model = this._state.model;
if (!model) throw new Error("No model configured");
@@ -231,6 +281,10 @@ export class Agent {
/** Continue from current context (for retry after overflow) */
async continue() {
if (this._state.isStreaming) {
throw new Error("Agent is already processing. Wait for completion before continuing.");
}
const messages = this._state.messages;
if (messages.length === 0) {
throw new Error("No messages to continue from");
@@ -276,23 +330,37 @@ export class Agent {
const config: AgentLoopConfig = {
model,
reasoning,
interruptMode: this.interruptMode,
convertToLlm: this.convertToLlm,
transformContext: this.transformContext,
getApiKey: this.getApiKey,
getToolContext: this.getToolContext,
interruptMode: this.interruptMode,
getQueuedMessages: async () => {
if (this.queueMode === "one-at-a-time") {
if (this.messageQueue.length > 0) {
const first = this.messageQueue[0];
this.messageQueue = this.messageQueue.slice(1);
getSteeringMessages: async () => {
if (this.steeringMode === "one-at-a-time") {
if (this.steeringQueue.length > 0) {
const first = this.steeringQueue[0];
this.steeringQueue = this.steeringQueue.slice(1);
return [first];
}
return [];
} else {
const queued = this.messageQueue.slice();
this.messageQueue = [];
return queued;
const steering = this.steeringQueue.slice();
this.steeringQueue = [];
return steering;
}
},
getFollowUpMessages: async () => {
if (this.followUpMode === "one-at-a-time") {
if (this.followUpQueue.length > 0) {
const first = this.followUpQueue[0];
this.followUpQueue = this.followUpQueue.slice(1);
return [first];
}
return [];
} else {
const followUp = this.followUpQueue.slice();
this.followUpQueue = [];
return followUp;
}
},
};
+20 -9
View File
@@ -22,6 +22,13 @@ export type StreamFn = (
export interface AgentLoopConfig extends SimpleStreamOptions {
model: Model<any>;
/**
* When to interrupt tool execution for steering messages.
* - "immediate": check after each tool call (default)
* - "wait": defer steering until the current turn completes
*/
interruptMode?: "immediate" | "wait";
/**
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
*
@@ -32,7 +39,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
* @example
* ```typescript
* convertToLlm: (messages) => messages.flatMap(m => {
* if (m.role === "hookMessage") {
* if (m.role === "custom") {
* // Convert custom message to user message
* return [{ role: "user", content: m.content, timestamp: m.timestamp }];
* }
@@ -75,20 +82,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
/**
* Returns queued messages to inject into the conversation.
* Returns steering messages to inject into the conversation mid-run.
*
* Called after each turn to check for user interruptions or injected messages.
* If messages are returned, they're added to the context before the next LLM call.
* Called after each tool execution to check for user interruptions unless interruptMode is "wait".
* If messages are returned, remaining tool calls are skipped and
* these messages are added to the context before the next LLM call.
*/
getQueuedMessages?: () => Promise<AgentMessage[]>;
getSteeringMessages?: () => Promise<AgentMessage[]>;
/**
* Controls when queued messages interrupt tool execution.
* Returns follow-up messages to process after the agent would otherwise stop.
*
* - "immediate" (default): Check queue after each tool, interrupt remaining tools if messages exist
* - "wait": Only process queued messages after the entire turn completes
* Called when the agent has no more tool calls and no steering messages.
* If messages are returned, they're added to the context and the agent
* continues with another turn.
*/
interruptMode?: "immediate" | "wait";
getFollowUpMessages?: () => Promise<AgentMessage[]>;
/**
* Provides tool execution context, resolved per tool call.
@@ -159,6 +168,8 @@ export interface RenderResultOptions {
expanded: boolean;
/** Whether this is a partial/streaming result */
isPartial: boolean;
/** Current spinner frame index for animated elements (optional) */
spinnerFrame?: number;
}
/**
+1 -1
View File
@@ -340,7 +340,7 @@ describe("agentLoop with AgentMessage", () => {
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
getQueuedMessages: async () => {
getSteeringMessages: async () => {
// Return queued message after first tool executes
if (executed.length === 1 && !queuedDelivered) {
queuedDelivered = true;
+2 -2
View File
@@ -93,11 +93,11 @@ describe("Agent", () => {
expect(agent.state.messages).toEqual([]);
});
it("should support message queueing", async () => {
it("should support steering message queueing", async () => {
const agent = new Agent();
const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() };
agent.queueMessage(message);
agent.steer(message);
// The message is queued but not yet in state.messages
expect(agent.state.messages).not.toContainEqual(message);
+3 -1
View File
@@ -4,6 +4,8 @@
### Added
- Added support for kwaipilot/kat-coder-pro model via OpenRouter
- Added OpenAI Codex responses provider with OAuth login support for ChatGPT Plus/Pro accounts
- Added Google Vertex AI provider (Gemini via Vertex) with Application Default Credentials support
### Changed
@@ -263,4 +265,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
## [0.9.4] - 2025-11-26
Initial release with multi-provider LLM support.
Initial release with multi-provider LLM support.
+14
View File
@@ -0,0 +1,14 @@
/**
* Type declarations for Bun's import attributes.
* These allow importing non-JS files as text at build time.
*/
declare module "*.md" {
const content: string;
export default content;
}
declare module "*.txt" {
const content: string;
export default content;
}
+16 -1
View File
@@ -6,6 +6,7 @@ import { loginGitHubCopilot } from "./utils/oauth/github-copilot";
import { loginAntigravity } from "./utils/oauth/google-antigravity";
import { loginGeminiCli } from "./utils/oauth/google-gemini-cli";
import { getOAuthProviders } from "./utils/oauth/index";
import { loginOpenAICodex } from "./utils/oauth/openai-codex";
import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types";
const AUTH_FILE = "auth.json";
@@ -89,6 +90,19 @@ async function login(provider: OAuthProvider): Promise<void> {
(msg) => console.log(msg),
);
break;
case "openai-codex":
credentials = await loginOpenAICodex({
onAuth: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.url}`);
if (info.instructions) console.log(info.instructions);
console.log();
},
onPrompt: async (p) => {
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
onProgress: (msg) => console.log(msg),
});
break;
}
const auth = await loadAuth();
@@ -114,6 +128,7 @@ Providers:
github-copilot GitHub Copilot
google-gemini-cli Google Gemini CLI
google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS)
openai-codex OpenAI Codex (ChatGPT Plus/Pro)
Examples:
npx @oh-my-pi/pi-ai login # interactive provider selection
@@ -141,7 +156,7 @@ Examples:
}
console.log();
const choice = await prompt("Enter number (1-4): ");
const choice = await prompt(`Enter number (1-${PROVIDERS.length}): `);
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= PROVIDERS.length) {
+2
View File
@@ -1,3 +1,5 @@
/// <reference path="./bun-imports.d.ts" />
export * from "./models";
export * from "./providers/anthropic";
export * from "./providers/google";
+6 -6
View File
@@ -3211,8 +3211,8 @@ export const MODELS = {
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 7168,
contextWindow: 163840,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"deepseek/deepseek-r1": {
id: "deepseek/deepseek-r1",
@@ -3240,13 +3240,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.39999999999999997,
output: 1.75,
input: 0.32,
output: 0.48,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 163840,
maxTokens: 65536,
contextWindow: 262144,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"deepseek/deepseek-r1-distill-llama-70b": {
id: "deepseek/deepseek-r1-distill-llama-70b",
+17 -10
View File
@@ -12,27 +12,34 @@ for (const [provider, models] of Object.entries(MODELS)) {
modelRegistry.set(provider, providerModels);
}
type ModelApi<
TProvider extends KnownProvider,
TModelId extends keyof (typeof MODELS)[TProvider],
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
type ProviderModels = typeof MODELS;
type ProviderWithModels = keyof ProviderModels;
export function getModel<TProvider extends KnownProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
type ModelApi<
TProvider extends ProviderWithModels,
TModelId extends keyof ProviderModels[TProvider],
> = ProviderModels[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
export function getModel<TProvider extends ProviderWithModels, TModelId extends keyof ProviderModels[TProvider]>(
provider: TProvider,
modelId: TModelId,
): Model<ModelApi<TProvider, TModelId>> {
return modelRegistry.get(provider)?.get(modelId as string) as Model<ModelApi<TProvider, TModelId>>;
): Model<ModelApi<TProvider, TModelId>>;
export function getModel(provider: KnownProvider, modelId: string): Model<Api> | undefined;
export function getModel(provider: KnownProvider, modelId: string): Model<Api> | undefined {
return modelRegistry.get(provider)?.get(modelId as string) as Model<Api> | undefined;
}
export function getProviders(): KnownProvider[] {
return Array.from(modelRegistry.keys()) as KnownProvider[];
}
export function getModels<TProvider extends KnownProvider>(
export function getModels<TProvider extends ProviderWithModels>(
provider: TProvider,
): Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
): Model<ModelApi<TProvider, keyof ProviderModels[TProvider]>>[];
export function getModels(provider: KnownProvider): Model<Api>[];
export function getModels(provider: KnownProvider): Model<Api>[] {
const models = modelRegistry.get(provider);
return models ? (Array.from(models.values()) as Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[]) : [];
return models ? (Array.from(models.values()) as Model<Api>[]) : [];
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
import { transformMessages } from "./transorm-messages";
type GoogleApiType = "google-generative-ai" | "google-gemini-cli";
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
/**
* Convert internal messages to Gemini Content[] format.
+355
View File
@@ -0,0 +1,355 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
Context,
Model,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
ToolCall,
} from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
import type { GoogleThinkingLevel } from "./google-gemini-cli";
import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared";
export interface GoogleVertexOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
project?: string;
location?: string;
}
const API_VERSION = "v1";
const THINKING_LEVEL_MAP: Record<GoogleThinkingLevel, ThinkingLevel> = {
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
MINIMAL: ThinkingLevel.MINIMAL,
LOW: ThinkingLevel.LOW,
MEDIUM: ThinkingLevel.MEDIUM,
HIGH: ThinkingLevel.HIGH,
};
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
model: Model<"google-vertex">,
context: Context,
options?: GoogleVertexOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-vertex" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
try {
const project = resolveProject(options);
const location = resolveLocation(options);
const client = createClient(model, project, location);
const params = buildParams(model, context, options);
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = part.thought === true;
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = part.thoughtSignature;
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: part.functionCall.args as Record<string, any>,
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some((b) => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
output.usage = {
input: chunk.usageMetadata.promptTokenCount || 0,
output:
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
cacheWrite: 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI {
const httpOptions: { headers?: Record<string, string> } = {};
if (model.headers) {
httpOptions.headers = { ...model.headers };
}
const hasHttpOptions = Object.values(httpOptions).some(Boolean);
return new GoogleGenAI({
vertexai: true,
project,
location,
apiVersion: API_VERSION,
httpOptions: hasHttpOptions ? httpOptions : undefined,
});
}
function resolveProject(options?: GoogleVertexOptions): string {
const project = options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
if (!project) {
throw new Error(
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
);
}
return project;
}
function resolveLocation(options?: GoogleVertexOptions): string {
const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION;
if (!location) {
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
}
return location;
}
function buildParams(
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions = {},
): GenerateContentParameters {
const contents = convertMessages(model, context);
const generationConfig: GenerateContentConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
@@ -0,0 +1,619 @@
import type {
ResponseFunctionToolCall,
ResponseInput,
ResponseInputContent,
ResponseInputImage,
ResponseInputText,
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses.js";
import { calculateCost } from "../models";
import { getEnvApiKey } from "../stream";
import type {
Api,
AssistantMessage,
Context,
Model,
StopReason,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
Tool,
ToolCall,
} from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { parseStreamingJson } from "../utils/json-parse";
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
import {
CODEX_BASE_URL,
JWT_CLAIM_PATH,
OPENAI_HEADER_VALUES,
OPENAI_HEADERS,
URL_PATHS,
} from "./openai-codex/constants";
import { getCodexInstructions } from "./openai-codex/prompts/codex";
import {
type CodexRequestOptions,
normalizeModel,
type RequestBody,
transformRequestBody,
} from "./openai-codex/request-transformer";
import { parseCodexError, parseCodexSseStream } from "./openai-codex/response-handler";
import { transformMessages } from "./transorm-messages";
export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null;
textVerbosity?: "low" | "medium" | "high";
include?: string[];
codexMode?: boolean;
}
const CODEX_DEBUG = process.env.PI_CODEX_DEBUG === "1" || process.env.PI_CODEX_DEBUG === "true";
export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = (
model: Model<"openai-codex-responses">,
context: Context,
options?: OpenAICodexResponsesOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "openai-codex-responses" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const accountId = getAccountId(apiKey);
const baseUrl = model.baseUrl || CODEX_BASE_URL;
const baseWithSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const url = rewriteUrlForCodex(new URL(URL_PATHS.RESPONSES.slice(1), baseWithSlash).toString());
const messages = convertMessages(model, context);
const params: RequestBody = {
model: model.id,
input: messages,
stream: true,
};
if (options?.maxTokens) {
params.max_output_tokens = options.maxTokens;
}
if (options?.temperature !== undefined) {
params.temperature = options.temperature;
}
if (context.tools) {
params.tools = convertTools(context.tools);
}
const normalizedModel = normalizeModel(params.model);
const codexInstructions = await getCodexInstructions(normalizedModel);
const codexOptions: CodexRequestOptions = {
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary ?? undefined,
textVerbosity: options?.textVerbosity,
include: options?.include,
};
const transformedBody = await transformRequestBody(
params,
codexInstructions,
codexOptions,
options?.codexMode ?? true,
);
const headers = createCodexHeaders(model.headers, accountId, apiKey, transformedBody.prompt_cache_key);
logCodexDebug("codex request", {
url,
model: params.model,
headers: redactHeaders(headers),
});
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal: options?.signal,
});
logCodexDebug("codex response", {
url: response.url,
status: response.status,
statusText: response.statusText,
contentType: response.headers.get("content-type") || null,
cfRay: response.headers.get("cf-ray") || null,
});
if (!response.ok) {
const info = await parseCodexError(response);
throw new Error(info.friendlyMessage || info.message);
}
if (!response.body) {
throw new Error("No response body");
}
stream.push({ type: "start", partial: output });
let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null;
let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const rawEvent of parseCodexSseStream(response)) {
const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
if (!eventType) continue;
if (eventType === "response.output_item.added") {
const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall;
if (item.type === "reasoning") {
currentItem = item;
currentBlock = { type: "thinking", thinking: "" };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else if (item.type === "message") {
currentItem = item;
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
} else if (item.type === "function_call") {
currentItem = item;
currentBlock = {
type: "toolCall",
id: `${item.call_id}|${item.id}`,
name: item.name,
arguments: {},
partialJson: item.arguments || "",
};
output.content.push(currentBlock);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
}
} else if (eventType === "response.reasoning_summary_part.added") {
if (currentItem && currentItem.type === "reasoning") {
currentItem.summary = currentItem.summary || [];
currentItem.summary.push((rawEvent as { part: ResponseReasoningItem["summary"][number] }).part);
}
} else if (eventType === "response.reasoning_summary_text.delta") {
if (currentItem && currentItem.type === "reasoning" && currentBlock?.type === "thinking") {
currentItem.summary = currentItem.summary || [];
const lastPart = currentItem.summary[currentItem.summary.length - 1];
if (lastPart) {
const delta = (rawEvent as { delta?: string }).delta || "";
currentBlock.thinking += delta;
lastPart.text += delta;
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta,
partial: output,
});
}
}
} else if (eventType === "response.reasoning_summary_part.done") {
if (currentItem && currentItem.type === "reasoning" && currentBlock?.type === "thinking") {
currentItem.summary = currentItem.summary || [];
const lastPart = currentItem.summary[currentItem.summary.length - 1];
if (lastPart) {
currentBlock.thinking += "\n\n";
lastPart.text += "\n\n";
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: "\n\n",
partial: output,
});
}
}
} else if (eventType === "response.content_part.added") {
if (currentItem && currentItem.type === "message") {
currentItem.content = currentItem.content || [];
const part = (rawEvent as { part?: ResponseOutputMessage["content"][number] }).part;
if (part && (part.type === "output_text" || part.type === "refusal")) {
currentItem.content.push(part);
}
}
} else if (eventType === "response.output_text.delta") {
if (currentItem && currentItem.type === "message" && currentBlock?.type === "text") {
const lastPart = currentItem.content[currentItem.content.length - 1];
if (lastPart && lastPart.type === "output_text") {
const delta = (rawEvent as { delta?: string }).delta || "";
currentBlock.text += delta;
lastPart.text += delta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta,
partial: output,
});
}
}
} else if (eventType === "response.refusal.delta") {
if (currentItem && currentItem.type === "message" && currentBlock?.type === "text") {
const lastPart = currentItem.content[currentItem.content.length - 1];
if (lastPart && lastPart.type === "refusal") {
const delta = (rawEvent as { delta?: string }).delta || "";
currentBlock.text += delta;
lastPart.refusal += delta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta,
partial: output,
});
}
}
} else if (eventType === "response.function_call_arguments.delta") {
if (currentItem && currentItem.type === "function_call" && currentBlock?.type === "toolCall") {
const delta = (rawEvent as { delta?: string }).delta || "";
currentBlock.partialJson += delta;
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta,
partial: output,
});
}
} else if (eventType === "response.output_item.done") {
const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall;
if (item.type === "reasoning" && currentBlock?.type === "thinking") {
currentBlock.thinking = item.summary?.map((s) => s.text).join("\n\n") || "";
currentBlock.thinkingSignature = JSON.stringify(item);
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
currentBlock = null;
} else if (item.type === "message" && currentBlock?.type === "text") {
currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("");
currentBlock.textSignature = item.id;
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
currentBlock = null;
} else if (item.type === "function_call") {
const toolCall: ToolCall = {
type: "toolCall",
id: `${item.call_id}|${item.id}`,
name: item.name,
arguments: JSON.parse(item.arguments),
};
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
} else if (eventType === "response.completed" || eventType === "response.done") {
const response = (
rawEvent as {
response?: {
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
input_tokens_details?: { cached_tokens?: number };
};
status?: string;
};
}
).response;
if (response?.usage) {
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
output.usage = {
input: (response.usage.input_tokens || 0) - cachedTokens,
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: 0,
totalTokens: response.usage.total_tokens || 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
calculateCost(model, output.usage);
output.stopReason = mapStopReason(response?.status);
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
output.stopReason = "toolUse";
}
} else if (eventType === "error") {
const code = (rawEvent as { code?: string }).code || "";
const message = (rawEvent as { message?: string }).message || "Unknown error";
throw new Error(code ? `Error Code ${code}: ${message}` : message);
} else if (eventType === "response.failed") {
throw new Error("Unknown error");
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) delete (block as { index?: number }).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
function createCodexHeaders(
initHeaders: Record<string, string> | undefined,
accountId: string,
accessToken: string,
promptCacheKey?: string,
): Headers {
const headers = new Headers(initHeaders ?? {});
headers.delete("x-api-key");
headers.set("Authorization", `Bearer ${accessToken}`);
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
if (promptCacheKey) {
headers.set(OPENAI_HEADERS.CONVERSATION_ID, promptCacheKey);
headers.set(OPENAI_HEADERS.SESSION_ID, promptCacheKey);
} else {
headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
headers.delete(OPENAI_HEADERS.SESSION_ID);
}
headers.set("accept", "text/event-stream");
headers.set("content-type", "application/json");
return headers;
}
function logCodexDebug(message: string, details?: Record<string, unknown>): void {
if (!CODEX_DEBUG) return;
if (details) {
console.error(`[codex] ${message}`, details);
return;
}
console.error(`[codex] ${message}`);
}
function redactHeaders(headers: Headers): Record<string, string> {
const redacted: Record<string, string> = {};
headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === "authorization") {
redacted[key] = "Bearer [redacted]";
return;
}
if (
lower.includes("account") ||
lower.includes("session") ||
lower.includes("conversation") ||
lower === "cookie"
) {
redacted[key] = "[redacted]";
return;
}
redacted[key] = value;
});
return redacted;
}
function rewriteUrlForCodex(url: string): string {
return url.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
}
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
};
[key: string]: unknown;
};
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1] ?? "";
const decoded = Buffer.from(payload, "base64").toString("utf-8");
return JSON.parse(decoded) as JwtPayload;
} catch {
return null;
}
}
function getAccountId(accessToken: string): string {
const payload = decodeJwt(accessToken);
const auth = payload?.[JWT_CLAIM_PATH];
const accountId = auth?.chatgpt_account_id;
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return accountId;
}
function shortHash(str: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
}
function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput {
const messages: ResponseInput = [];
const transformedMessages = transformMessages(context.messages, model);
let msgIndex = 0;
for (const msg of transformedMessages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
messages.push({
role: "user",
content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
});
} else {
const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => {
if (item.type === "text") {
return {
type: "input_text",
text: sanitizeSurrogates(item.text),
} satisfies ResponseInputText;
}
return {
type: "input_image",
detail: "auto",
image_url: `data:${item.mimeType};base64,${item.data}`,
} satisfies ResponseInputImage;
});
const filteredContent = !model.input.includes("image")
? content.filter((c) => c.type !== "input_image")
: content;
if (filteredContent.length === 0) continue;
messages.push({
role: "user",
content: filteredContent,
});
}
} else if (msg.role === "assistant") {
const output: ResponseInput = [];
for (const block of msg.content) {
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
const reasoningItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem;
output.push(reasoningItem);
}
} else if (block.type === "text") {
const textBlock = block as TextContent;
let msgId = textBlock.textSignature;
if (!msgId) {
msgId = `msg_${msgIndex}`;
} else if (msgId.length > 64) {
msgId = `msg_${shortHash(msgId)}`;
}
output.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }],
status: "completed",
id: msgId,
} satisfies ResponseOutputMessage);
} else if (block.type === "toolCall" && msg.stopReason !== "error") {
const toolCall = block as ToolCall;
output.push({
type: "function_call",
id: toolCall.id.split("|")[1],
call_id: toolCall.id.split("|")[0],
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments),
});
}
}
if (output.length === 0) continue;
messages.push(...output);
} else if (msg.role === "toolResult") {
const textResult = msg.content
.filter((c) => c.type === "text")
.map((c) => (c as { text: string }).text)
.join("\n");
const hasImages = msg.content.some((c) => c.type === "image");
const hasText = textResult.length > 0;
messages.push({
type: "function_call_output",
call_id: msg.toolCallId.split("|")[0],
output: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
});
if (hasImages && model.input.includes("image")) {
const contentParts: ResponseInputContent[] = [];
contentParts.push({
type: "input_text",
text: "Attached image(s) from tool result:",
} satisfies ResponseInputText);
for (const block of msg.content) {
if (block.type === "image") {
contentParts.push({
type: "input_image",
detail: "auto",
image_url: `data:${block.mimeType};base64,${block.data}`,
} satisfies ResponseInputImage);
}
}
messages.push({
role: "user",
content: contentParts,
});
}
}
msgIndex++;
}
return messages;
}
function convertTools(
tools: Tool[],
): Array<{ type: "function"; name: string; description: string; parameters: Record<string, unknown>; strict: null }> {
return tools.map((tool) => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.parameters as unknown as Record<string, unknown>,
strict: null,
}));
}
function mapStopReason(status: string | undefined): StopReason {
if (!status) return "stop";
switch (status) {
case "completed":
return "stop";
case "incomplete":
return "length";
case "failed":
case "cancelled":
return "error";
case "in_progress":
case "queued":
return "stop";
default:
return "stop";
}
}
@@ -0,0 +1,25 @@
/**
* Constants for OpenAI Codex (ChatGPT OAuth) backend
*/
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
export const OPENAI_HEADERS = {
BETA: "OpenAI-Beta",
ACCOUNT_ID: "chatgpt-account-id",
ORIGINATOR: "originator",
SESSION_ID: "session_id",
CONVERSATION_ID: "conversation_id",
} as const;
export const OPENAI_HEADER_VALUES = {
BETA_RESPONSES: "responses=experimental",
ORIGINATOR_CODEX: "codex_cli_rs",
} as const;
export const URL_PATHS = {
RESPONSES: "/responses",
CODEX_RESPONSES: "/codex/responses",
} as const;
export const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const;
@@ -0,0 +1,105 @@
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Codex CLI harness, sandboxing, and approvals
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
- **read-only**: The sandbox only permits reading files.
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
- **restricted**: Requires approval
- **enabled**: No approval needed
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
- (for all of these, you should weigh alternative paths that do not require approval)
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
When requesting approval to execute a command that will require escalated privileges:
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow final‑answer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
* Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
@@ -0,0 +1,217 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import FALLBACK_INSTRUCTIONS from "./codex-instructions.md" with { type: "text" };
const GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest";
const GITHUB_HTML_RELEASES = "https://github.com/openai/codex/releases/latest";
const DEFAULT_AGENT_DIR = join(homedir(), ".pi", "agent");
function getAgentDir(): string {
return process.env.PI_CODING_AGENT_DIR || DEFAULT_AGENT_DIR;
}
function getCacheDir(): string {
return join(getAgentDir(), "cache", "openai-codex");
}
export type ModelFamily = "gpt-5.2-codex" | "codex-max" | "codex" | "gpt-5.2" | "gpt-5.1";
const PROMPT_FILES: Record<ModelFamily, string> = {
"gpt-5.2-codex": "gpt-5.2-codex_prompt.md",
"codex-max": "gpt-5.1-codex-max_prompt.md",
codex: "gpt_5_codex_prompt.md",
"gpt-5.2": "gpt_5_2_prompt.md",
"gpt-5.1": "gpt_5_1_prompt.md",
};
const CACHE_FILES: Record<ModelFamily, string> = {
"gpt-5.2-codex": "gpt-5.2-codex-instructions.md",
"codex-max": "codex-max-instructions.md",
codex: "codex-instructions.md",
"gpt-5.2": "gpt-5.2-instructions.md",
"gpt-5.1": "gpt-5.1-instructions.md",
};
export type CacheMetadata = {
etag: string | null;
tag: string;
lastChecked: number;
url: string;
};
export function getModelFamily(normalizedModel: string): ModelFamily {
if (normalizedModel.includes("gpt-5.2-codex") || normalizedModel.includes("gpt 5.2 codex")) {
return "gpt-5.2-codex";
}
if (normalizedModel.includes("codex-max")) {
return "codex-max";
}
if (normalizedModel.includes("codex") || normalizedModel.startsWith("codex-")) {
return "codex";
}
if (normalizedModel.includes("gpt-5.2")) {
return "gpt-5.2";
}
return "gpt-5.1";
}
async function getLatestReleaseTag(): Promise<string> {
try {
const response = await fetch(GITHUB_API_RELEASES);
if (response.ok) {
const data = (await response.json()) as { tag_name?: string };
if (data.tag_name) {
return data.tag_name;
}
}
} catch {
// fallback
}
const htmlResponse = await fetch(GITHUB_HTML_RELEASES);
if (!htmlResponse.ok) {
throw new Error(`Failed to fetch latest release: ${htmlResponse.status}`);
}
const finalUrl = htmlResponse.url;
if (finalUrl) {
const parts = finalUrl.split("/tag/");
const last = parts[parts.length - 1];
if (last && !last.includes("/")) {
return last;
}
}
const html = await htmlResponse.text();
const match = html.match(/\/openai\/codex\/releases\/tag\/([^"]+)/);
if (match?.[1]) {
return match[1];
}
throw new Error("Failed to determine latest release tag from GitHub");
}
export async function getCodexInstructions(normalizedModel = "gpt-5.1-codex"): Promise<string> {
const modelFamily = getModelFamily(normalizedModel);
const promptFile = PROMPT_FILES[modelFamily];
const cacheDir = getCacheDir();
const cacheFile = join(cacheDir, CACHE_FILES[modelFamily]);
const cacheMetaFile = join(cacheDir, `${CACHE_FILES[modelFamily].replace(".md", "-meta.json")}`);
try {
let cachedETag: string | null = null;
let cachedTag: string | null = null;
let cachedTimestamp: number | null = null;
if (existsSync(cacheMetaFile)) {
const metadata = JSON.parse(readFileSync(cacheMetaFile, "utf-8")) as CacheMetadata;
cachedETag = metadata.etag;
cachedTag = metadata.tag;
cachedTimestamp = metadata.lastChecked;
}
const CACHE_TTL_MS = 15 * 60 * 1000;
if (cachedTimestamp && Date.now() - cachedTimestamp < CACHE_TTL_MS && existsSync(cacheFile)) {
return readFileSync(cacheFile, "utf-8");
}
const latestTag = await getLatestReleaseTag();
const instructionsUrl = `https://raw.githubusercontent.com/openai/codex/${latestTag}/codex-rs/core/${promptFile}`;
if (cachedTag !== latestTag) {
cachedETag = null;
}
const headers: Record<string, string> = {};
if (cachedETag) {
headers["If-None-Match"] = cachedETag;
}
const response = await fetch(instructionsUrl, { headers });
if (response.status === 304) {
if (existsSync(cacheFile)) {
return readFileSync(cacheFile, "utf-8");
}
}
if (response.ok) {
const instructions = await response.text();
const newETag = response.headers.get("etag");
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cacheFile, instructions, "utf-8");
writeFileSync(
cacheMetaFile,
JSON.stringify({
etag: newETag,
tag: latestTag,
lastChecked: Date.now(),
url: instructionsUrl,
} satisfies CacheMetadata),
"utf-8",
);
return instructions;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
console.error(
`[openai-codex] Failed to fetch ${modelFamily} instructions from GitHub:`,
error instanceof Error ? error.message : String(error),
);
if (existsSync(cacheFile)) {
console.error(`[openai-codex] Using cached ${modelFamily} instructions`);
return readFileSync(cacheFile, "utf-8");
}
console.error(`[openai-codex] Falling back to bundled instructions for ${modelFamily}`);
return FALLBACK_INSTRUCTIONS;
}
}
export const TOOL_REMAP_MESSAGE = `<user_instructions priority="0">
<environment_override priority="0">
YOU ARE IN A DIFFERENT ENVIRONMENT. These instructions override ALL previous tool references.
</environment_override>
<tool_replacements priority="0">
<critical_rule priority="0">
❌ APPLY_PATCH DOES NOT EXIST → ✅ USE "edit" INSTEAD
- NEVER use: apply_patch, applyPatch
- ALWAYS use: edit tool for ALL file modifications
</critical_rule>
<critical_rule priority="0">
❌ UPDATE_PLAN DOES NOT EXIST
- NEVER use: update_plan, updatePlan, read_plan, readPlan, todowrite, todoread
- There is no plan tool in this environment
</critical_rule>
</tool_replacements>
<available_tools priority="0">
File Operations:
• read - Read file contents
• edit - Modify files with exact find/replace
• write - Create or overwrite files
Search/Discovery:
• grep - Search file contents for patterns (read-only)
• find - Find files by glob pattern (read-only)
• ls - List directory contents (read-only)
Execution:
• bash - Run shell commands
</available_tools>
<verification_checklist priority="0">
Before file modifications:
1. Am I using "edit" NOT "apply_patch"?
2. Am I avoiding plan tools entirely?
3. Am I using only the tools listed above?
</verification_checklist>
</user_instructions>`;
@@ -0,0 +1,48 @@
/**
* Codex-Pi bridge prompt
* Aligns Codex CLI expectations with Pi's toolset.
*/
export const CODEX_PI_BRIDGE = `# Codex Running in Pi
You are running Codex through pi, a terminal coding assistant. The tools and rules differ from Codex CLI.
## CRITICAL: Tool Replacements
<critical_rule priority="0">
❌ APPLY_PATCH DOES NOT EXIST → ✅ USE "edit" INSTEAD
- NEVER use: apply_patch, applyPatch
- ALWAYS use: edit for ALL file modifications
</critical_rule>
<critical_rule priority="0">
❌ UPDATE_PLAN DOES NOT EXIST
- NEVER use: update_plan, updatePlan, read_plan, readPlan, todowrite, todoread
- There is no plan tool in this environment
</critical_rule>
## Available Tools (pi)
- read - Read file contents
- bash - Execute bash commands
- edit - Modify files with exact find/replace (requires prior read)
- write - Create or overwrite files
- grep - Search file contents (read-only)
- find - Find files by glob pattern (read-only)
- ls - List directory contents (read-only)
## Usage Rules
- Read before edit; use read instead of cat/sed for file contents
- Use edit for surgical changes; write only for new files or complete rewrites
- Prefer grep/find/ls over bash for discovery
- Be concise and show file paths clearly when working with files
## Verification Checklist
1. Using edit, not apply_patch
2. No plan tools used
3. Only the tools listed above are called
Below are additional system instruction you MUST follow when responding:
`;
@@ -0,0 +1,328 @@
import { TOOL_REMAP_MESSAGE } from "./prompts/codex";
import { CODEX_PI_BRIDGE } from "./prompts/pi-codex-bridge";
export interface ReasoningConfig {
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
summary: "auto" | "concise" | "detailed" | "off" | "on";
}
export interface CodexRequestOptions {
reasoningEffort?: ReasoningConfig["effort"];
reasoningSummary?: ReasoningConfig["summary"] | null;
textVerbosity?: "low" | "medium" | "high";
include?: string[];
}
export interface InputItem {
id?: string | null;
type?: string | null;
role?: string;
content?: unknown;
call_id?: string | null;
name?: string;
output?: unknown;
arguments?: string;
}
export interface RequestBody {
model: string;
store?: boolean;
stream?: boolean;
instructions?: string;
input?: InputItem[];
tools?: unknown;
temperature?: number;
reasoning?: Partial<ReasoningConfig>;
text?: {
verbosity?: "low" | "medium" | "high";
};
include?: string[];
prompt_cache_key?: string;
max_output_tokens?: number;
max_completion_tokens?: number;
[key: string]: unknown;
}
const MODEL_MAP: Record<string, string> = {
"gpt-5.1-codex": "gpt-5.1-codex",
"gpt-5.1-codex-low": "gpt-5.1-codex",
"gpt-5.1-codex-medium": "gpt-5.1-codex",
"gpt-5.1-codex-high": "gpt-5.1-codex",
"gpt-5.1-codex-max": "gpt-5.1-codex-max",
"gpt-5.1-codex-max-low": "gpt-5.1-codex-max",
"gpt-5.1-codex-max-medium": "gpt-5.1-codex-max",
"gpt-5.1-codex-max-high": "gpt-5.1-codex-max",
"gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max",
"gpt-5.2": "gpt-5.2",
"gpt-5.2-none": "gpt-5.2",
"gpt-5.2-low": "gpt-5.2",
"gpt-5.2-medium": "gpt-5.2",
"gpt-5.2-high": "gpt-5.2",
"gpt-5.2-xhigh": "gpt-5.2",
"gpt-5.2-codex": "gpt-5.2-codex",
"gpt-5.2-codex-low": "gpt-5.2-codex",
"gpt-5.2-codex-medium": "gpt-5.2-codex",
"gpt-5.2-codex-high": "gpt-5.2-codex",
"gpt-5.2-codex-xhigh": "gpt-5.2-codex",
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
"gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini",
"gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini",
"gpt-5.1": "gpt-5.1",
"gpt-5.1-none": "gpt-5.1",
"gpt-5.1-low": "gpt-5.1",
"gpt-5.1-medium": "gpt-5.1",
"gpt-5.1-high": "gpt-5.1",
"gpt-5.1-chat-latest": "gpt-5.1",
"gpt-5-codex": "gpt-5.1-codex",
"codex-mini-latest": "gpt-5.1-codex-mini",
"gpt-5-codex-mini": "gpt-5.1-codex-mini",
"gpt-5-codex-mini-medium": "gpt-5.1-codex-mini",
"gpt-5-codex-mini-high": "gpt-5.1-codex-mini",
"gpt-5": "gpt-5.1",
"gpt-5-mini": "gpt-5.1",
"gpt-5-nano": "gpt-5.1",
};
function getNormalizedModel(modelId: string): string | undefined {
if (MODEL_MAP[modelId]) return MODEL_MAP[modelId];
const lowerModelId = modelId.toLowerCase();
const match = Object.keys(MODEL_MAP).find((key) => key.toLowerCase() === lowerModelId);
return match ? MODEL_MAP[match] : undefined;
}
export function normalizeModel(model: string | undefined): string {
if (!model) return "gpt-5.1";
const modelId = model.includes("/") ? model.split("/").pop()! : model;
const mappedModel = getNormalizedModel(modelId);
if (mappedModel) return mappedModel;
const normalized = modelId.toLowerCase();
if (normalized.includes("gpt-5.2-codex") || normalized.includes("gpt 5.2 codex")) {
return "gpt-5.2-codex";
}
if (normalized.includes("gpt-5.2") || normalized.includes("gpt 5.2")) {
return "gpt-5.2";
}
if (normalized.includes("gpt-5.1-codex-max") || normalized.includes("gpt 5.1 codex max")) {
return "gpt-5.1-codex-max";
}
if (normalized.includes("gpt-5.1-codex-mini") || normalized.includes("gpt 5.1 codex mini")) {
return "gpt-5.1-codex-mini";
}
if (
normalized.includes("codex-mini-latest") ||
normalized.includes("gpt-5-codex-mini") ||
normalized.includes("gpt 5 codex mini")
) {
return "codex-mini-latest";
}
if (normalized.includes("gpt-5.1-codex") || normalized.includes("gpt 5.1 codex")) {
return "gpt-5.1-codex";
}
if (normalized.includes("gpt-5.1") || normalized.includes("gpt 5.1")) {
return "gpt-5.1";
}
if (normalized.includes("codex")) {
return "gpt-5.1-codex";
}
if (normalized.includes("gpt-5") || normalized.includes("gpt 5")) {
return "gpt-5.1";
}
return "gpt-5.1";
}
function getReasoningConfig(modelName: string | undefined, options: CodexRequestOptions = {}): ReasoningConfig {
const normalizedName = modelName?.toLowerCase() ?? "";
const isGpt52Codex = normalizedName.includes("gpt-5.2-codex") || normalizedName.includes("gpt 5.2 codex");
const isGpt52General = (normalizedName.includes("gpt-5.2") || normalizedName.includes("gpt 5.2")) && !isGpt52Codex;
const isCodexMax = normalizedName.includes("codex-max") || normalizedName.includes("codex max");
const isCodexMini =
normalizedName.includes("codex-mini") ||
normalizedName.includes("codex mini") ||
normalizedName.includes("codex_mini") ||
normalizedName.includes("codex-mini-latest");
const isCodex = normalizedName.includes("codex") && !isCodexMini;
const isLightweight = !isCodexMini && (normalizedName.includes("nano") || normalizedName.includes("mini"));
const isGpt51General =
(normalizedName.includes("gpt-5.1") || normalizedName.includes("gpt 5.1")) &&
!isCodex &&
!isCodexMax &&
!isCodexMini;
const supportsXhigh = isGpt52General || isGpt52Codex || isCodexMax;
const supportsNone = isGpt52General || isGpt51General;
const defaultEffort: ReasoningConfig["effort"] = isCodexMini
? "medium"
: supportsXhigh
? "high"
: isLightweight
? "minimal"
: "medium";
let effort = options.reasoningEffort || defaultEffort;
if (isCodexMini) {
if (effort === "minimal" || effort === "low" || effort === "none") {
effort = "medium";
}
if (effort === "xhigh") {
effort = "high";
}
if (effort !== "high" && effort !== "medium") {
effort = "medium";
}
}
if (!supportsXhigh && effort === "xhigh") {
effort = "high";
}
if (!supportsNone && effort === "none") {
effort = "low";
}
if (isCodex && effort === "minimal") {
effort = "low";
}
return {
effort,
summary: options.reasoningSummary ?? "auto",
};
}
function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
if (!Array.isArray(input)) return input;
return input
.filter((item) => item.type !== "item_reference")
.map((item) => {
if (item.id != null) {
const { id: _id, ...rest } = item;
return rest as InputItem;
}
return item;
});
}
function addCodexBridgeMessage(
input: InputItem[] | undefined,
hasTools: boolean,
systemPrompt?: string,
): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const bridgeText = systemPrompt ? `${CODEX_PI_BRIDGE}\n\n${systemPrompt}` : CODEX_PI_BRIDGE;
const bridgeMessage: InputItem = {
type: "message",
role: "developer",
content: [
{
type: "input_text",
text: bridgeText,
},
],
};
return [bridgeMessage, ...input];
}
function addToolRemapMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const toolRemapMessage: InputItem = {
type: "message",
role: "developer",
content: [
{
type: "input_text",
text: TOOL_REMAP_MESSAGE,
},
],
};
return [toolRemapMessage, ...input];
}
export async function transformRequestBody(
body: RequestBody,
codexInstructions: string,
options: CodexRequestOptions = {},
codexMode = true,
systemPrompt?: string,
): Promise<RequestBody> {
const normalizedModel = normalizeModel(body.model);
body.model = normalizedModel;
body.store = false;
body.stream = true;
body.instructions = codexInstructions;
if (body.input && Array.isArray(body.input)) {
body.input = filterInput(body.input);
if (codexMode) {
body.input = addCodexBridgeMessage(body.input, !!body.tools, systemPrompt);
} else {
body.input = addToolRemapMessage(body.input, !!body.tools);
}
if (body.input) {
const functionCallIds = new Set(
body.input
.filter((item) => item.type === "function_call" && typeof item.call_id === "string")
.map((item) => item.call_id as string),
);
body.input = body.input.map((item) => {
if (item.type === "function_call_output" && typeof item.call_id === "string") {
const callId = item.call_id as string;
if (!functionCallIds.has(callId)) {
const itemRecord = item as unknown as Record<string, unknown>;
const toolName = typeof itemRecord.name === "string" ? itemRecord.name : "tool";
let text = "";
try {
const output = itemRecord.output;
text = typeof output === "string" ? output : JSON.stringify(output);
} catch {
text = String(itemRecord.output ?? "");
}
if (text.length > 16000) {
text = `${text.slice(0, 16000)}\n...[truncated]`;
}
return {
type: "message",
role: "assistant",
content: `[Previous ${toolName} result; call_id=${callId}]: ${text}`,
} as InputItem;
}
}
return item;
});
}
}
const reasoningConfig = getReasoningConfig(normalizedModel, options);
body.reasoning = {
...body.reasoning,
...reasoningConfig,
};
body.text = {
...body.text,
verbosity: options.textVerbosity || "medium",
};
body.include = options.include || ["reasoning.encrypted_content"];
delete body.max_output_tokens;
delete body.max_completion_tokens;
return body;
}
@@ -0,0 +1,133 @@
export type CodexRateLimit = {
used_percent?: number;
window_minutes?: number;
resets_at?: number;
};
export type CodexRateLimits = {
primary?: CodexRateLimit;
secondary?: CodexRateLimit;
};
export type CodexErrorInfo = {
message: string;
status: number;
friendlyMessage?: string;
rateLimits?: CodexRateLimits;
raw?: string;
};
export async function parseCodexError(response: Response): Promise<CodexErrorInfo> {
const raw = await response.text();
let message = raw || response.statusText || "Request failed";
let friendlyMessage: string | undefined;
let rateLimits: CodexRateLimits | undefined;
try {
const parsed = JSON.parse(raw) as { error?: Record<string, unknown> };
const err = parsed?.error ?? {};
const headers = response.headers;
const primary = {
used_percent: toNumber(headers.get("x-codex-primary-used-percent")),
window_minutes: toInt(headers.get("x-codex-primary-window-minutes")),
resets_at: toInt(headers.get("x-codex-primary-reset-at")),
};
const secondary = {
used_percent: toNumber(headers.get("x-codex-secondary-used-percent")),
window_minutes: toInt(headers.get("x-codex-secondary-window-minutes")),
resets_at: toInt(headers.get("x-codex-secondary-reset-at")),
};
rateLimits =
primary.used_percent !== undefined || secondary.used_percent !== undefined
? { primary, secondary }
: undefined;
const code = String((err as { code?: string; type?: string }).code ?? (err as { type?: string }).type ?? "");
const resetsAt = (err as { resets_at?: number }).resets_at ?? primary.resets_at ?? secondary.resets_at;
const mins = resetsAt ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) : undefined;
if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) {
const planType = (err as { plan_type?: string }).plan_type;
const plan = planType ? ` (${String(planType).toLowerCase()} plan)` : "";
const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${when}`.trim();
}
const errMessage = (err as { message?: string }).message;
message = errMessage || friendlyMessage || message;
} catch {
// raw body not JSON
}
return {
message,
status: response.status,
friendlyMessage,
rateLimits,
raw: raw,
};
}
export async function* parseCodexSseStream(response: Response): AsyncGenerator<Record<string, unknown>> {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let index = buffer.indexOf("\n\n");
while (index !== -1) {
const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const event = parseSseChunk(chunk);
if (event) yield event;
index = buffer.indexOf("\n\n");
}
}
if (buffer.trim()) {
const event = parseSseChunk(buffer);
if (event) yield event;
}
}
function parseSseChunk(chunk: string): Record<string, unknown> | null {
const lines = chunk.split("\n");
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trim());
}
}
if (dataLines.length === 0) return null;
const data = dataLines.join("\n").trim();
if (!data || data === "[DONE]") return null;
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
function toNumber(v: string | null): number | undefined {
if (v == null) return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function toInt(v: string | null): number | undefined {
if (v == null) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
+117 -8
View File
@@ -1,3 +1,6 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { supportsXhigh } from "./models";
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
import { type GoogleOptions, streamGoogle } from "./providers/google";
@@ -6,6 +9,8 @@ import {
type GoogleThinkingLevel,
streamGoogleGeminiCli,
} from "./providers/google-gemini-cli";
import { type GoogleVertexOptions, streamGoogleVertex } from "./providers/google-vertex";
import { type OpenAICodexResponsesOptions, streamOpenAICodexResponses } from "./providers/openai-codex-responses";
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions";
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses";
import type {
@@ -20,6 +25,17 @@ import type {
SimpleStreamOptions,
} from "./types";
const VERTEX_ADC_CREDENTIALS_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(): boolean {
if (cachedVertexAdcCredentialsExists === null) {
cachedVertexAdcCredentialsExists = existsSync(VERTEX_ADC_CREDENTIALS_PATH);
}
return cachedVertexAdcCredentialsExists;
}
/**
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
*
@@ -38,6 +54,19 @@ export function getEnvApiKey(provider: any): string | undefined {
return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
}
// Vertex AI uses Application Default Credentials, not API keys.
// Auth is configured via `gcloud auth application-default login`.
if (provider === "google-vertex") {
const hasCredentials = hasVertexAdcCredentials();
const hasProject = !!(process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT);
const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION;
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
}
return undefined;
}
const envMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
google: "GEMINI_API_KEY",
@@ -58,6 +87,11 @@ export function stream<TApi extends Api>(
context: Context,
options?: OptionsForApi<TApi>,
): AssistantMessageEventStream {
// Vertex AI uses Application Default Credentials, not API keys
if (model.api === "google-vertex") {
return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions);
}
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
@@ -75,6 +109,9 @@ export function stream<TApi extends Api>(
case "openai-responses":
return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any);
case "openai-codex-responses":
return streamOpenAICodexResponses(model as Model<"openai-codex-responses">, context, providerOptions as any);
case "google-generative-ai":
return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
@@ -107,6 +144,12 @@ export function streamSimple<TApi extends Api>(
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
// Vertex AI uses Application Default Credentials, not API keys
if (model.api === "google-vertex") {
const providerOptions = mapOptionsForApi(model, options, undefined);
return stream(model, context, providerOptions);
}
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
@@ -147,6 +190,8 @@ function mapOptionsForApi<TApi extends Api>(
return { ...base, thinkingEnabled: false } satisfies AnthropicOptions;
}
// Claude requires max_tokens > thinking.budget_tokens
// So we need to ensure maxTokens accounts for both thinking and output
const anthropicBudgets = {
minimal: 1024,
low: 2048,
@@ -154,10 +199,21 @@ function mapOptionsForApi<TApi extends Api>(
high: 16384,
};
const minOutputTokens = 1024;
let thinkingBudget = anthropicBudgets[clampReasoning(options.reasoning)!];
// Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
// If not enough room for thinking + output, reduce thinking budget
if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
}
return {
...base,
maxTokens,
thinkingEnabled: true,
thinkingBudgetTokens: anthropicBudgets[clampReasoning(options.reasoning)!],
thinkingBudgetTokens: thinkingBudget,
} satisfies AnthropicOptions;
}
@@ -173,6 +229,12 @@ function mapOptionsForApi<TApi extends Api>(
reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
} satisfies OpenAIResponsesOptions;
case "openai-codex-responses":
return {
...base,
reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning),
} satisfies OpenAICodexResponsesOptions;
case "google-generative-ai": {
// Explicitly disable thinking when reasoning is not specified
// This is needed because Gemini has "dynamic thinking" enabled by default
@@ -222,7 +284,9 @@ function mapOptionsForApi<TApi extends Api>(
} satisfies GoogleGeminiCliOptions;
}
// Gemini 2.x models use thinkingBudget
// Models using thinkingBudget (Gemini 2.x, Claude via Antigravity)
// Claude requires max_tokens > thinking.budget_tokens
// So we need to ensure maxTokens accounts for both thinking and output
const budgets: Record<ClampedReasoningEffort, number> = {
minimal: 1024,
low: 2048,
@@ -230,13 +294,55 @@ function mapOptionsForApi<TApi extends Api>(
high: 16384,
};
const minOutputTokens = 1024;
let thinkingBudget = budgets[effort];
// Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
// If not enough room for thinking + output, reduce thinking budget
if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
}
return {
...base,
maxTokens,
thinking: {
enabled: true,
budgetTokens: thinkingBudget,
},
} satisfies GoogleGeminiCliOptions;
}
case "google-vertex": {
// Explicitly disable thinking when reasoning is not specified
// This is needed because Gemini has "dynamic thinking" enabled by default
if (!options?.reasoning) {
return { ...base, thinking: { enabled: false } } satisfies GoogleVertexOptions;
}
const googleModel = model as Model<"google-vertex">;
const effort = clampReasoning(options.reasoning)!;
// Gemini 3 models use thinkingLevel exclusively instead of thinkingBudget.
// https://ai.google.dev/gemini-api/docs/thinking#set-budget
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel)) {
return {
...base,
thinking: {
enabled: true,
level: getGemini3ThinkingLevel(effort, googleModel),
},
} satisfies GoogleVertexOptions;
}
return {
...base,
thinking: {
enabled: true,
budgetTokens: budgets[effort],
budgetTokens: getGoogleBudget(googleModel, effort),
},
} satisfies GoogleGeminiCliOptions;
} satisfies GoogleVertexOptions;
}
default: {
@@ -249,19 +355,19 @@ function mapOptionsForApi<TApi extends Api>(
type ClampedReasoningEffort = Exclude<ReasoningEffort, "xhigh">;
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
function isGemini3ProModel(model: Model<"google-generative-ai"> | Model<"google-vertex">): boolean {
// Covers gemini-3-pro, gemini-3-pro-preview, and possible other prefixed ids in the future
return model.id.includes("3-pro");
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
function isGemini3FlashModel(model: Model<"google-generative-ai"> | Model<"google-vertex">): boolean {
// Covers gemini-3-flash, gemini-3-flash-preview, and possible other prefixed ids in the future
return model.id.includes("3-flash");
}
function getGemini3ThinkingLevel(
effort: ClampedReasoningEffort,
model: Model<"google-generative-ai">,
model: Model<"google-generative-ai"> | Model<"google-vertex">,
): GoogleThinkingLevel {
if (isGemini3ProModel(model)) {
// Gemini 3 Pro only supports LOW/HIGH (for now)
@@ -312,7 +418,10 @@ function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: stri
}
}
function getGoogleBudget(model: Model<"google-generative-ai">, effort: ClampedReasoningEffort): number {
function getGoogleBudget(
model: Model<"google-generative-ai"> | Model<"google-vertex">,
effort: ClampedReasoningEffort,
): number {
// See https://ai.google.dev/gemini-api/docs/thinking#set-budget
if (model.id.includes("2.5-pro")) {
const budgets: Record<ClampedReasoningEffort, number> = {
+9 -1
View File
@@ -1,6 +1,8 @@
import type { AnthropicOptions } from "./providers/anthropic";
import type { GoogleOptions } from "./providers/google";
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
import type { GoogleVertexOptions } from "./providers/google-vertex";
import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
import type { OpenAICompletionsOptions } from "./providers/openai-completions";
import type { OpenAIResponsesOptions } from "./providers/openai-responses";
import type { AssistantMessageEventStream } from "./utils/event-stream";
@@ -10,16 +12,20 @@ export type { AssistantMessageEventStream } from "./utils/event-stream";
export type Api =
| "openai-completions"
| "openai-responses"
| "openai-codex-responses"
| "anthropic-messages"
| "google-generative-ai"
| "google-gemini-cli";
| "google-gemini-cli"
| "google-vertex";
export interface ApiOptionsMap {
"anthropic-messages": AnthropicOptions;
"openai-completions": OpenAICompletionsOptions;
"openai-responses": OpenAIResponsesOptions;
"openai-codex-responses": OpenAICodexResponsesOptions;
"google-generative-ai": GoogleOptions;
"google-gemini-cli": GoogleGeminiCliOptions;
"google-vertex": GoogleVertexOptions;
}
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
@@ -39,7 +45,9 @@ export type KnownProvider =
| "google"
| "google-gemini-cli"
| "google-antigravity"
| "google-vertex"
| "openai"
| "openai-codex"
| "github-copilot"
| "xai"
| "groq"
+14
View File
@@ -28,6 +28,11 @@ export {
loginGeminiCli,
refreshGoogleCloudToken,
} from "./google-gemini-cli";
// OpenAI Codex (ChatGPT OAuth)
export {
loginOpenAICodex,
refreshOpenAICodexToken,
} from "./openai-codex";
export * from "./types";
@@ -39,6 +44,7 @@ import { refreshAnthropicToken } from "./anthropic";
import { refreshGitHubCopilotToken } from "./github-copilot";
import { refreshAntigravityToken } from "./google-antigravity";
import { refreshGoogleCloudToken } from "./google-gemini-cli";
import { refreshOpenAICodexToken } from "./openai-codex";
import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types";
/**
@@ -74,6 +80,9 @@ export async function refreshOAuthToken(
}
newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
break;
case "openai-codex":
newCredentials = await refreshOpenAICodexToken(credentials.refresh);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
@@ -139,5 +148,10 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
available: true,
},
{
id: "openai-codex",
name: "ChatGPT Plus/Pro (Codex Subscription)",
available: true,
},
];
}
+334
View File
@@ -0,0 +1,334 @@
/**
* OpenAI Codex (ChatGPT OAuth) flow
*/
import { generatePKCE } from "./pkce";
import type { OAuthCredentials, OAuthPrompt } from "./types";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const REDIRECT_URI = "http://localhost:1455/auth/callback";
const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
const SUCCESS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
</head>
<body>
<p>Authentication successful. Return to your terminal to continue.</p>
</body>
</html>`;
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed" };
type TokenResult = TokenSuccess | TokenFailure;
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
};
[key: string]: unknown;
};
function createState(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) return {};
try {
const url = new URL(value);
return {
code: url.searchParams.get("code") ?? undefined,
state: url.searchParams.get("state") ?? undefined,
};
} catch {
// not a URL
}
if (value.includes("#")) {
const [code, state] = value.split("#", 2);
return { code, state };
}
if (value.includes("code=")) {
const params = new URLSearchParams(value);
return {
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
};
}
return { code: value };
}
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1] ?? "";
const decoded = Buffer.from(payload, "base64").toString("utf-8");
return JSON.parse(decoded) as JwtPayload;
} catch {
return null;
}
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResult> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] code->token failed:", response.status, text);
return { type: "failed" };
}
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
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: "success",
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
}
async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
try {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] Token refresh failed:", response.status, text);
return { type: "failed" };
}
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
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: "success",
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
} catch (error) {
console.error("[openai-codex] Token refresh error:", error);
return { type: "failed" };
}
}
async function createAuthorizationFlow(): Promise<{ verifier: string; state: string; url: string }> {
const { verifier, challenge } = await generatePKCE();
const state = createState();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPE);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
url.searchParams.set("originator", "codex_cli_rs");
return { verifier, state, url: url.toString() };
}
type OAuthServerInfo = {
close: () => void;
waitForCode: () => Promise<{ code: string } | null>;
};
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
let lastCode: string | null = null;
return new Promise((resolve) => {
try {
const server = Bun.serve({
port: 1455,
hostname: "127.0.0.1",
fetch(req) {
try {
const url = new URL(req.url);
if (url.pathname !== "/auth/callback") {
return new Response("Not found", { status: 404 });
}
if (url.searchParams.get("state") !== state) {
return new Response("State mismatch", { status: 400 });
}
const code = url.searchParams.get("code");
if (!code) {
return new Response("Missing authorization code", { status: 400 });
}
lastCode = code;
return new Response(SUCCESS_HTML, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
} catch {
return new Response("Internal error", { status: 500 });
}
},
});
resolve({
close: () => server.stop(),
waitForCode: async () => {
const sleep = () => new Promise((r) => setTimeout(r, 100));
for (let i = 0; i < 600; i += 1) {
if (lastCode) return { code: lastCode };
await sleep();
}
return null;
},
});
} catch (err) {
const code = (err as { code?: string }).code;
console.error(
"[openai-codex] Failed to bind http://127.0.0.1:1455 (",
code,
") Falling back to manual paste.",
);
resolve({
close: () => {},
waitForCode: async () => null,
});
}
});
}
function getAccountId(accessToken: string): string | null {
const payload = decodeJwt(accessToken);
const auth = payload?.[JWT_CLAIM_PATH];
const accountId = auth?.chatgpt_account_id;
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}
/**
* Login with OpenAI Codex OAuth
*/
export async function loginOpenAICodex(options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
}): Promise<OAuthCredentials> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
let code: string | undefined;
try {
const result = await server.waitForCode();
if (result?.code) {
code = result.code;
}
if (!code) {
const input = await options.onPrompt({
message: "Paste the authorization code (or full redirect URL):",
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== state) {
throw new Error("State mismatch");
}
code = parsed.code;
}
if (!code) {
throw new Error("Missing authorization code");
}
const tokenResult = await exchangeAuthorizationCode(code, verifier);
if (tokenResult.type !== "success") {
throw new Error("Token exchange failed");
}
const accountId = getAccountId(tokenResult.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: tokenResult.access,
refresh: tokenResult.refresh,
expires: tokenResult.expires,
accountId,
};
} finally {
server.close();
}
}
/**
* Refresh OpenAI Codex OAuth token
*/
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");
}
const accountId = getAccountId(result.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: result.access,
refresh: result.refresh,
expires: result.expires,
accountId,
};
}
+7 -1
View File
@@ -5,9 +5,15 @@ export type OAuthCredentials = {
enterpriseUrl?: string;
projectId?: string;
email?: string;
accountId?: string;
};
export type OAuthProvider = "anthropic" | "github-copilot" | "google-gemini-cli" | "google-antigravity";
export type OAuthProvider =
| "anthropic"
| "github-copilot"
| "google-gemini-cli"
| "google-antigravity"
| "openai-codex";
export type OAuthPrompt = {
message: string;
@@ -0,0 +1,132 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { streamOpenAICodexResponses } from "../src/providers/openai-codex-responses";
import type { Context, Model } from "../src/types";
const originalFetch = global.fetch;
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
afterEach(() => {
global.fetch = originalFetch;
if (originalAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
}
vi.restoreAllMocks();
});
describe("openai-codex streaming", () => {
it("streams SSE responses into AssistantMessageEventStream", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
const payload = Buffer.from(
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
"utf8",
).toString("base64");
const token = `aaa.${payload}.bbb`;
const sse = `${[
`data: ${JSON.stringify({
type: "response.output_item.added",
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
})}`,
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
`data: ${JSON.stringify({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
},
})}`,
`data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
input_tokens_details: { cached_tokens: 0 },
},
},
})}`,
].join("\n\n")}\n\n`;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sse));
controller.close();
},
});
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
}
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
}
if (url === "https://chatgpt.com/backend-api/codex/responses") {
const headers = init?.headers instanceof Headers ? init.headers : undefined;
expect(headers?.get("Authorization")).toBe(`Bearer ${token}`);
expect(headers?.get("chatgpt-account-id")).toBe("acc_test");
expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental");
expect(headers?.get("originator")).toBe("codex_cli_rs");
expect(headers?.get("accept")).toBe("text/event-stream");
expect(headers?.has("x-api-key")).toBe(false);
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
return new Response("not found", { status: 404 });
});
global.fetch = fetchMock as unknown as typeof fetch;
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token });
let sawTextDelta = false;
let sawDone = false;
for await (const event of streamResult) {
if (event.type === "text_delta") {
sawTextDelta = true;
}
if (event.type === "done") {
sawDone = true;
expect(event.message.content.find((c) => c.type === "text")?.text).toBe("Hello");
}
}
expect(sawTextDelta).toBe(true);
expect(sawDone).toBe(true);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCodexInstructions } from "../src/providers/openai-codex/prompts/codex";
import FALLBACK_PROMPT from "../src/providers/openai-codex/prompts/codex-instructions.md" with { type: "text" };
import { CODEX_PI_BRIDGE } from "../src/providers/openai-codex/prompts/pi-codex-bridge";
import {
normalizeModel,
type RequestBody,
transformRequestBody,
} from "../src/providers/openai-codex/request-transformer";
import { parseCodexError } from "../src/providers/openai-codex/response-handler";
const DEFAULT_PROMPT_PREFIX =
"You are an expert coding assistant. You help users with coding tasks by reading files, executing commands";
describe("openai-codex request transformer", () => {
it("filters item_reference, strips ids, and inserts bridge message", async () => {
const body: RequestBody = {
model: "gpt-5.1-codex",
input: [
{
type: "message",
role: "developer",
id: "sys-1",
content: [{ type: "input_text", text: `${DEFAULT_PROMPT_PREFIX}...` }],
},
{
type: "message",
role: "user",
id: "user-1",
content: [{ type: "input_text", text: "hello" }],
},
{ type: "item_reference", id: "ref-1" },
{ type: "function_call_output", call_id: "missing", name: "tool", output: "result" },
],
tools: [{ type: "function", name: "tool", description: "", parameters: {} }],
};
const transformed = await transformRequestBody(body, "CODEX_INSTRUCTIONS", {}, true);
expect(transformed.store).toBe(false);
expect(transformed.stream).toBe(true);
expect(transformed.instructions).toBe("CODEX_INSTRUCTIONS");
expect(transformed.include).toEqual(["reasoning.encrypted_content"]);
const input = transformed.input || [];
expect(input.some((item) => item.type === "item_reference")).toBe(false);
expect(input.some((item) => "id" in item)).toBe(false);
expect(input[0]?.type).toBe("message");
expect(input[0]?.content).toEqual([{ type: "input_text", text: CODEX_PI_BRIDGE }]);
const orphaned = input.find((item) => item.type === "message" && item.role === "assistant");
expect(orphaned?.content).toMatch(/Previous tool result/);
});
});
describe("openai-codex model normalization", () => {
it("maps space-separated codex-mini names to codex-mini-latest", () => {
expect(normalizeModel("gpt 5 codex mini")).toBe("codex-mini-latest");
});
});
describe("openai-codex error parsing", () => {
it("produces friendly usage-limit messages and rate limits", async () => {
const resetAt = Math.floor(Date.now() / 1000) + 600;
const response = new Response(
JSON.stringify({
error: { code: "usage_limit_reached", plan_type: "Plus", resets_at: resetAt },
}),
{
status: 429,
headers: {
"x-codex-primary-used-percent": "99",
"x-codex-primary-window-minutes": "60",
"x-codex-primary-reset-at": String(resetAt),
},
},
);
const info = await parseCodexError(response);
expect(info.friendlyMessage?.toLowerCase()).toContain("usage limit");
expect(info.rateLimits?.primary?.used_percent).toBe(99);
});
});
describe("openai-codex prompt caching", () => {
const originalFetch = global.fetch;
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
if (originalAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
}
});
it("caches prompts with etag and reuses cache", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
const tag = "rust-v0.0.0";
const promptText = "PROMPT_CONTENT";
const etag = '"etag-123"';
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
return new Response(JSON.stringify({ tag_name: tag }), { status: 200 });
}
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
const headerValue =
init?.headers && typeof init.headers === "object" && "If-None-Match" in init.headers
? String((init.headers as Record<string, string>)["If-None-Match"])
: undefined;
if (headerValue === etag) {
return new Response("", { status: 304, headers: { etag } });
}
return new Response(promptText, { status: 200, headers: { etag } });
}
return new Response("not found", { status: 404 });
});
global.fetch = fetchMock as unknown as typeof fetch;
const first = await getCodexInstructions("gpt-5.1-codex");
expect(first).toBe(promptText);
const metaPath = join(tempDir, "cache", "openai-codex", "codex-instructions-meta.json");
const meta = JSON.parse(readFileSync(metaPath, "utf-8")) as { etag: string; tag: string; lastChecked: number };
writeFileSync(metaPath, JSON.stringify({ ...meta, lastChecked: 0 }), "utf-8");
const second = await getCodexInstructions("gpt-5.1-codex");
expect(second).toBe(promptText);
expect(fetchMock).toHaveBeenCalled();
const rawCalls = fetchMock.mock.calls.filter((call) =>
String(call[0]).startsWith("https://raw.githubusercontent.com/openai/codex/"),
);
expect(rawCalls.length).toBeGreaterThan(0);
});
it("falls back to bundled instructions when cache and network are unavailable", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-"));
process.env.PI_CODING_AGENT_DIR = tempDir;
const fetchMock = vi.fn(async () => {
throw new Error("network down");
});
global.fetch = fetchMock as unknown as typeof fetch;
const instructions = await getCodexInstructions("gpt-5.1-codex");
expect(instructions).toBe(FALLBACK_PROMPT);
});
});
+3 -3
View File
@@ -4,6 +4,6 @@
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}
"include": ["src/**/*.ts", "src/bun-imports.d.ts"],
"exclude": ["node_modules", "dist"]
}
+6 -1
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Added
- Added extensions API with auto-discovery (`.omp/extensions`) and `--extension`/`-e` loading for custom tools, commands, and lifecycle hooks
- Added prompt templates loaded from global and project `.omp/prompts` directories with `/template` expansion in the input box
- Built-in provider overrides in `models.json`: override just `baseUrl` to route a built-in provider through a proxy while keeping all its models, or define `models` to fully replace the provider
- Shell commands without context contribution: use `!!command` to execute a bash command that is shown in the TUI and saved to session history but excluded from LLM context. Useful for running commands you don't want the AI to see
- Added VoiceSupervisor class for realtime voice mode using OpenAI Realtime API with continuous mic streaming and semantic VAD turn detection
- Added VoiceController class for steering user input and deciding presentation of assistant responses
- Added echo suppression and noise floor filtering for microphone input during voice playback
@@ -42,6 +46,7 @@
### Fixed
- `/model` selector now opens instantly instead of waiting for OAuth token refresh. Token refresh is deferred until a model is actually used
- Fixed cross-platform browser opening to work on Windows (via cmd /c start) and fail gracefully when unavailable
## [3.15.1] - 2026-01-05
@@ -1678,4 +1683,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
File diff suppressed because it is too large Load Diff
+69 -13
View File
@@ -54,22 +54,38 @@ Response:
```
The `images` field is optional. Each image uses `ImageContent` format with base64 or URL source.
When prompting during streaming, set `"streamingBehavior": "steer"` or `"followUp"` to queue the message.
#### queue_message
#### steer
Queue a message to be injected at the next agent turn. Queued messages are added to the conversation without triggering a new prompt. Useful for injecting context mid-conversation.
Queue a steering message to interrupt the agent mid-run. Useful for injecting corrections while streaming.
```json
{ "type": "queue_message", "message": "Additional context" }
{ "type": "steer", "message": "Additional context" }
```
Response:
```json
{ "type": "response", "command": "queue_message", "success": true }
{ "type": "response", "command": "steer", "success": true }
```
See [set_queue_mode](#set_queue_mode) for controlling how queued messages are processed.
#### follow_up
Queue a follow-up message to be processed after the current run completes.
```json
{ "type": "follow_up", "message": "Additional context" }
```
Response:
```json
{ "type": "response", "command": "follow_up", "success": true }
```
See [set_steering_mode](#set_steering_mode), [set_follow_up_mode](#set_follow_up_mode), and
[set_interrupt_mode](#set_interrupt_mode) for controlling queued message handling.
#### abort
@@ -133,7 +149,9 @@ Response:
"thinkingLevel": "medium",
"isStreaming": false,
"isCompacting": false,
"queueMode": "all",
"steeringMode": "all",
"followUpMode": "one-at-a-time",
"interruptMode": "immediate",
"sessionFile": "/path/to/session.jsonl",
"sessionId": "abc123",
"autoCompactionEnabled": true,
@@ -272,25 +290,63 @@ Response:
}
```
### Queue Mode
### Queue Modes
#### set_queue_mode
#### set_steering_mode
Control how queued messages (from `queue_message`) are injected into the conversation.
Control how steering messages are injected into the conversation.
```json
{ "type": "set_queue_mode", "mode": "one-at-a-time" }
{ "type": "set_steering_mode", "mode": "one-at-a-time" }
```
Modes:
- `"all"`: Inject all queued messages at the next turn
- `"one-at-a-time"`: Inject one queued message per turn (default)
- `"all"`: Inject all steering messages at the next turn
- `"one-at-a-time"`: Inject one steering message per turn (default)
Response:
```json
{ "type": "response", "command": "set_queue_mode", "success": true }
{ "type": "response", "command": "set_steering_mode", "success": true }
```
#### set_follow_up_mode
Control how follow-up messages are injected into the conversation.
```json
{ "type": "set_follow_up_mode", "mode": "one-at-a-time" }
```
Modes:
- `"all"`: Inject all follow-up messages at the next turn
- `"one-at-a-time"`: Inject one follow-up message per turn (default)
Response:
```json
{ "type": "response", "command": "set_follow_up_mode", "success": true }
```
#### set_interrupt_mode
Control how the agent handles incoming steering messages while streaming.
```json
{ "type": "set_interrupt_mode", "mode": "wait" }
```
Modes:
- `"immediate"`: Interrupt immediately when steering arrives
- `"wait"`: Wait to apply steering until current tool call completes
Response:
```json
{ "type": "response", "command": "set_interrupt_mode", "success": true }
```
### Compaction
@@ -150,7 +150,7 @@ Implementation:
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
- `message_end` handler routes based on marker presence
- `AgentSession.sendHookMessage()` handles three cases:
- Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end`
- Streaming: queues via `agent.steer()` or `agent.followUp()`, loop processes and emits `message_end`
- Not streaming + triggerTurn: direct append + `agent.continue()`
- Not streaming + no trigger: direct append only
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
@@ -0,0 +1,141 @@
# Extension Examples
Example extensions for pi-coding-agent.
## Usage
```bash
# Load an extension with --extension flag
pi --extension examples/extensions/permission-gate.ts
# Or copy to extensions directory for auto-discovery
cp permission-gate.ts ~/.omp/agent/extensions/
```
## Examples
### Lifecycle & Safety
| Extension | Description |
|-----------|-------------|
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
### Custom Tools
| Extension | Description |
|-----------|-------------|
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
| `hello.ts` | Minimal custom tool example |
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions |
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
### Commands & UI
| Extension | Description |
|-----------|-------------|
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
### Git Integration
| Extension | Description |
|-----------|-------------|
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
### System Prompt & Compaction
| Extension | Description |
|-----------|-------------|
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
### External Dependencies
| Extension | Description |
|-----------|-------------|
| `chalk-logger.ts` | Uses chalk from parent node_modules (demonstrates jiti module resolution) |
| `with-deps/` | Extension with its own package.json and dependencies |
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
## Writing Extensions
See [docs/extensions.md](../../docs/extensions.md) for full documentation.
```typescript
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function (pi: ExtensionAPI) {
// Subscribe to lifecycle events
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
// Register custom tools
pi.registerTool({
name: "greet",
label: "Greeting",
description: "Generate a greeting",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params, onUpdate, ctx, signal) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
// Register commands
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify("Hello!", "info");
},
});
}
```
## Key Patterns
**Use StringEnum for string parameters** (required for Google API compatibility):
```typescript
import { StringEnum } from "@oh-my-pi/pi-ai";
// Good
action: StringEnum(["list", "add"] as const)
// Bad - doesn't work with Google
action: Type.Union([Type.Literal("list"), Type.Literal("add")])
```
**State persistence via details:**
```typescript
// Store state in tool result details for proper branching support
return {
content: [{ type: "text", text: "Done" }],
details: { todos: [...todos], nextId }, // Persisted in session
};
// Reconstruct on session events
pi.on("session_start", async (_event, ctx) => {
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.toolName === "my_tool") {
const details = entry.message.details;
// Reconstruct state from details
}
}
});
```
@@ -0,0 +1,87 @@
/**
* API Demo Extension
*
* Demonstrates using ExtensionAPI's logger, typebox, and pi module access.
* These features are now exposed directly on the ExtensionAPI, matching
* the CustomToolAPI interface.
*/
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// 1. Access TypeBox directly from pi.typebox (no separate import needed)
const { Type } = pi.typebox;
// 2. Access the logger for debugging
pi.logger.debug("API demo extension loaded");
// 3. Register a tool that uses all three API features
pi.registerTool({
name: "api_demo",
label: "API Demo",
description: "Demonstrates ExtensionAPI capabilities: logger, typebox, and pi module access",
parameters: Type.Object({
message: Type.String({ description: "Test message" }),
logLevel: Type.Optional(
Type.Union([Type.Literal("error"), Type.Literal("warn"), Type.Literal("debug")], {
description: "Log level to use",
default: "debug",
}),
),
}),
async execute(_toolCallId, params, _onUpdate, ctx, _signal) {
const { message, logLevel = "debug" } = params as { message: string; logLevel?: "error" | "warn" | "debug" };
// Use logger at specified level
pi.logger[logLevel]("API demo tool executed", { message, logLevel });
// Access pi module utilities
const { logger: piLogger } = pi.pi;
piLogger.debug("Accessed pi module from extension", { sessionFile: ctx.sessionManager.getSessionFile() });
// Get session information
const sessionInfo = `Session: ${ctx.sessionManager.getSessionFile()}`;
const modelInfo = ctx.model ? `Model: ${ctx.model.id}` : "Model: none";
return {
content: [
{
type: "text",
text: [
`API Demo Tool executed successfully!`,
``,
`Message: ${message}`,
`Log Level: ${logLevel}`,
``,
`Features demonstrated:`,
`1. ✓ Logger access via pi.logger`,
`2. ✓ TypeBox access via pi.typebox`,
`3. ✓ Pi module access via pi.pi`,
``,
`Context:`,
`- ${sessionInfo}`,
`- ${modelInfo}`,
`- CWD: ${ctx.cwd}`,
].join("\n"),
},
],
details: {
message,
logLevel,
sessionFile: ctx.sessionManager.getSessionFile(),
modelId: ctx.model?.id,
},
};
},
});
// Demonstrate event handling with logger
pi.on("session_start", async () => {
pi.logger.debug("Session started", { extension: "api-demo" });
});
pi.on("agent_start", async () => {
pi.logger.debug("Agent started", { extension: "api-demo" });
});
}
@@ -0,0 +1,26 @@
/**
* Example extension that uses a 3rd party dependency (chalk).
* Tests that jiti can resolve npm modules correctly.
*/
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
import chalk from "chalk";
export default function (pi: ExtensionAPI) {
// Log with colors using chalk
console.log(`${chalk.green("✓")} ${chalk.bold("chalk-logger extension loaded")}`);
pi.on("agent_start", async () => {
console.log(`${chalk.blue("[chalk-logger]")} Agent starting`);
});
pi.on("tool_call", async (event) => {
console.log(`${chalk.yellow("[chalk-logger]")} Tool: ${chalk.cyan(event.toolName)}`);
return undefined;
});
pi.on("agent_end", async (event) => {
const count = event.messages.length;
console.log(`${chalk.green("[chalk-logger]")} Done with ${chalk.bold(String(count))} messages`);
});
}
@@ -0,0 +1,33 @@
/**
* Hello Tool - Minimal custom tool example
*
* Demonstrates using ExtensionAPI's logger, typebox, and pi module access.
*/
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Access TypeBox via pi.typebox (no need to import separately)
const { Type } = pi.typebox;
pi.registerTool({
name: "hello",
label: "Hello",
description: "A simple greeting tool",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
const { name } = params as { name: string };
// Use logger for debugging
pi.logger.debug("Hello tool executed", { name });
return {
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});
}
@@ -0,0 +1,44 @@
/**
* Pirate Extension
*
* Demonstrates using systemPromptAppend in before_agent_start to dynamically
* modify the system prompt based on extension state.
*
* Usage:
* 1. Copy this file to ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) or your project's .omp/extensions/
* 2. Use /pirate to toggle pirate mode
* 3. When enabled, the agent will respond like a pirate
*/
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function pirateExtension(pi: ExtensionAPI) {
let pirateMode = false;
// Register /pirate command to toggle pirate mode
pi.registerCommand("pirate", {
description: "Toggle pirate mode (agent speaks like a pirate)",
handler: async (_args, ctx) => {
pirateMode = !pirateMode;
ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info");
},
});
// Append to system prompt when pirate mode is enabled
pi.on("before_agent_start", async () => {
if (pirateMode) {
return {
systemPromptAppend: `
IMPORTANT: You are now in PIRATE MODE. You must:
- Speak like a stereotypical pirate in all responses
- Use phrases like "Arrr!", "Ahoy!", "Shiver me timbers!", "Avast!", "Ye scurvy dog!"
- Replace "my" with "me", "you" with "ye", "your" with "yer"
- Refer to the user as "matey" or "landlubber"
- End sentences with nautical expressions
- Still complete the actual task correctly, just in pirate speak
`,
};
}
return undefined;
});
}
@@ -0,0 +1,551 @@
/**
* Plan Mode Extension
*
* Provides a Claude Code-style "plan mode" for safe code exploration.
* When enabled, the agent can only use read-only tools and cannot modify files.
*
* Features:
* - /plan command to toggle plan mode
* - In plan mode: only read, bash (read-only), grep, find, ls are available
* - Injects system context telling the agent about the restrictions
* - After each agent response, prompts to execute the plan or continue planning
* - Shows "plan" indicator in footer when active
* - Extracts todo list from plan and tracks progress during execution
* - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
*
* Usage:
* 1. Copy this file to ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) or your project's .omp/extensions/
* 2. Use /plan to toggle plan mode on/off
* 3. Or start in plan mode with --plan flag
*/
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
import { Key } from "@oh-my-pi/pi-tui";
// Read-only tools for plan mode
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
// Full set of tools for normal mode
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
// Patterns for destructive bash commands that should be blocked in plan mode
const DESTRUCTIVE_PATTERNS = [
/\brm\b/i,
/\brmdir\b/i,
/\bmv\b/i,
/\bcp\b/i,
/\bmkdir\b/i,
/\btouch\b/i,
/\bchmod\b/i,
/\bchown\b/i,
/\bchgrp\b/i,
/\bln\b/i,
/\btee\b/i,
/\btruncate\b/i,
/\bdd\b/i,
/\bshred\b/i,
/[^<]>(?!>)/,
/>>/,
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
/\byarn\s+(add|remove|install|publish)/i,
/\bpnpm\s+(add|remove|install|publish)/i,
/\bpip\s+(install|uninstall)/i,
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
/\bbrew\s+(install|uninstall|upgrade)/i,
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
/\bsudo\b/i,
/\bsu\b/i,
/\bkill\b/i,
/\bpkill\b/i,
/\bkillall\b/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
/\bservice\s+\S+\s+(start|stop|restart)/i,
/\b(vim?|nano|emacs|code|subl)\b/i,
];
// Read-only commands that are always safe
const SAFE_COMMANDS = [
/^\s*cat\b/,
/^\s*head\b/,
/^\s*tail\b/,
/^\s*less\b/,
/^\s*more\b/,
/^\s*grep\b/,
/^\s*find\b/,
/^\s*ls\b/,
/^\s*pwd\b/,
/^\s*echo\b/,
/^\s*printf\b/,
/^\s*wc\b/,
/^\s*sort\b/,
/^\s*uniq\b/,
/^\s*diff\b/,
/^\s*file\b/,
/^\s*stat\b/,
/^\s*du\b/,
/^\s*df\b/,
/^\s*tree\b/,
/^\s*which\b/,
/^\s*whereis\b/,
/^\s*type\b/,
/^\s*env\b/,
/^\s*printenv\b/,
/^\s*uname\b/,
/^\s*whoami\b/,
/^\s*id\b/,
/^\s*date\b/,
/^\s*cal\b/,
/^\s*uptime\b/,
/^\s*ps\b/,
/^\s*top\b/,
/^\s*htop\b/,
/^\s*free\b/,
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
/^\s*git\s+ls-/i,
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
/^\s*yarn\s+(list|info|why|audit)/i,
/^\s*node\s+--version/i,
/^\s*python\s+--version/i,
/^\s*curl\s/i,
/^\s*wget\s+-O\s*-/i,
/^\s*jq\b/,
/^\s*sed\s+-n/i,
/^\s*awk\b/,
/^\s*rg\b/,
/^\s*fd\b/,
/^\s*bat\b/,
/^\s*exa\b/,
];
function isSafeCommand(command: string): boolean {
if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) {
if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
return true;
}
}
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
return false;
}
return true;
}
// Todo item with step number
interface TodoItem {
step: number;
text: string;
completed: boolean;
}
/**
* Clean up extracted step text for display.
*/
function cleanStepText(text: string): string {
let cleaned = text
// Remove markdown bold/italic
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
// Remove markdown code
.replace(/`([^`]+)`/g, "$1")
// Remove leading action words that are redundant
.replace(
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
"",
)
// Clean up extra whitespace
.replace(/\s+/g, " ")
.trim();
// Capitalize first letter
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
// Truncate if too long
if (cleaned.length > 50) {
cleaned = `${cleaned.slice(0, 47)}...`;
}
return cleaned;
}
/**
* Extract todo items from assistant message.
*/
function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = [];
// Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
for (const match of message.matchAll(numberedPattern)) {
let text = match[2].trim();
text = text.replace(/\*{1,2}$/, "").trim();
// Skip if too short or looks like code/command
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
// If no numbered items, try bullet points
if (items.length === 0) {
const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim;
for (const match of message.matchAll(stepPattern)) {
let text = match[1].trim();
text = text.replace(/\*{1,2}$/, "").trim();
if (text.length > 10 && !text.startsWith("`")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
}
return items;
}
export default function planModeExtension(pi: ExtensionAPI) {
let planModeEnabled = false;
let toolsCalledThisTurn = false;
let executionMode = false;
let todoItems: TodoItem[] = [];
// Register --plan CLI flag
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
type: "boolean",
default: false,
});
// Helper to update status displays
function updateStatus(ctx: ExtensionContext) {
if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((t) => t.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
} else if (planModeEnabled) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
// Show widget during execution (no IDs shown to user)
if (executionMode && todoItems.length > 0) {
const lines: string[] = [];
for (const item of todoItems) {
if (item.completed) {
lines.push(ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("dim", item.text));
} else {
lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text);
}
}
ctx.ui.setWidget("plan-todos", lines);
} else {
ctx.ui.setWidget("plan-todos", undefined);
}
}
function togglePlanMode(ctx: ExtensionContext) {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
pi.setActiveTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
updateStatus(ctx);
}
// Register /plan command
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => {
togglePlanMode(ctx);
},
});
// Register /todos command
pi.registerCommand("todos", {
description: "Show current plan todo list",
handler: async (_args, ctx) => {
if (todoItems.length === 0) {
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
return;
}
const todoList = todoItems
.map((item, i) => {
const checkbox = item.completed ? "✓" : "○";
return `${i + 1}. ${checkbox} ${item.text}`;
})
.join("\n");
ctx.ui.notify(`Plan Progress:\n${todoList}`, "info");
},
});
// Register Shift+P shortcut
pi.registerShortcut(Key.shift("p"), {
description: "Toggle plan mode",
handler: async (ctx) => {
togglePlanMode(ctx);
},
});
// Block destructive bash in plan mode
pi.on("tool_call", async (event) => {
if (!planModeEnabled) return;
if (event.toolName !== "bash") return;
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`,
};
}
});
// Track step completion based on tool results
pi.on("tool_result", async (_event, ctx) => {
toolsCalledThisTurn = true;
if (!executionMode || todoItems.length === 0) return;
// Mark the first uncompleted step as done when any tool succeeds
const nextStep = todoItems.find((t) => !t.completed);
if (nextStep) {
nextStep.completed = true;
updateStatus(ctx);
}
});
// Filter out stale plan mode context messages from LLM context
// This ensures the agent only sees the CURRENT state (plan mode on/off)
pi.on("context", async (event) => {
// Only filter when NOT in plan mode (i.e., when executing)
if (planModeEnabled) {
return;
}
// Remove any previous plan-mode-context messages
const _beforeCount = event.messages.length;
const filtered = event.messages.filter((m) => {
if (m.role === "user" && Array.isArray(m.content)) {
const hasOldContext = m.content.some(
(c: { type: string; text?: string }) => c.type === "text" && c.text?.includes("[PLAN MODE ACTIVE]"),
);
if (hasOldContext) {
return false;
}
}
return true;
});
return { messages: filtered };
});
// Inject plan mode context
pi.on("before_agent_start", async () => {
if (!planModeEnabled && !executionMode) {
return;
}
if (planModeEnabled) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- You can only use: read, bash, grep, find, ls
- You CANNOT use: edit, write (file modifications are disabled)
- Bash is restricted to READ-ONLY commands
- Focus on analysis, planning, and understanding the codebase
Create a detailed numbered plan:
1. First step description
2. Second step description
...
Do NOT attempt to make changes - just describe what you would do.`,
display: false,
},
};
}
if (executionMode && todoItems.length > 0) {
const remaining = todoItems.filter((t) => !t.completed);
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING PLAN - Full tool access enabled]
Remaining steps:
${todoList}
Execute each step in order.`,
display: false,
},
};
}
});
// After agent finishes
pi.on("agent_end", async (event, ctx) => {
// In execution mode, check if all steps complete
if (executionMode && todoItems.length > 0) {
const allComplete = todoItems.every((t) => t.completed);
if (allComplete) {
// Show final completed list in chat
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
pi.sendMessage(
{
customType: "plan-complete",
content: `**Plan Complete!** ✓\n\n${completedList}`,
display: true,
},
{ triggerTurn: false },
);
executionMode = false;
todoItems = [];
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
}
return;
}
if (!planModeEnabled) return;
if (!ctx.hasUI) return;
// Extract todos from last message
const messages = event.messages;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
if (lastAssistant && Array.isArray(lastAssistant.content)) {
const textContent = lastAssistant.content
.filter(
(block: { type: string; text?: string }): block is { type: "text"; text: string } =>
block.type === "text",
)
.map((block: { type: "text"; text: string }) => block.text)
.join("\n");
if (textContent) {
const extracted = extractTodoItems(textContent);
if (extracted.length > 0) {
todoItems = extracted;
}
}
}
const hasTodos = todoItems.length > 0;
// Show todo list in chat (no IDs shown to user, just numbered)
if (hasTodos) {
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
pi.sendMessage(
{
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
},
{ triggerTurn: false },
);
}
const choice = await ctx.ui.select("Plan mode - what next?", [
hasTodos ? "Execute the plan (track progress)" : "Execute the plan",
"Stay in plan mode",
"Refine the plan",
]);
if (choice?.startsWith("Execute")) {
planModeEnabled = false;
executionMode = hasTodos;
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
// Simple execution message - context event filters old plan mode messages
// and before_agent_start injects fresh execution context with IDs
const execMessage = hasTodos
? `Execute the plan. Start with: ${todoItems[0].text}`
: "Execute the plan you just created.";
pi.sendMessage(
{
customType: "plan-mode-execute",
content: execMessage,
display: true,
},
{ triggerTurn: true },
);
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.input("What should be refined?");
if (refinement) {
ctx.ui.setEditorText(refinement);
}
}
});
// Initialize state on session start
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("plan") === true) {
planModeEnabled = true;
}
const entries = ctx.sessionManager.getEntries();
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
if (planModeEntry?.data) {
if (planModeEntry.data.enabled !== undefined) {
planModeEnabled = planModeEntry.data.enabled;
}
if (planModeEntry.data.todos) {
todoItems = planModeEntry.data.todos;
}
if (planModeEntry.data.executing) {
executionMode = planModeEntry.data.executing;
}
}
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
}
updateStatus(ctx);
});
// Reset tool tracking at start of each turn and persist state
pi.on("turn_start", async () => {
toolsCalledThisTurn = false;
pi.appendEntry("plan-mode", {
enabled: planModeEnabled,
todos: todoItems,
executing: executionMode,
});
});
// Handle non-tool turns (e.g., analysis, explanation steps)
pi.on("turn_end", async (_event, ctx) => {
if (!executionMode || todoItems.length === 0) return;
// If no tools were called this turn, the agent was doing analysis/explanation
// Mark the next uncompleted step as done
if (!toolsCalledThisTurn) {
const nextStep = todoItems.find((t) => !t.completed);
if (nextStep) {
nextStep.completed = true;
updateStatus(ctx);
}
}
});
}
@@ -0,0 +1,35 @@
---
name: reviewer
description: Code review specialist for quality and security analysis
tools: read, grep, find, ls, bash
model: claude-sonnet-4-5
---
You are a senior code reviewer. Analyze code for quality, security, and maintainability.
Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds.
Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only.
Strategy:
1. Run `git diff` to see recent changes (if applicable)
2. Read the modified files
3. Check for bugs, security issues, code smells
Output format:
## Files Reviewed
- `path/to/file.ts` (lines X-Y)
## Critical (must fix)
- `file.ts:42` - Issue description
## Warnings (should fix)
- `file.ts:100` - Issue description
## Suggestions (consider)
- `file.ts:150` - Improvement idea
## Summary
Overall assessment in 2-3 sentences.
Be specific with file paths and line numbers.
@@ -0,0 +1,299 @@
/**
* Todo Extension - Demonstrates state management via session entries
*
* This extension:
* - Registers a `todo` tool for the LLM to manage todos
* - Registers a `/todos` command for users to view the list
*
* State is stored in tool result details (not external files), which allows
* proper branching - when you branch, the todo state is automatically
* correct for that point in history.
*/
import { StringEnum } from "@oh-my-pi/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@oh-my-pi/pi-coding-agent";
import { matchesKey, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodoDetails {
action: "list" | "add" | "toggle" | "clear";
todos: Todo[];
nextId: number;
error?: string;
}
const TodoParams = Type.Object({
action: StringEnum(["list", "add", "toggle", "clear"] as const),
text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
});
/**
* UI component for the /todos command
*/
class TodoListComponent {
private todos: Todo[];
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
this.todos = todos;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines: string[] = [];
const th = this.theme;
lines.push("");
const title = th.fg("accent", " Todos ");
const headerLine =
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
lines.push(truncateToWidth(headerLine, width));
lines.push("");
if (this.todos.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
} else {
const done = this.todos.filter((t) => t.done).length;
const total = this.todos.length;
lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
lines.push("");
for (const todo of this.todos) {
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
const id = th.fg("accent", `#${todo.id}`);
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
}
}
lines.push("");
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
export default function (pi: ExtensionAPI) {
// In-memory state (reconstructed from session on load)
let todos: Todo[] = [];
let nextId = 1;
/**
* Reconstruct state from session entries.
* Scans tool results for this tool and applies them in order.
*/
const reconstructState = (ctx: ExtensionContext) => {
todos = [];
nextId = 1;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = (entry as { message?: { role?: string; toolName?: string; details?: unknown } }).message;
if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
const details = msg.details as TodoDetails | undefined;
if (details) {
todos = details.todos;
nextId = details.nextId;
}
}
};
// Reconstruct state on session events
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_branch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// Register the todo tool for the LLM
pi.registerTool({
name: "todo",
label: "Todo",
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
parameters: TodoParams,
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
switch (params.action) {
case "list":
return {
content: [
{
type: "text",
text: todos.length
? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
: "No todos",
},
],
details: { action: "list", todos: [...todos], nextId } as TodoDetails,
};
case "add": {
if (!params.text) {
return {
content: [{ type: "text", text: "Error: text required for add" }],
details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
};
}
const newTodo: Todo = { id: nextId++, text: params.text, done: false };
todos.push(newTodo);
return {
content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
details: { action: "add", todos: [...todos], nextId } as TodoDetails,
};
}
case "toggle": {
if (params.id === undefined) {
return {
content: [{ type: "text", text: "Error: id required for toggle" }],
details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
};
}
const todo = todos.find((t) => t.id === params.id);
if (!todo) {
return {
content: [{ type: "text", text: `Todo #${params.id} not found` }],
details: {
action: "toggle",
todos: [...todos],
nextId,
error: `#${params.id} not found`,
} as TodoDetails,
};
}
todo.done = !todo.done;
return {
content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
};
}
case "clear": {
const count = todos.length;
todos = [];
nextId = 1;
return {
content: [{ type: "text", text: `Cleared ${count} todos` }],
details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: {
action: "list",
todos: [...todos],
nextId,
error: `unknown action: ${params.action}`,
} as TodoDetails,
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as TodoDetails | undefined;
if (!details) {
const text = result.content[0] as { type: string; text?: string } | undefined;
return new Text(text?.type === "text" && text.text ? text.text : "", 0, 0);
}
if (details.error) {
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
}
const todoList = details.todos;
switch (details.action) {
case "list": {
if (todoList.length === 0) {
return new Text(theme.fg("dim", "No todos"), 0, 0);
}
let listText = theme.fg("muted", `${todoList.length} todo(s):`);
const display = expanded ? todoList : todoList.slice(0, 5);
for (const t of display) {
const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
}
if (!expanded && todoList.length > 5) {
listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
}
return new Text(listText, 0, 0);
}
case "add": {
const added = todoList[todoList.length - 1];
return new Text(
theme.fg("success", "✓ Added ") +
theme.fg("accent", `#${added.id}`) +
" " +
theme.fg("muted", added.text),
0,
0,
);
}
case "toggle": {
const text = result.content[0] as { type: string; text?: string } | undefined;
const msg = text?.type === "text" && text.text ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "clear":
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
}
},
});
// Register the /todos command for users
pi.registerCommand("todos", {
description: "Show all todos on the current branch",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/todos requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}
@@ -0,0 +1,145 @@
/**
* Tools Extension
*
* Provides a /tools command to enable/disable tools interactively.
* Tool selection persists across session reloads and respects branch navigation.
*
* Usage:
* 1. Copy this file to ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) or your project's .omp/extensions/
* 2. Use /tools to open the tool selector
*/
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
import { getSettingsListTheme } from "@oh-my-pi/pi-coding-agent";
import { Container, type SettingItem, SettingsList } from "@oh-my-pi/pi-tui";
// State persisted to session
interface ToolsState {
enabledTools: string[];
}
export default function toolsExtension(pi: ExtensionAPI) {
// Track enabled tools
let enabledTools: Set<string> = new Set();
let allTools: string[] = [];
// Persist current state
function persistState() {
pi.appendEntry<ToolsState>("tools-config", {
enabledTools: Array.from(enabledTools),
});
}
// Apply current tool selection
function applyTools() {
pi.setActiveTools(Array.from(enabledTools));
}
// Find the last tools-config entry in the current branch
function restoreFromBranch(ctx: ExtensionContext) {
allTools = pi.getAllTools();
// Get entries in current branch only
const branchEntries = ctx.sessionManager.getBranch();
let savedTools: string[] | undefined;
for (const entry of branchEntries) {
if (entry.type === "custom" && (entry as { customType?: string }).customType === "tools-config") {
const data = (entry as { data?: ToolsState }).data;
if (data?.enabledTools) {
savedTools = data.enabledTools;
}
}
}
if (savedTools) {
// Restore saved tool selection (filter to only tools that still exist)
enabledTools = new Set(savedTools.filter((t: string) => allTools.includes(t)));
applyTools();
} else {
// No saved state - sync with currently active tools
enabledTools = new Set(pi.getActiveTools());
}
}
// Register /tools command
pi.registerCommand("tools", {
description: "Enable/disable tools",
handler: async (_args, ctx) => {
// Refresh tool list
allTools = pi.getAllTools();
await ctx.ui.custom((tui, theme, done) => {
// Build settings items for each tool
const items: SettingItem[] = allTools.map((tool) => ({
id: tool,
label: tool,
currentValue: enabledTools.has(tool) ? "enabled" : "disabled",
values: ["enabled", "disabled"],
}));
const container = new Container();
container.addChild(
new (class {
render(_width: number) {
return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
}
invalidate() {}
})(),
);
const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
getSettingsListTheme(),
(id, newValue) => {
// Update enabled state and apply immediately
if (newValue === "enabled") {
enabledTools.add(id);
} else {
enabledTools.delete(id);
}
applyTools();
persistState();
},
() => {
// Close dialog
done(undefined);
},
);
container.addChild(settingsList);
const component = {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
settingsList.handleInput?.(data);
tui.requestRender();
},
};
return component;
});
},
});
// Restore state on session start
pi.on("session_start", async (_event, ctx) => {
restoreFromBranch(ctx);
});
// Restore state when navigating the session tree
pi.on("session_tree", async (_event, ctx) => {
restoreFromBranch(ctx);
});
// Restore state after branching
pi.on("session_branch", async (_event, ctx) => {
restoreFromBranch(ctx);
});
}
@@ -0,0 +1 @@
node_modules/
@@ -0,0 +1,36 @@
/**
* Example extension with its own npm dependencies.
* Tests that jiti resolves modules from the extension's own node_modules.
*
* Requires: npm install in this directory
*/
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import ms from "ms";
export default function (pi: ExtensionAPI) {
// Register a tool that uses ms
pi.registerTool({
name: "parse_duration",
label: "Parse Duration",
description: "Parse a human-readable duration string (e.g., '2 days', '1h', '5m') to milliseconds",
parameters: Type.Object({
duration: Type.String({ description: "Duration string like '2 days', '1h', '5m'" }),
}),
execute: async (_toolCallId, params) => {
const result = ms(params.duration as ms.StringValue);
if (result === undefined) {
return {
content: [{ type: "text", text: `Invalid duration: "${params.duration}"` }],
isError: true,
details: {},
};
}
return {
content: [{ type: "text", text: `${params.duration} = ${result} milliseconds` }],
details: {},
};
},
});
}
@@ -0,0 +1,31 @@
{
"name": "pi-extension-with-deps",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "1.0.0",
"dependencies": {
"ms": "^2.1.3"
},
"devDependencies": {
"@types/ms": "^2.1.0"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
}
}
}
@@ -0,0 +1,16 @@
{
"name": "pi-extension-with-deps",
"version": "1.0.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"ms": "^2.1.3"
},
"devDependencies": {
"@types/ms": "^2.1.0"
}
}
@@ -8,8 +8,8 @@ import { getModel } from "@oh-my-pi/pi-ai";
import { createAgentSession, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
// Option 1: Find a specific built-in model by provider/id
const opus = getModel("anthropic", "claude-opus-4-5");
@@ -24,7 +24,7 @@ if (customModel) {
}
// Option 3: Pick from available models (have valid API keys)
const available = await modelRegistry.getAvailable();
const available = modelRegistry.getAvailable();
console.log(
"Available models:",
available.map((m) => `${m.provider}/${m.id}`),
@@ -39,17 +39,21 @@ console.log("Custom tools session created");
// With custom cwd - MUST use factory functions!
const customCwd = "/path/to/project";
const customTools = await createCodingTools(customCwd);
await createAgentSession({
cwd: customCwd,
tools: createCodingTools(customCwd), // Tools resolve paths relative to customCwd
tools: customTools, // Tools resolve paths relative to customCwd
sessionManager: SessionManager.inMemory(),
});
console.log("Custom cwd session created");
// Or pick specific tools for custom cwd
const customReadTool = await createReadTool(customCwd);
const customBashTool = await createBashTool(customCwd);
const customGrepTool = await createGrepTool(customCwd);
await createAgentSession({
cwd: customCwd,
tools: [createReadTool(customCwd), createBashTool(customCwd), createGrepTool(customCwd)],
tools: [customReadTool, customBashTool, customGrepTool],
sessionManager: SessionManager.inMemory(),
});
console.log("Specific tools with custom cwd session created");
@@ -69,7 +73,7 @@ const weatherTool: CustomTool = {
};
const { session } = await createAgentSession({
customTools: [{ tool: weatherTool }],
customTools: [weatherTool],
sessionManager: SessionManager.inMemory(),
});
@@ -0,0 +1,81 @@
/**
* Extensions Configuration
*
* Extensions intercept agent events and can register custom tools.
* They provide a unified system for extensions, custom tools, commands, and more.
*
* Extension files are discovered from:
* - ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/)
* - <cwd>/.omp/extensions/ (legacy: <cwd>/.pi/extensions/)
* - Paths specified in settings.json "extensions" array
* - Paths passed via --extension CLI flag
*
* An extension is a TypeScript file that exports a default function:
* export default function (pi: ExtensionAPI) { ... }
*/
import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Extensions are loaded from disk, not passed inline to createAgentSession.
// Use the discovery mechanism:
// 1. Place extension files in ~/.omp/agent/extensions/ or .omp/extensions/
// 2. Add paths to settings.json: { "extensions": ["./my-extension.ts"] }
// 3. Use --extension flag: pi --extension ./my-extension.ts
// To add additional extension paths beyond discovery:
const { session } = await createAgentSession({
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
sessionManager: SessionManager.inMemory(),
});
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("List files in the current directory.");
console.log();
// Example extension file (./my-logging-extension.ts):
/*
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("agent_start", async () => {
console.log("[Extension] Agent starting");
});
pi.on("tool_call", async (event) => {
console.log(\`[Extension] Tool: \${event.toolName}\`);
// Return { block: true, reason: "..." } to block execution
return undefined;
});
pi.on("agent_end", async (event) => {
console.log(\`[Extension] Done, \${event.messages.length} messages\`);
});
// Register a custom tool
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "Does something useful",
parameters: Type.Object({
input: Type.String(),
}),
execute: async (_toolCallId, params, _onUpdate, _ctx, _signal) => ({
content: [{ type: "text", text: \`Processed: \${params.input}\` }],
details: {},
}),
});
// Register a command
pi.registerCommand("mycommand", {
description: "Do something",
handler: async (args, ctx) => {
ctx.ui.notify(\`Command executed with: \${args}\`);
},
});
}
*/
+14 -13
View File
@@ -2,12 +2,13 @@
* Hooks Configuration
*
* Hooks intercept agent events for logging, blocking, or modification.
* Note: "hooks" is now called "extensions" in the API.
*/
import { createAgentSession, type HookFactory, SessionManager } from "@oh-my-pi/pi-coding-agent";
import { createAgentSession, type ExtensionFactory, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Logging hook
const loggingHook: HookFactory = (api) => {
// Logging hook (now called extension)
const loggingHook: ExtensionFactory = (api) => {
api.on("agent_start", async () => {
console.log("[Hook] Agent starting");
});
@@ -22,8 +23,8 @@ const loggingHook: HookFactory = (api) => {
});
};
// Blocking hook (returns { block: true, reason: "..." })
const safetyHook: HookFactory = (api) => {
// Blocking extension (returns { block: true, reason: "..." })
const safetyHook: ExtensionFactory = (api) => {
api.on("tool_call", async (event) => {
if (event.toolName === "bash") {
const cmd = (event.input as { command?: string }).command ?? "";
@@ -35,9 +36,9 @@ const safetyHook: HookFactory = (api) => {
});
};
// Use inline hooks
// Use inline extensions (hooks is now extensions)
const { session } = await createAgentSession({
hooks: [{ factory: loggingHook }, { factory: safetyHook }],
extensions: [loggingHook, safetyHook],
sessionManager: SessionManager.inMemory(),
});
@@ -50,12 +51,12 @@ session.subscribe((event) => {
await session.prompt("List files in the current directory.");
console.log();
// Disable all hooks:
// hooks: []
// Disable all extensions:
// extensions: []
// Merge with discovered hooks:
// const discovered = await discoverHooks();
// hooks: [...discovered, { factory: myHook }]
// Merge with discovered extensions:
// const discovered = await discoverExtensions();
// extensions: [...discovered.extensions.map(e => e.factory), myHook]
// Add paths without replacing discovery:
// additionalHookPaths: ["/extra/hooks"]
// additionalExtensionPaths: ["/extra/extensions"]
@@ -0,0 +1,42 @@
/**
* Prompt Templates
*
* File-based templates that inject content when invoked with /templatename.
*/
import {
createAgentSession,
discoverPromptTemplates,
type PromptTemplate,
SessionManager,
} from "@oh-my-pi/pi-coding-agent";
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = await discoverPromptTemplates();
console.log("Discovered prompt templates:");
for (const template of discovered) {
console.log(` /${template.name}: ${template.description}`);
}
// Define custom templates
const deployTemplate: PromptTemplate = {
name: "deploy",
description: "Deploy the application",
source: "(custom)",
content: `# Deploy Instructions
1. Build: npm run build
2. Test: npm test
3. Deploy: npm run deploy`,
};
// Use discovered + custom templates
await createAgentSession({
promptTemplates: [...discovered, deployTemplate],
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} prompt templates`);
// Disable prompt templates:
// promptTemplates: []
@@ -2,24 +2,25 @@
* Slash Commands
*
* File-based commands that inject content when invoked with /commandname.
* Note: File-based slash commands are now called "prompt templates".
*/
import {
createAgentSession,
discoverSlashCommands,
type FileSlashCommand,
discoverPromptTemplates,
type PromptTemplate,
SessionManager,
} from "@oh-my-pi/pi-coding-agent";
// Discover commands from cwd/.omp/commands/ and ~/.omp/agent/commands/
const discovered = discoverSlashCommands();
console.log("Discovered slash commands:");
// Discover prompt templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
const discovered = await discoverPromptTemplates();
console.log("Discovered prompt templates:");
for (const cmd of discovered) {
console.log(` /${cmd.name}: ${cmd.description}`);
}
// Define custom commands
const deployCommand: FileSlashCommand = {
// Define custom prompt templates
const deployCommand: PromptTemplate = {
name: "deploy",
description: "Deploy the application",
source: "(custom)",
@@ -30,13 +31,17 @@ const deployCommand: FileSlashCommand = {
3. Deploy: npm run deploy`,
};
// Use discovered + custom commands
// Note: slashCommands is now managed by the agent session automatically.
// Custom commands can be loaded via discoverCustomTSCommands() for TypeScript commands.
// For file-based markdown commands, use promptTemplates instead.
// Convert file-based slash commands to prompt templates
await createAgentSession({
slashCommands: [...discovered, deployCommand],
promptTemplates: [...discovered, deployCommand],
sessionManager: SessionManager.inMemory(),
});
console.log(`Session created with ${discovered.length + 1} slash commands`);
console.log(`Session created with ${discovered.length + 1} prompt templates`);
// Disable slash commands:
// slashCommands: []
// Disable prompt templates:
// promptTemplates: []
@@ -15,8 +15,8 @@ import {
// Default: discoverAuthStorage() uses ~/.omp/agent/auth.json
// discoverModels() loads built-in + custom models from ~/.omp/agent/models.json
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
await createAgentSession({
sessionManager: SessionManager.inMemory(),
@@ -15,7 +15,7 @@ import {
createAgentSession,
createBashTool,
createReadTool,
type HookFactory,
type ExtensionFactory,
ModelRegistry,
SessionManager,
SettingsManager,
@@ -33,8 +33,8 @@ if (process.env.MY_ANTHROPIC_KEY) {
// Model registry with no custom models.json
const modelRegistry = new ModelRegistry(authStorage);
// Inline hook
const auditHook: HookFactory = (api) => {
// Inline extension
const auditHook: ExtensionFactory = (api) => {
api.on("tool_call", async (event) => {
console.log(`[Audit] ${event.toolName}`);
return undefined;
@@ -76,11 +76,11 @@ const { session } = await createAgentSession({
Available: read, bash, status. Be concise.`,
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
customTools: [{ tool: statusTool }],
hooks: [{ factory: auditHook }],
customTools: [statusTool],
extensions: [auditHook],
skills: [],
contextFiles: [],
slashCommands: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(),
settingsManager,
});
+2 -1
View File
@@ -67,9 +67,10 @@
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/ms": "^2.1.0",
"@types/ndjson": "^2.0.4",
"@types/node": "^24.3.0",
"vitest": "^3.2.4"
"ms": "^2.1.3"
},
"keywords": [
"coding-agent",
@@ -0,0 +1,34 @@
/**
* Extension Modules Capability
*
* TypeScript/JavaScript extension modules loaded by the extension system.
*/
import { defineCapability } from "./index";
import type { SourceMeta } from "./types";
/**
* A loaded extension module.
*/
export interface ExtensionModule {
/** Extension module name (derived from path) */
name: string;
/** Absolute path to extension entrypoint */
path: string;
/** Source level */
level: "user" | "project";
/** Source metadata */
_source: SourceMeta;
}
export const extensionModuleCapability = defineCapability<ExtensionModule>({
id: "extension-modules",
displayName: "Extension Modules",
description: "TypeScript/JavaScript extension modules loaded by the extension system",
key: (ext) => ext.name,
validate: (ext) => {
if (!ext.name) return "Missing name";
if (!ext.path) return "Missing path";
return undefined;
},
});
+22 -7
View File
@@ -29,7 +29,7 @@ export interface Args {
models?: string[];
tools?: ToolName[];
hooks?: string[];
customTools?: string[];
extensions?: string[];
print?: boolean;
export?: string;
noSkills?: boolean;
@@ -37,6 +37,8 @@ export interface Args {
listModels?: string | true;
messages: string[];
fileArgs: string[];
/** Unknown flags (potentially extension flags) - map of flag name to value */
unknownFlags: Map<string, boolean | string>;
}
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
@@ -45,10 +47,11 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
}
export function parseArgs(args: string[]): Args {
export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
const result: Args = {
messages: [],
fileArgs: [],
unknownFlags: new Map(),
};
for (let i = 0; i < args.length; i++) {
@@ -120,9 +123,9 @@ export function parseArgs(args: string[]): Args {
} else if (arg === "--hook" && i + 1 < args.length) {
result.hooks = result.hooks ?? [];
result.hooks.push(args[++i]);
} else if (arg === "--tool" && i + 1 < args.length) {
result.customTools = result.customTools ?? [];
result.customTools.push(args[++i]);
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
result.extensions = result.extensions ?? [];
result.extensions.push(args[++i]);
} else if (arg === "--no-skills") {
result.noSkills = true;
} else if (arg === "--skills" && i + 1 < args.length) {
@@ -137,6 +140,18 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg.startsWith("@")) {
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
} else if (arg.startsWith("--") && extensionFlags) {
// Check if it's an extension-registered flag
const flagName = arg.slice(2);
const extFlag = extensionFlags.get(flagName);
if (extFlag) {
if (extFlag.type === "boolean") {
result.unknownFlags.set(flagName, true);
} else if (extFlag.type === "string" && i + 1 < args.length) {
result.unknownFlags.set(flagName, args[++i]);
}
}
// Unknown flags without extensionFlags are silently ignored (first pass)
} else if (!arg.startsWith("-")) {
result.messages.push(arg);
}
@@ -170,8 +185,8 @@ ${chalk.bold("Options:")}
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--hook <path> Load a hook file (can be used multiple times)
--tool <path> Load a custom tool file (can be used multiple times)
--hook <path> Load a hook/extension file (can be used multiple times)
--extension, -e <path> Load an extension file (can be used multiple times)
--no-skills Disable skills discovery and loading
--skills <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker)
--export <file> Export session file to HTML and exit
+37 -66
View File
@@ -2,12 +2,12 @@
* Process @file CLI arguments into text content and image attachments
*/
import { access, readFile, stat } from "node:fs/promises";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import chalk from "chalk";
import sharp from "sharp";
import { resolveReadPath } from "../core/tools/path-utils";
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
export interface ProcessedFiles {
@@ -15,55 +15,14 @@ export interface ProcessedFiles {
images: ImageContent[];
}
const RESIZE_TRIGGER_MAX_DIMENSION = 2048;
const MAX_RESIZE_WIDTH = 1920;
const MAX_RESIZE_HEIGHT = 1080;
const JPEG_CONVERT_THRESHOLD_BYTES = 2 * 1024 * 1024;
const JPEG_QUALITY = 85;
async function processImageAttachment(buffer: Buffer, mimeType: string): Promise<{ buffer: Buffer; mimeType: string }> {
const metadata = await sharp(buffer, { failOnError: false }).metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const maxDim = Math.max(width, height);
const shouldResize = width > 0 && height > 0 && maxDim > RESIZE_TRIGGER_MAX_DIMENSION;
const shouldConvertToJpeg = buffer.length > JPEG_CONVERT_THRESHOLD_BYTES;
if (!shouldResize && !shouldConvertToJpeg) {
return { buffer, mimeType };
}
let pipeline = sharp(buffer, { failOnError: false });
if (shouldResize) {
pipeline = pipeline.resize({
width: MAX_RESIZE_WIDTH,
height: MAX_RESIZE_HEIGHT,
fit: "inside",
withoutEnlargement: true,
});
}
if (shouldConvertToJpeg) {
pipeline = pipeline.jpeg({ quality: JPEG_QUALITY });
return { buffer: await pipeline.toBuffer(), mimeType: "image/jpeg" };
}
if (mimeType === "image/png") {
pipeline = pipeline.png();
} else if (mimeType === "image/webp") {
pipeline = pipeline.webp();
} else if (mimeType === "image/gif") {
pipeline = pipeline.gif();
} else {
pipeline = pipeline.jpeg({ quality: JPEG_QUALITY });
return { buffer: await pipeline.toBuffer(), mimeType: "image/jpeg" };
}
return { buffer: await pipeline.toBuffer(), mimeType };
export interface ProcessFileOptions {
/** Whether to auto-resize images to 2000x2000 max. Default: true */
autoResizeImages?: boolean;
}
/** Process @file arguments into text content and image attachments */
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
export async function processFileArguments(fileArgs: string[], options?: ProcessFileOptions): Promise<ProcessedFiles> {
const _autoResizeImages = options?.autoResizeImages ?? true;
let text = "";
const images: ImageContent[] = [];
@@ -71,16 +30,12 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
const absolutePath = resolve(resolveReadPath(fileArg, process.cwd()));
// Check if file exists
try {
await access(absolutePath);
} catch {
// Check if file exists and is not empty
if (!existsSync(absolutePath)) {
console.error(chalk.red(`Error: File not found: ${absolutePath}`));
process.exit(1);
}
// Check if file is empty
const stats = await stat(absolutePath);
const stats = statSync(absolutePath);
if (stats.size === 0) {
// Skip empty files
continue;
@@ -90,24 +45,40 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
if (mimeType) {
// Handle image file
const content = await readFile(absolutePath);
const processed = await processImageAttachment(content, mimeType);
const base64Content = processed.buffer.toString("base64");
const buffer = readFileSync(absolutePath);
const base64Content = buffer.toString("base64");
const attachment: ImageContent = {
type: "image",
mimeType: processed.mimeType,
data: base64Content,
};
let attachment: ImageContent;
let dimensionNote: string | undefined;
if (_autoResizeImages) {
const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
dimensionNote = formatDimensionNote(resized);
attachment = {
type: "image",
mimeType: resized.mimeType,
data: resized.data,
};
} else {
attachment = {
type: "image",
mimeType,
data: base64Content,
};
}
images.push(attachment);
// Add text reference to image
text += `<file name="${absolutePath}"></file>\n`;
// Add text reference to image with optional dimension note
if (dimensionNote) {
text += `<file name="${absolutePath}">${dimensionNote}</file>\n`;
} else {
text += `<file name="${absolutePath}"></file>\n`;
}
} else {
// Handle text file
try {
const content = await readFile(absolutePath, "utf-8");
const content = readFileSync(absolutePath, "utf-8");
text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
+1 -1
View File
@@ -25,7 +25,7 @@ function formatTokenCount(count: number): string {
* List available models, optionally filtered by search pattern
*/
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
const models = await modelRegistry.getAvailable();
const models = modelRegistry.getAvailable();
if (models.length === 0) {
console.log("No models available. Set API keys in environment variables.");
+25 -14
View File
@@ -104,6 +104,11 @@ export function getCommandsDir(): string {
return join(getAgentDir(), "commands");
}
/** Get path to prompts directory */
export function getPromptsDir(): string {
return join(getAgentDir(), "prompts");
}
/** Get path to sessions directory */
export function getSessionsDir(): string {
return join(getAgentDir(), "sessions");
@@ -230,8 +235,8 @@ export function readConfigFile<T = unknown>(
for (const { path: base, source, level } of dirs) {
const filePath = join(base, subpath);
if (existsSync(filePath)) {
try {
try {
if (existsSync(filePath)) {
const content = readFileSync(filePath, "utf-8");
return {
path: filePath,
@@ -239,9 +244,9 @@ export function readConfigFile<T = unknown>(
level,
content: JSON.parse(content) as T,
};
} catch {
// Continue to next file on parse error
}
} catch {
// Continue to next file on parse error
}
}
@@ -261,8 +266,8 @@ export function readAllConfigFiles<T = unknown>(
for (const { path: base, source, level } of dirs) {
const filePath = join(base, subpath);
if (existsSync(filePath)) {
try {
try {
if (existsSync(filePath)) {
const content = readFileSync(filePath, "utf-8");
results.push({
path: filePath,
@@ -270,9 +275,9 @@ export function readAllConfigFiles<T = unknown>(
level,
content: JSON.parse(content) as T,
});
} catch {
// Skip files that fail to parse
}
} catch {
// Skip files that fail to parse
}
}
@@ -319,9 +324,9 @@ export function findConfigFileWithMeta(
// Walk-Up Config Discovery (for monorepo scenarios)
// =============================================================================
function isDirectory(p: string): boolean {
async function isDirectory(p: string): Promise<boolean> {
try {
return statSync(p).isDirectory();
return existsSync(p) && statSync(p).isDirectory();
} catch {
return false;
}
@@ -335,14 +340,17 @@ function isDirectory(p: string): boolean {
* @param cwd - Starting directory
* @returns First existing directory found, or undefined
*/
export function findNearestProjectConfigDir(subpath: string, cwd: string = process.cwd()): ConfigDirEntry | undefined {
export async function findNearestProjectConfigDir(
subpath: string,
cwd: string = process.cwd(),
): Promise<ConfigDirEntry | undefined> {
let currentDir = cwd;
while (true) {
// Check all config bases at this level, in priority order
for (const { base, name } of PROJECT_CONFIG_BASES) {
const candidate = join(currentDir, base, subpath);
if (isDirectory(candidate)) {
if (await isDirectory(candidate)) {
return { path: candidate, source: name, level: "project" };
}
}
@@ -361,7 +369,10 @@ export function findNearestProjectConfigDir(subpath: string, cwd: string = proce
* Returns one entry per config base (.omp, .pi, .claude) - the nearest one found.
* Results are in priority order (highest first).
*/
export function findAllNearestProjectConfigDirs(subpath: string, cwd: string = process.cwd()): ConfigDirEntry[] {
export async function findAllNearestProjectConfigDirs(
subpath: string,
cwd: string = process.cwd(),
): Promise<ConfigDirEntry[]> {
const results: ConfigDirEntry[] = [];
const foundBases = new Set<string>();
@@ -372,7 +383,7 @@ export function findAllNearestProjectConfigDirs(subpath: string, cwd: string = p
if (foundBases.has(name)) continue;
const candidate = join(currentDir, base, subpath);
if (isDirectory(candidate)) {
if (await isDirectory(candidate)) {
results.push({ path: candidate, source: name, level: "project" });
foundBases.add(name);
}
File diff suppressed because it is too large Load Diff
+33 -21
View File
@@ -3,7 +3,7 @@
* Handles loading, saving, and refreshing credentials from auth.json.
*/
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import {
getEnvApiKey,
@@ -12,6 +12,7 @@ import {
loginAntigravity,
loginGeminiCli,
loginGitHubCopilot,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
} from "@oh-my-pi/pi-ai";
@@ -46,9 +47,7 @@ export class AuthStorage {
constructor(
private authPath: string,
private fallbackPaths: string[] = [],
) {
this.reload();
}
) {}
/**
* Set a runtime API key override (not persisted to disk).
@@ -77,7 +76,7 @@ export class AuthStorage {
* Reload credentials from disk.
* Checks primary path first, then fallback paths.
*/
reload(): void {
async reload(): Promise<void> {
const pathsToCheck = [this.authPath, ...this.fallbackPaths];
logger.debug("AuthStorage.reload checking paths", { paths: pathsToCheck });
@@ -105,13 +104,11 @@ export class AuthStorage {
/**
* Save credentials to disk.
*/
private save(): void {
const dir = dirname(this.authPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
Bun.write(this.authPath, JSON.stringify(this.data, null, 2));
private async save(): Promise<void> {
writeFileSync(this.authPath, JSON.stringify(this.data, null, 2));
chmodSync(this.authPath, 0o600);
const dir = dirname(this.authPath);
chmodSync(dir, 0o700);
}
/**
@@ -124,17 +121,17 @@ export class AuthStorage {
/**
* Set credential for a provider.
*/
set(provider: string, credential: AuthCredential): void {
async set(provider: string, credential: AuthCredential): Promise<void> {
this.data[provider] = credential;
this.save();
await this.save();
}
/**
* Remove credential for a provider.
*/
remove(provider: string): void {
async remove(provider: string): Promise<void> {
delete this.data[provider];
this.save();
await this.save();
}
/**
@@ -145,12 +142,24 @@ export class AuthStorage {
}
/**
* Check if credentials exist for a provider.
* Check if credentials exist for a provider in auth.json.
*/
has(provider: string): boolean {
return provider in this.data;
}
/**
* Check if any form of auth is configured for a provider.
* Unlike getApiKey(), this doesn't refresh OAuth tokens.
*/
hasAuth(provider: string): boolean {
if (this.runtimeOverrides.has(provider)) return true;
if (this.data[provider]) return true;
if (getEnvApiKey(provider)) return true;
if (this.fallbackResolver?.(provider)) return true;
return false;
}
/**
* Get all credentials (for passing to getOAuthApiKey).
*/
@@ -191,18 +200,21 @@ export class AuthStorage {
case "google-antigravity":
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
break;
case "openai-codex":
credentials = await loginOpenAICodex(callbacks);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
this.set(provider, { type: "oauth", ...credentials });
await this.set(provider, { type: "oauth", ...credentials });
}
/**
* Logout from a provider.
*/
logout(provider: string): void {
this.remove(provider);
async logout(provider: string): Promise<void> {
await this.remove(provider);
}
/**
@@ -240,11 +252,11 @@ export class AuthStorage {
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
if (result) {
this.data[provider] = { type: "oauth", ...result.newCredentials };
this.save();
await this.save();
return result.apiKey;
}
} catch {
this.remove(provider);
await this.remove(provider);
}
}
@@ -14,7 +14,7 @@ import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createHookMessage,
createCustomMessage,
} from "../messages";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager";
import { estimateTokens } from "./compaction";
@@ -149,7 +149,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
return entry.message;
case "custom_message":
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@@ -186,9 +186,9 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
// Only extract from pi-generated summaries (fromExtension !== true), not extension-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
if (entry.type === "branch_summary" && !entry.fromExtension && entry.details) {
const details = entry.details as BranchSummaryDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
@@ -11,7 +11,7 @@ import { complete, completeSimple } from "@oh-my-pi/pi-ai";
import compactionSummaryPrompt from "../../prompts/compaction-summary.md" with { type: "text" };
import compactionTurnPrefixPrompt from "../../prompts/compaction-turn-prefix.md" with { type: "text" };
import compactionUpdateSummaryPrompt from "../../prompts/compaction-update-summary.md" with { type: "text" };
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages";
import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages";
import type { CompactionEntry, SessionEntry } from "../session-manager";
import {
computeFileLists,
@@ -46,7 +46,7 @@ function extractFileOperations(
// Collect from previous compaction's details (if pi-generated)
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
if (!prevCompaction.fromExtension && prevCompaction.details) {
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
@@ -78,7 +78,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
return entry.message;
}
if (entry.type === "custom_message") {
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
}
if (entry.type === "branch_summary") {
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
@@ -99,8 +99,8 @@ function formatError(err: unknown): string {
return String(err);
}
function pickAgent(cwd: string): AgentDefinition {
const { agents } = discoverAgents(cwd);
async function pickAgent(cwd: string): Promise<AgentDefinition> {
const { agents } = await discoverAgents(cwd);
// Use the bundled "task" agent as the general-purpose default.
const agent = getAgent(agents, "task") ?? agents[0];
if (!agent) {
@@ -256,7 +256,7 @@ async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise<st
});
await updateSession(session.id, { status: "active" });
const agent = pickAgent(ctx.cwd);
const agent = await pickAgent(ctx.cwd);
const context = args.scope ? `Scope: ${args.scope}` : undefined;
// Command context doesn't expose a spawn API, so run the task subprocess directly.
@@ -299,7 +299,7 @@ async function handleParallel(args: ParallelTask[], ctx: HookCommandContext): Pr
validateDisjointScopes(args.map((t) => t.scope));
const sessionId = `parallel-${Date.now()}`;
const agent = pickAgent(ctx.cwd);
const agent = await pickAgent(ctx.cwd);
const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = [];
for (let i = 0; i < args.length; i++) {
@@ -3,6 +3,7 @@
*/
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Theme } from "../../modes/interactive/theme/theme";
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types";
/**
@@ -18,6 +19,10 @@ export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolCon
hidden: tool.hidden,
execute: (toolCallId, params, signal, onUpdate, context) =>
tool.execute(toolCallId, params, onUpdate, context ?? getContext(), signal),
renderCall: tool.renderCall ? (args, theme) => tool.renderCall?.(args, theme as Theme) : undefined,
renderResult: tool.renderResult
? (result, options, theme) => tool.renderResult?.(result, options, theme as Theme)
: undefined,
};
}
@@ -0,0 +1,59 @@
export interface EventBus {
emit(channel: string, data: unknown): void;
on(channel: string, handler: (data: unknown) => void): () => void;
}
export interface EventBusController extends EventBus {
clear(): void;
}
class SimpleEventEmitter {
private listeners = new Map<string, Set<(data: unknown) => void>>();
on(channel: string, handler: (data: unknown) => void): void {
if (!this.listeners.has(channel)) {
this.listeners.set(channel, new Set());
}
this.listeners.get(channel)!.add(handler);
}
off(channel: string, handler: (data: unknown) => void): void {
this.listeners.get(channel)?.delete(handler);
}
emit(channel: string, data: unknown): void {
const handlers = this.listeners.get(channel);
if (handlers) {
for (const handler of handlers) {
handler(data);
}
}
}
removeAllListeners(): void {
this.listeners.clear();
}
}
export function createEventBus(): EventBusController {
const emitter = new SimpleEventEmitter();
return {
emit: (channel, data) => {
emitter.emit(channel, data);
},
on: (channel, handler) => {
const safeHandler = async (data: unknown) => {
try {
await handler(data);
} catch (err) {
console.error(`Event handler error (${channel}):`, err);
}
};
emitter.on(channel, safeHandler);
return () => emitter.off(channel, safeHandler);
},
clear: () => {
emitter.removeAllListeners();
},
};
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,100 @@
/**
* Extension system for lifecycle events and custom tools.
*/
export { discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions } from "./loader";
export type { BranchHandler, ExtensionErrorListener, NavigateTreeHandler, NewSessionHandler } from "./runner";
export { ExtensionRunner } from "./runner";
export type {
AgentEndEvent,
AgentStartEvent,
// Re-exports
AgentToolResult,
AgentToolUpdateCallback,
AppendEntryHandler,
BashToolResultEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
// Events - Agent
ContextEvent,
// Event Results
ContextEventResult,
CustomToolResultEvent,
EditToolResultEvent,
ExecOptions,
ExecResult,
// API
ExtensionAPI,
ExtensionCommandContext,
// Context
ExtensionContext,
// Errors
ExtensionError,
ExtensionEvent,
ExtensionFactory,
ExtensionFlag,
ExtensionHandler,
ExtensionShortcut,
ExtensionUIContext,
FindToolResultEvent,
GetActiveToolsHandler,
GetAllToolsHandler,
GrepToolResultEvent,
LoadExtensionsResult,
// Loaded Extension
LoadedExtension,
LsToolResultEvent,
// Message Rendering
MessageRenderer,
MessageRenderOptions,
ReadToolResultEvent,
// Commands
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionBranchEvent,
SessionCompactEvent,
SessionEvent,
SessionShutdownEvent,
// Events - Session
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
SetActiveToolsHandler,
// Events - Tool
ToolCallEvent,
ToolCallEventResult,
// Tools
ToolDefinition,
ToolRenderResultOptions,
ToolResultEvent,
ToolResultEventResult,
TreePreparation,
TurnEndEvent,
TurnStartEvent,
WriteToolResultEvent,
} from "./types";
// Type guards
export {
isBashToolResult,
isEditToolResult,
isFindToolResult,
isGrepToolResult,
isLsToolResult,
isReadToolResult,
isWriteToolResult,
} from "./types";
export {
wrapRegisteredTool,
wrapRegisteredTools,
wrapToolsWithExtensions,
wrapToolWithExtensions,
} from "./wrapper";
@@ -0,0 +1,501 @@
/**
* Extension loader - loads TypeScript extension modules using native Bun import.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import * as path from "node:path";
import type { KeyId } from "@oh-my-pi/pi-tui";
import * as TypeBox from "@sinclair/typebox";
import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module";
import { loadSync } from "../../discovery";
import { getExtensionNameFromPath } from "../../discovery/helpers";
import * as piCodingAgent from "../../index";
import { theme } from "../../modes/interactive/theme/theme";
import { createEventBus, type EventBus } from "../event-bus";
import type { ExecOptions } from "../exec";
import { execCommand } from "../exec";
import { logger } from "../logger";
import type {
AppendEntryHandler,
ExtensionAPI,
ExtensionContext,
ExtensionFactory,
ExtensionFlag,
ExtensionShortcut,
ExtensionUIContext,
GetActiveToolsHandler,
GetAllToolsHandler,
LoadExtensionsResult,
LoadedExtension,
MessageRenderer,
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SetActiveToolsHandler,
ToolDefinition,
} from "./types";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(homedir(), normalized.slice(1));
}
return normalized;
}
function resolvePath(extPath: string, cwd: string): string {
const expanded = expandPath(extPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
function createNoOpUIContext(): ExtensionUIContext {
return {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
};
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
function createExtensionAPI(
handlers: Map<string, HandlerFn[]>,
tools: Map<string, RegisteredTool>,
cwd: string,
extensionPath: string,
eventBus: EventBus,
_sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
): {
api: ExtensionAPI;
messageRenderers: Map<string, MessageRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
setFlagValue: (name: string, value: boolean | string) => void;
} {
let sendMessageHandler: SendMessageHandler = () => {};
let appendEntryHandler: AppendEntryHandler = () => {};
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
let getAllToolsHandler: GetAllToolsHandler = () => [];
let setActiveToolsHandler: SetActiveToolsHandler = () => {};
const messageRenderers = new Map<string, MessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const flags = new Map<string, ExtensionFlag>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<KeyId, ExtensionShortcut>();
const api = {
logger,
typebox: TypeBox,
pi: piCodingAgent,
on(event: string, handler: HandlerFn): void {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
registerTool(tool: ToolDefinition): void {
tools.set(tool.name, {
definition: tool,
extensionPath,
});
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options });
},
registerShortcut(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: ExtensionContext) => Promise<void> | void;
},
): void {
shortcuts.set(shortcut, { shortcut, extensionPath, ...options });
},
registerFlag(
name: string,
options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
): void {
flags.set(name, { name, extensionPath, ...options });
if (options.default !== undefined) {
flagValues.set(name, options.default);
}
},
getFlag(name: string): boolean | string | undefined {
return flagValues.get(name);
},
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {
messageRenderers.set(customType, renderer as MessageRenderer);
},
sendMessage(message, options): void {
sendMessageHandler(message, options);
},
appendEntry(customType: string, data?: unknown): void {
appendEntryHandler(customType, data);
},
exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options);
},
getActiveTools(): string[] {
return getActiveToolsHandler();
},
getAllTools(): string[] {
return getAllToolsHandler();
},
setActiveTools(toolNames: string[]): void {
setActiveToolsHandler(toolNames);
},
events: eventBus,
} as ExtensionAPI;
return {
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => {
getActiveToolsHandler = handler;
},
setGetAllToolsHandler: (handler: GetAllToolsHandler) => {
getAllToolsHandler = handler;
},
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => {
setActiveToolsHandler = handler;
},
setFlagValue: (name: string, value: boolean | string) => {
flagValues.set(name, value);
},
};
}
async function loadExtension(
extensionPath: string,
cwd: string,
eventBus: EventBus,
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
): Promise<{ extension: LoadedExtension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd);
try {
const module = await import(resolvedPath);
const factory = (module.default ?? module) as ExtensionFactory;
if (typeof factory !== "function") {
return { extension: null, error: "Extension must export a default function" };
}
const handlers = new Map<string, HandlerFn[]>();
const tools = new Map<string, RegisteredTool>();
const {
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
setSetActiveToolsHandler,
setFlagValue,
} = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI);
factory(api);
return {
extension: {
path: extensionPath,
resolvedPath,
handlers,
tools,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
setSetActiveToolsHandler,
setFlagValue,
},
error: null,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { extension: null, error: `Failed to load extension: ${message}` };
}
}
/**
* Create a LoadedExtension from an inline factory function.
*/
export function loadExtensionFromFactory(
factory: ExtensionFactory,
cwd: string,
eventBus: EventBus,
sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
name = "<inline>",
): LoadedExtension {
const handlers = new Map<string, HandlerFn[]>();
const tools = new Map<string, RegisteredTool>();
const {
api,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
setSetActiveToolsHandler,
setFlagValue,
} = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI);
factory(api);
return {
path: name,
resolvedPath: name,
handlers,
tools,
messageRenderers,
commands,
flags,
flagValues,
shortcuts,
setSendMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
setSetActiveToolsHandler,
setFlagValue,
};
}
/**
* Load extensions from paths.
*/
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: LoadedExtension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedEventBus = eventBus ?? createEventBus();
const sharedUI = { ui: createNoOpUIContext(), hasUI: false };
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, sharedUI);
if (error) {
errors.push({ path: extPath, error });
continue;
}
if (extension) {
extensions.push(extension);
}
}
return {
extensions,
errors,
setUIContext(uiContext, hasUI) {
sharedUI.ui = uiContext;
sharedUI.hasUI = hasUI;
},
};
}
interface ExtensionManifest {
extensions?: string[];
themes?: string[];
skills?: string[];
}
function readExtensionManifest(packageJsonPath: string): ExtensionManifest | null {
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { omp?: ExtensionManifest; pi?: ExtensionManifest };
const manifest = pkg.omp ?? pkg.pi;
if (manifest && typeof manifest === "object") {
return manifest;
}
return null;
} catch {
return null;
}
}
function isExtensionFile(name: string): boolean {
return name.endsWith(".ts") || name.endsWith(".js");
}
/**
* Discover extensions in a directory.
*
* Discovery rules:
* 1. Direct files: `extensions/*.ts` or `*.js` → load
* 2. Subdirectory with index: `extensions/<ext>/index.ts` or `index.js` → load
* 3. Subdirectory with package.json: `extensions/<ext>/package.json` with "omp"/"pi" field → load declared paths
*
* No recursion beyond one level. Complex packages must use package.json manifest.
*/
function discoverExtensionsInDir(dir: string): string[] {
if (!existsSync(dir)) {
return [];
}
const discovered: string[] = [];
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
// 1. Direct files: *.ts or *.js
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
discovered.push(entryPath);
continue;
}
// 2 & 3. Subdirectories
if (entry.isDirectory() || entry.isSymbolicLink()) {
// Check for package.json with "omp"/"pi" field first
const packageJsonPath = path.join(entryPath, "package.json");
if (existsSync(packageJsonPath)) {
const manifest = readExtensionManifest(packageJsonPath);
if (manifest?.extensions) {
// Load paths declared in manifest (relative to package.json dir)
for (const extPath of manifest.extensions) {
const resolvedExtPath = path.resolve(entryPath, extPath);
if (existsSync(resolvedExtPath)) {
discovered.push(resolvedExtPath);
}
}
continue;
}
}
// Check for index.ts or index.js
const indexTs = path.join(entryPath, "index.ts");
const indexJs = path.join(entryPath, "index.js");
if (existsSync(indexTs)) {
discovered.push(indexTs);
} else if (existsSync(indexJs)) {
discovered.push(indexJs);
}
}
}
} catch {
return [];
}
return discovered;
}
/**
* Discover and load extensions from standard locations.
*/
export async function discoverAndLoadExtensions(
configuredPaths: string[],
cwd: string,
eventBus?: EventBus,
disabledExtensionIds: string[] = [],
): Promise<LoadExtensionsResult> {
const allPaths: string[] = [];
const seen = new Set<string>();
const disabled = new Set(disabledExtensionIds);
const isDisabledName = (name: string): boolean => disabled.has(`extension-module:${name}`);
const addPath = (extPath: string): void => {
const resolved = path.resolve(extPath);
if (!seen.has(resolved)) {
seen.add(resolved);
allPaths.push(extPath);
}
};
const addPaths = (paths: string[]) => {
for (const extPath of paths) {
if (isDisabledName(getExtensionNameFromPath(extPath))) continue;
addPath(extPath);
}
};
// 1. Discover extension modules via capability API (native .omp/.pi only)
const discovered = loadSync<ExtensionModule>(extensionModuleCapability.id, { cwd });
for (const ext of discovered.items) {
if (ext._source.provider !== "native") continue;
if (isDisabledName(ext.name)) continue;
addPath(ext.path);
}
// 2. Explicitly configured paths
for (const configuredPath of configuredPaths) {
const resolved = resolvePath(configuredPath, cwd);
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
addPaths(discoverExtensionsInDir(resolved));
} else {
addPath(resolved);
}
}
return loadExtensions(allPaths, cwd, eventBus);
}
@@ -0,0 +1,477 @@
/**
* Extension runner - executes extensions and manages their lifecycle.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
import type { KeyId } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/interactive/theme/theme";
import type { ModelRegistry } from "../model-registry";
import type { SessionManager } from "../session-manager";
import type {
AppendEntryHandler,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
ExtensionCommandContext,
ExtensionContext,
ExtensionError,
ExtensionEvent,
ExtensionFlag,
ExtensionShortcut,
ExtensionUIContext,
GetActiveToolsHandler,
GetAllToolsHandler,
LoadedExtension,
MessageRenderer,
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
SetActiveToolsHandler,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
} from "./types";
/** Combined result from all before_agent_start handlers */
interface BeforeAgentStartCombinedResult {
messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
systemPromptAppend?: string;
}
export type ExtensionErrorListener = (error: ExtensionError) => void;
export type NewSessionHandler = (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
export type NavigateTreeHandler = (
targetId: string,
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
const noOpUIContext: ExtensionUIContext = {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
};
export class ExtensionRunner {
private extensions: LoadedExtension[];
private uiContext: ExtensionUIContext;
private hasUI: boolean;
private cwd: string;
private sessionManager: SessionManager;
private modelRegistry: ModelRegistry;
private errorListeners: Set<ExtensionErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
private isIdleFn: () => boolean = () => true;
private waitForIdleFn: () => Promise<void> = async () => {};
private abortFn: () => void = () => {};
private hasPendingMessagesFn: () => boolean = () => false;
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
private branchHandler: BranchHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
constructor(
extensions: LoadedExtension[],
cwd: string,
sessionManager: SessionManager,
modelRegistry: ModelRegistry,
) {
this.extensions = extensions;
this.uiContext = noOpUIContext;
this.hasUI = false;
this.cwd = cwd;
this.sessionManager = sessionManager;
this.modelRegistry = modelRegistry;
}
initialize(options: {
getModel: () => Model<any> | undefined;
sendMessageHandler: SendMessageHandler;
appendEntryHandler: AppendEntryHandler;
getActiveToolsHandler: GetActiveToolsHandler;
getAllToolsHandler: GetAllToolsHandler;
setActiveToolsHandler: SetActiveToolsHandler;
newSessionHandler?: NewSessionHandler;
branchHandler?: BranchHandler;
navigateTreeHandler?: NavigateTreeHandler;
isIdle?: () => boolean;
waitForIdle?: () => Promise<void>;
abort?: () => void;
hasPendingMessages?: () => boolean;
uiContext?: ExtensionUIContext;
hasUI?: boolean;
}): void {
this.getModel = options.getModel;
this.isIdleFn = options.isIdle ?? (() => true);
this.waitForIdleFn = options.waitForIdle ?? (async () => {});
this.abortFn = options.abort ?? (() => {});
this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false);
if (options.newSessionHandler) {
this.newSessionHandler = options.newSessionHandler;
}
if (options.branchHandler) {
this.branchHandler = options.branchHandler;
}
if (options.navigateTreeHandler) {
this.navigateTreeHandler = options.navigateTreeHandler;
}
for (const ext of this.extensions) {
ext.setSendMessageHandler(options.sendMessageHandler);
ext.setAppendEntryHandler(options.appendEntryHandler);
ext.setGetActiveToolsHandler(options.getActiveToolsHandler);
ext.setGetAllToolsHandler(options.getAllToolsHandler);
ext.setSetActiveToolsHandler(options.setActiveToolsHandler);
}
this.uiContext = options.uiContext ?? noOpUIContext;
this.hasUI = options.hasUI ?? false;
}
getUIContext(): ExtensionUIContext | null {
return this.uiContext;
}
getHasUI(): boolean {
return this.hasUI;
}
getExtensionPaths(): string[] {
return this.extensions.map((e) => e.path);
}
/** Get all registered tools from all extensions. */
getAllRegisteredTools(): RegisteredTool[] {
const tools: RegisteredTool[] = [];
for (const ext of this.extensions) {
for (const tool of ext.tools.values()) {
tools.push(tool);
}
}
return tools;
}
getFlags(): Map<string, ExtensionFlag> {
const allFlags = new Map<string, ExtensionFlag>();
for (const ext of this.extensions) {
for (const [name, flag] of ext.flags) {
allFlags.set(name, flag);
}
}
return allFlags;
}
setFlagValue(name: string, value: boolean | string): void {
for (const ext of this.extensions) {
if (ext.flags.has(name)) {
ext.setFlagValue(name, value);
}
}
}
private static readonly RESERVED_SHORTCUTS = new Set([
"ctrl+c",
"ctrl+d",
"ctrl+z",
"ctrl+k",
"ctrl+p",
"ctrl+l",
"ctrl+o",
"ctrl+t",
"ctrl+g",
"shift+tab",
"shift+ctrl+p",
"alt+enter",
"escape",
"enter",
]);
getShortcuts(): Map<KeyId, ExtensionShortcut> {
const allShortcuts = new Map<KeyId, ExtensionShortcut>();
for (const ext of this.extensions) {
for (const [key, shortcut] of ext.shortcuts) {
const normalizedKey = key.toLowerCase() as KeyId;
if (ExtensionRunner.RESERVED_SHORTCUTS.has(normalizedKey)) {
console.warn(
`Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`,
);
continue;
}
const existing = allShortcuts.get(normalizedKey);
if (existing) {
console.warn(
`Extension shortcut conflict: '${key}' registered by both ${existing.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`,
);
}
allShortcuts.set(normalizedKey, shortcut);
}
}
return allShortcuts;
}
onError(listener: ExtensionErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
emitError(error: ExtensionError): void {
for (const listener of this.errorListeners) {
listener(error);
}
}
hasHandlers(eventType: string): boolean {
for (const ext of this.extensions) {
const handlers = ext.handlers.get(eventType);
if (handlers && handlers.length > 0) {
return true;
}
}
return false;
}
getMessageRenderer(customType: string): MessageRenderer | undefined {
for (const ext of this.extensions) {
const renderer = ext.messageRenderers.get(customType);
if (renderer) {
return renderer;
}
}
return undefined;
}
getRegisteredCommands(): RegisteredCommand[] {
const commands: RegisteredCommand[] = [];
for (const ext of this.extensions) {
for (const command of ext.commands.values()) {
commands.push(command);
}
}
return commands;
}
getCommand(name: string): RegisteredCommand | undefined {
for (const ext of this.extensions) {
const command = ext.commands.get(name);
if (command) {
return command;
}
}
return undefined;
}
private createContext(): ExtensionContext {
return {
ui: this.uiContext,
hasUI: this.hasUI,
cwd: this.cwd,
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
model: this.getModel(),
isIdle: () => this.isIdleFn(),
abort: () => this.abortFn(),
hasPendingMessages: () => this.hasPendingMessagesFn(),
hasQueuedMessages: () => this.hasPendingMessagesFn(),
};
}
createCommandContext(): ExtensionCommandContext {
return {
...this.createContext(),
waitForIdle: () => this.waitForIdleFn(),
newSession: (options) => this.newSessionHandler(options),
branch: (entryId) => this.branchHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
};
}
private isSessionBeforeEvent(
type: string,
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_branch" ||
type === "session_before_compact" ||
type === "session_before_tree"
);
}
async emit(
event: ExtensionEvent,
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
for (const ext of this.extensions) {
const handlers = ext.handlers.get(event.type);
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
if (result.cancel) {
return result;
}
}
if (event.type === "tool_result" && handlerResult) {
result = handlerResult as ToolResultEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: event.type,
error: message,
stack,
});
}
}
}
return result;
}
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
const ctx = this.createContext();
let result: ToolCallEventResult | undefined;
for (const ext of this.extensions) {
const handlers = ext.handlers.get("tool_call");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
if (handlerResult) {
result = handlerResult as ToolCallEventResult;
if (result.block) {
return result;
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "tool_call",
error: message,
stack,
});
return { block: true, reason: `Extension ${ext.path} failed: ${message}` };
}
}
}
return result;
}
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = structuredClone(messages);
for (const ext of this.extensions) {
const handlers = ext.handlers.get("context");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "context",
error: message,
stack,
});
}
}
}
return currentMessages;
}
async emitBeforeAgentStart(
prompt: string,
images?: ImageContent[],
): Promise<BeforeAgentStartCombinedResult | undefined> {
const ctx = this.createContext();
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
const systemPromptAppends: string[] = [];
for (const ext of this.extensions) {
const handlers = ext.handlers.get("before_agent_start");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
const handlerResult = await handler(event, ctx);
if (handlerResult) {
const result = handlerResult as BeforeAgentStartEventResult;
if (result.message) {
messages.push(result.message);
}
if (result.systemPromptAppend) {
systemPromptAppends.push(result.systemPromptAppend);
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "before_agent_start",
error: message,
stack,
});
}
}
}
if (messages.length > 0 || systemPromptAppends.length > 0) {
return {
messages: messages.length > 0 ? messages : undefined,
systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined,
};
}
return undefined;
}
}
@@ -0,0 +1,712 @@
/**
* Extension system types.
*
* Extensions are TypeScript modules that can:
* - Subscribe to agent lifecycle events
* - Register LLM-callable tools
* - Register commands, keyboard shortcuts, and CLI flags
* - Interact with the user via UI primitives
*/
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { Component, KeyId, TUI } from "@oh-my-pi/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../compaction";
import type { EventBus } from "../event-bus";
import type { ExecOptions, ExecResult } from "../exec";
import type { CustomMessage } from "../messages";
import type { ModelRegistry } from "../model-registry";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../session-manager";
import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools";
import type { EditToolDetails } from "../tools/edit";
export type { ExecOptions, ExecResult } from "../exec";
export type { AgentToolResult, AgentToolUpdateCallback };
// ============================================================================
// UI Context
// ============================================================================
/**
* UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation.
*/
export interface ExtensionUIContext {
/** Show a selector and return the user's choice. */
select(title: string, options: string[]): Promise<string | undefined>;
/** Show a confirmation dialog. */
confirm(title: string, message: string): Promise<boolean>;
/** Show a text input dialog. */
input(title: string, placeholder?: string): Promise<string | undefined>;
/** Show a notification to the user. */
notify(message: string, type?: "info" | "warning" | "error"): void;
/** Set status text in the footer/status bar. Pass undefined to clear. */
setStatus(key: string, text: string | undefined): void;
/** Set a widget to display above the editor. Accepts string array or component factory. */
setWidget(key: string, content: string[] | undefined): void;
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set the terminal window/tab title. */
setTitle(title: string): void;
/** Show a custom component with keyboard focus. */
custom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
/** Set the text in the core input editor. */
setEditorText(text: string): void;
/** Get the current text from the core input editor. */
getEditorText(): string;
/** Show a multi-line editor for text editing. */
editor(title: string, prefill?: string): Promise<string | undefined>;
/** Get the current theme for styling. */
readonly theme: Theme;
}
// ============================================================================
// Extension Context
// ============================================================================
/**
* Context passed to extension event handlers.
*/
export interface ExtensionContext {
/** UI methods for user interaction */
ui: ExtensionUIContext;
/** Whether UI is available (false in print/RPC mode) */
hasUI: boolean;
/** Current working directory */
cwd: string;
/** Session manager (read-only) */
sessionManager: ReadonlySessionManager;
/** Model registry for API key resolution */
modelRegistry: ModelRegistry;
/** Current model (may be undefined) */
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Abort the current agent operation */
abort(): void;
/** Whether there are queued messages waiting */
hasPendingMessages(): boolean;
/** @deprecated Use hasPendingMessages() instead */
hasQueuedMessages(): boolean;
}
/**
* Extended context for command handlers.
* Includes session control methods only safe in user-initiated commands.
*/
export interface ExtensionCommandContext extends ExtensionContext {
/** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>;
/** Start a new session, optionally with initialization. */
newSession(options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }>;
/** Branch from a specific entry, creating a new session file. */
branch(entryId: string): Promise<{ cancelled: boolean }>;
/** Navigate to a different point in the session tree. */
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
}
// ============================================================================
// Tool Types
// ============================================================================
/** Rendering options for tool results */
export interface ToolRenderResultOptions {
/** Whether the result view is expanded */
expanded: boolean;
/** Whether this is a partial/streaming result */
isPartial: boolean;
/** Current spinner frame index for animated elements (optional) */
spinnerFrame?: number;
}
/** Session event for tool onSession lifecycle */
export interface ToolSessionEvent {
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start" and "shutdown" */
previousSessionFile: string | undefined;
}
/**
* Tool definition for registerTool().
*/
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown> {
/** Tool name (used in LLM tool calls) */
name: string;
/** Human-readable label for UI */
label: string;
/** Description for LLM */
description: string;
/** Parameter schema (TypeBox) */
parameters: TParams;
/** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
hidden?: boolean;
/** Execute the tool. */
execute(
toolCallId: string,
params: Static<TParams>,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AgentToolResult<TDetails>>;
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
onSession?: (event: ToolSessionEvent, ctx: ExtensionContext) => void | Promise<void>;
/** Custom rendering for tool call display */
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
/** Custom rendering for tool result display */
renderResult?: (result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme) => Component;
}
// ============================================================================
// Session Events
// ============================================================================
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
reason: "new" | "resume";
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
reason: "new" | "resume";
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
preparation: CompactionPreparation;
branchEntries: SessionEntry[];
customInstructions?: string;
signal: AbortSignal;
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromExtension: boolean;
}
/** Fired on process exit */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation */
export interface TreePreparation {
targetId: string;
oldLeafId: string | null;
commonAncestorId: string | null;
entriesToSummarize: SessionEntry[];
userWantsSummary: boolean;
}
/** Fired before navigating in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
preparation: TreePreparation;
signal: AbortSignal;
}
/** Fired after navigating in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromExtension?: boolean;
}
export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
// ============================================================================
// Agent Events
// ============================================================================
/** Fired before each LLM call. Can modify messages. */
export interface ContextEvent {
type: "context";
messages: AgentMessage[];
}
/** Fired after user submits prompt but before agent loop. */
export interface BeforeAgentStartEvent {
type: "before_agent_start";
prompt: string;
images?: ImageContent[];
}
/** Fired when an agent loop starts */
export interface AgentStartEvent {
type: "agent_start";
}
/** Fired when an agent loop ends */
export interface AgentEndEvent {
type: "agent_end";
messages: AgentMessage[];
}
/** Fired at the start of each turn */
export interface TurnStartEvent {
type: "turn_start";
turnIndex: number;
timestamp: number;
}
/** Fired at the end of each turn */
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
// ============================================================================
// Tool Events
// ============================================================================
/** Fired before a tool executes. Can block. */
export interface ToolCallEvent {
type: "tool_call";
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
}
interface ToolResultEventBase {
type: "tool_result";
toolCallId: string;
input: Record<string, unknown>;
content: (TextContent | ImageContent)[];
isError: boolean;
}
export interface BashToolResultEvent extends ToolResultEventBase {
toolName: "bash";
details: BashToolDetails | undefined;
}
export interface ReadToolResultEvent extends ToolResultEventBase {
toolName: "read";
details: ReadToolDetails | undefined;
}
export interface EditToolResultEvent extends ToolResultEventBase {
toolName: "edit";
details: EditToolDetails | undefined;
}
export interface WriteToolResultEvent extends ToolResultEventBase {
toolName: "write";
details: undefined;
}
export interface GrepToolResultEvent extends ToolResultEventBase {
toolName: "grep";
details: GrepToolDetails | undefined;
}
export interface FindToolResultEvent extends ToolResultEventBase {
toolName: "find";
details: FindToolDetails | undefined;
}
export interface LsToolResultEvent extends ToolResultEventBase {
toolName: "ls";
details: LsToolDetails | undefined;
}
export interface CustomToolResultEvent extends ToolResultEventBase {
toolName: string;
details: unknown;
}
/** Fired after a tool executes. Can modify result. */
export type ToolResultEvent =
| BashToolResultEvent
| ReadToolResultEvent
| EditToolResultEvent
| WriteToolResultEvent
| GrepToolResultEvent
| FindToolResultEvent
| LsToolResultEvent
| CustomToolResultEvent;
// Type guards
export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent {
return e.toolName === "bash";
}
export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent {
return e.toolName === "read";
}
export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent {
return e.toolName === "edit";
}
export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent {
return e.toolName === "write";
}
export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent {
return e.toolName === "grep";
}
export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent {
return e.toolName === "find";
}
export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
return e.toolName === "ls";
}
/** Union of all event types */
export type ExtensionEvent =
| SessionEvent
| ContextEvent
| BeforeAgentStartEvent
| AgentStartEvent
| AgentEndEvent
| TurnStartEvent
| TurnEndEvent
| ToolCallEvent
| ToolResultEvent;
// ============================================================================
// Event Results
// ============================================================================
export interface ContextEventResult {
messages?: AgentMessage[];
}
export interface ToolCallEventResult {
block?: boolean;
reason?: string;
}
export interface ToolResultEventResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
}
export interface BeforeAgentStartEventResult {
message?: Pick<CustomMessage, "customType" | "content" | "display" | "details">;
systemPromptAppend?: string;
}
export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
export interface SessionBeforeBranchResult {
cancel?: boolean;
skipConversationRestore?: boolean;
}
export interface SessionBeforeCompactResult {
cancel?: boolean;
compaction?: CompactionResult;
}
export interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: {
summary: string;
details?: unknown;
};
}
// ============================================================================
// Message Rendering
// ============================================================================
export interface MessageRenderOptions {
expanded: boolean;
}
export type MessageRenderer<T = unknown> = (
message: CustomMessage<T>,
options: MessageRenderOptions,
theme: Theme,
) => Component | undefined;
// ============================================================================
// Command Registration
// ============================================================================
export interface RegisteredCommand {
name: string;
description?: string;
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
}
// ============================================================================
// Extension API
// ============================================================================
/** Handler function type for events */
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
export type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;
/**
* ExtensionAPI passed to extension factory functions.
*/
export interface ExtensionAPI {
// =========================================================================
// Module Access
// =========================================================================
/** File logger for error/warning/debug messages */
logger: typeof import("../logger").logger;
/** Injected @sinclair/typebox module for defining tool parameters */
typebox: typeof import("@sinclair/typebox");
/** Injected pi-coding-agent exports for accessing SDK utilities */
pi: typeof import("../../index.js");
// =========================================================================
// Event Subscription
// =========================================================================
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(
event: "session_before_switch",
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
): void;
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
on(
event: "session_before_branch",
handler: ExtensionHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>,
): void;
on(event: "session_branch", handler: ExtensionHandler<SessionBranchEvent>): void;
on(
event: "session_before_compact",
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
// =========================================================================
// Tool Registration
// =========================================================================
/** Register a tool that the LLM can call. */
registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
// =========================================================================
// Command, Shortcut, Flag Registration
// =========================================================================
/** Register a custom command. */
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
/** Register a keyboard shortcut. */
registerShortcut(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: ExtensionContext) => Promise<void> | void;
},
): void;
/** Register a CLI flag. */
registerFlag(
name: string,
options: {
description?: string;
type: "boolean" | "string";
default?: boolean | string;
},
): void;
/** Get the value of a registered CLI flag. */
getFlag(name: string): boolean | string | undefined;
// =========================================================================
// Message Rendering
// =========================================================================
/** Register a custom renderer for CustomMessageEntry. */
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
// =========================================================================
// Actions
// =========================================================================
/** Send a custom message to the session. */
sendMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void;
/** Append a custom entry to the session for state persistence (not sent to LLM). */
appendEntry<T = unknown>(customType: string, data?: T): void;
/** Execute a shell command. */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** Get the list of currently active tool names. */
getActiveTools(): string[];
/** Get all configured tools (built-in + extension tools). */
getAllTools(): string[];
/** Set the active tools by name. */
setActiveTools(toolNames: string[]): void;
/** Shared event bus for extension communication. */
events: EventBus;
}
/** Extension factory function type. */
export type ExtensionFactory = (pi: ExtensionAPI) => void;
// ============================================================================
// Loaded Extension Types
// ============================================================================
export interface RegisteredTool {
definition: ToolDefinition;
extensionPath: string;
}
export interface ExtensionFlag {
name: string;
description?: string;
type: "boolean" | "string";
default?: boolean | string;
extensionPath: string;
}
export interface ExtensionShortcut {
shortcut: KeyId;
description?: string;
handler: (ctx: ExtensionContext) => Promise<void> | void;
extensionPath: string;
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
export type SendMessageHandler = <T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
export type GetActiveToolsHandler = () => string[];
export type GetAllToolsHandler = () => string[];
export type SetActiveToolsHandler = (toolNames: string[]) => void;
/** Loaded extension with all registered items. */
export interface LoadedExtension {
path: string;
resolvedPath: string;
handlers: Map<string, HandlerFn[]>;
tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void;
setFlagValue: (name: string, value: boolean | string) => void;
}
/** Result of loading extensions. */
export interface LoadExtensionsResult {
extensions: LoadedExtension[];
errors: Array<{ path: string; error: string }>;
setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
}
// ============================================================================
// Extension Error
// ============================================================================
export interface ExtensionError {
extensionPath: string;
event: string;
error: string;
stack?: string;
}
@@ -0,0 +1,147 @@
/**
* Tool wrappers for extensions.
*/
import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import type { Theme } from "../../modes/interactive/theme/theme";
import type { ExtensionRunner } from "./runner";
import type { ExtensionContext, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types";
/**
* Wrap a RegisteredTool into an AgentTool.
*/
export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: () => ExtensionContext): AgentTool {
const { definition } = registeredTool;
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, onUpdate, getContext(), signal),
renderCall: definition.renderCall ? (args, theme) => definition.renderCall?.(args, theme as Theme) : undefined,
renderResult: definition.renderResult
? (result, options, theme) =>
definition.renderResult?.(
result,
{ expanded: options.expanded, isPartial: options.isPartial, spinnerFrame: options.spinnerFrame },
theme as Theme,
)
: undefined,
};
}
/**
* Wrap all registered tools into AgentTools.
*/
export function wrapRegisteredTools(
registeredTools: RegisteredTool[],
getContext: () => ExtensionContext,
): AgentTool[] {
return registeredTools.map((rt) => wrapRegisteredTool(rt, getContext));
}
/**
* Wrap a tool with extension callbacks for interception.
* - Emits tool_call event before execution (can block)
* - Emits tool_result event after execution (can modify result)
*/
export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: ExtensionRunner): AgentTool<any, T> {
return {
...tool,
execute: async (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<T>,
context?: AgentToolContext,
) => {
// Emit tool_call event - extensions can block execution
if (runner.hasHandlers("tool_call")) {
try {
const callResult = (await runner.emitToolCall({
type: "tool_call",
toolName: tool.name,
toolCallId,
input: params,
})) as ToolCallEventResult | undefined;
if (callResult?.block) {
const reason = callResult.reason || "Tool execution was blocked by an extension";
throw new Error(reason);
}
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
}
}
// Execute the actual tool
let result: { content: any; details: T };
let executionError: Error | undefined;
try {
result = await tool.execute(toolCallId, params, signal, onUpdate, context);
} catch (err) {
executionError = err instanceof Error ? err : new Error(String(err));
result = {
content: [{ type: "text", text: executionError.message }],
details: undefined as T,
};
}
// Emit tool_result event - extensions can modify the result and error status
if (runner.hasHandlers("tool_result")) {
const resultResult = (await runner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: !!executionError,
})) as ToolResultEventResult | undefined;
if (resultResult) {
const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content;
const modifiedDetails = (resultResult.details ?? result.details) as T;
// Extension can override error status
if (resultResult.isError === true && !executionError) {
// Extension marks a successful result as error
const textBlocks = (modifiedContent ?? []).filter((c): c is TextContent => c.type === "text");
const errorText =
textBlocks.map((t) => t.text).join("\n") || "Tool result marked as error by extension";
throw new Error(errorText);
}
if (resultResult.isError === false && executionError) {
// Extension clears the error - return success
return { content: modifiedContent, details: modifiedDetails };
}
// Error status unchanged, but content/details may be modified
if (executionError) {
throw executionError;
}
return { content: modifiedContent, details: modifiedDetails };
}
}
// No extension modification
if (executionError) {
throw executionError;
}
return result;
},
};
}
/**
* Wrap all tools with extension callbacks.
*/
export function wrapToolsWithExtensions<T>(tools: AgentTool<any, T>[], runner: ExtensionRunner): AgentTool<any, T>[] {
return tools.map((tool) => wrapToolWithExtensions(tool, runner));
}
@@ -268,7 +268,7 @@ export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
fromExtension: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
@@ -309,7 +309,7 @@ export interface SessionTreeEvent {
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook */
fromHook?: boolean;
fromExtension?: boolean;
}
/** Union of all session event types */
+10 -21
View File
@@ -14,27 +14,16 @@ export {
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor";
export type { CompactionResult } from "./compaction/index";
export {
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
type CustomToolsLoadResult,
type CustomToolUIContext,
discoverAndLoadCustomTools,
type ExecResult,
type LoadedCustomTool,
loadCustomTools,
type RenderResultOptions,
} from "./custom-tools/index";
export {
type HookAPI,
type HookContext,
type HookError,
type HookEvent,
type HookFactory,
HookRunner,
type HookUIContext,
loadHooks,
} from "./hooks/index";
discoverAndLoadExtensions,
type ExtensionAPI,
type ExtensionCommandContext,
type ExtensionContext,
type ExtensionFactory,
ExtensionRunner,
type ExtensionUIContext,
loadExtensionFromFactory,
type ToolDefinition,
} from "./extensions/index";
export {
createMCPManager,
discoverAndLoadMCPTools,
@@ -0,0 +1,199 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
DEFAULT_EDITOR_KEYBINDINGS,
type EditorAction,
type EditorKeybindingsConfig,
EditorKeybindingsManager,
type KeyId,
matchesKey,
setEditorKeybindings,
} from "@oh-my-pi/pi-tui";
import { getAgentDir } from "../config";
/**
* Application-level actions (coding agent specific).
*/
export type AppAction =
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "followUp";
/**
* All configurable actions.
*/
export type KeyAction = AppAction | EditorAction;
/**
* Full keybindings configuration (app + editor actions).
*/
export type KeybindingsConfig = {
[K in KeyAction]?: KeyId | KeyId[];
};
/**
* Default application keybindings.
*/
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
interrupt: "escape",
clear: "ctrl+c",
exit: "ctrl+d",
suspend: "ctrl+z",
cycleThinkingLevel: "shift+tab",
cycleModelForward: "ctrl+p",
cycleModelBackward: "shift+ctrl+p",
selectModel: "ctrl+l",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
externalEditor: "ctrl+g",
followUp: "alt+enter",
};
/**
* All default keybindings (app + editor).
*/
export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
...DEFAULT_EDITOR_KEYBINDINGS,
...DEFAULT_APP_KEYBINDINGS,
};
// App actions list for type checking
const APP_ACTIONS: AppAction[] = [
"interrupt",
"clear",
"exit",
"suspend",
"cycleThinkingLevel",
"cycleModelForward",
"cycleModelBackward",
"selectModel",
"expandTools",
"toggleThinking",
"externalEditor",
"followUp",
];
function isAppAction(action: string): action is AppAction {
return APP_ACTIONS.includes(action as AppAction);
}
/**
* Manages all keybindings (app + editor).
*/
export class KeybindingsManager {
private config: KeybindingsConfig;
private appActionToKeys: Map<AppAction, KeyId[]>;
private constructor(config: KeybindingsConfig) {
this.config = config;
this.appActionToKeys = new Map();
this.buildMaps();
}
/**
* Create from config file and set up editor keybindings.
*/
static async create(agentDir: string = getAgentDir()): Promise<KeybindingsManager> {
const configPath = join(agentDir, "keybindings.json");
const config = await KeybindingsManager.loadFromFile(configPath);
const manager = new KeybindingsManager(config);
// Set up editor keybindings globally
const editorConfig: EditorKeybindingsConfig = {};
for (const [action, keys] of Object.entries(config)) {
if (!isAppAction(action)) {
editorConfig[action as EditorAction] = keys;
}
}
setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
return manager;
}
/**
* Create in-memory.
*/
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(config);
}
private static async loadFromFile(path: string): Promise<KeybindingsConfig> {
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, "utf-8"));
} catch {
return {};
}
}
private buildMaps(): void {
this.appActionToKeys.clear();
// Set defaults for app actions
for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.appActionToKeys.set(action as AppAction, [...keyArray]);
}
// Override with user config (app actions only)
for (const [action, keys] of Object.entries(this.config)) {
if (keys === undefined || !isAppAction(action)) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.appActionToKeys.set(action, keyArray);
}
}
/**
* Check if input matches an app action.
*/
matches(data: string, action: AppAction): boolean {
const keys = this.appActionToKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Get keys bound to an app action.
*/
getKeys(action: AppAction): KeyId[] {
return this.appActionToKeys.get(action) ?? [];
}
/**
* Get display string for an action.
*/
getDisplayString(action: AppAction): string {
const keys = this.getKeys(action);
if (keys.length === 0) return "";
if (keys.length === 1) return keys[0]!;
return keys.join("/");
}
/**
* Get the full effective config.
*/
getEffectiveConfig(): Required<KeybindingsConfig> {
const result = { ...DEFAULT_KEYBINDINGS };
for (const [action, keys] of Object.entries(this.config)) {
if (keys !== undefined) {
(result as KeybindingsConfig)[action as KeyAction] = keys;
}
}
return result;
}
}
// Re-export for convenience
export type { EditorAction, KeyId };
+26 -7
View File
@@ -35,11 +35,24 @@ export interface BashExecutionMessage {
truncated: boolean;
fullOutputPath?: string;
timestamp: number;
/** If true, this message is excluded from LLM context (!! prefix) */
excludeFromContext?: boolean;
}
/**
* Message type for hook-injected messages via sendMessage().
* These are custom messages that hooks can inject into the conversation.
* Message type for extension-injected messages via sendMessage().
*/
export interface CustomMessage<T = unknown> {
role: "custom";
customType: string;
content: string | (TextContent | ImageContent)[];
display: boolean;
details?: T;
timestamp: number;
}
/**
* Legacy hook message type (pre-extensions). Kept for session migration.
*/
export interface HookMessage<T = unknown> {
role: "hookMessage";
@@ -78,9 +91,11 @@ export interface FileMentionMessage {
}
// Extend CustomAgentMessages via declaration merging
// Legacy hookMessage is kept for migration; new code should use custom.
declare module "@oh-my-pi/pi-agent-core" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
custom: CustomMessage;
hookMessage: HookMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
@@ -125,22 +140,22 @@ export function createCompactionSummaryMessage(
): CompactionSummaryMessage {
return {
role: "compactionSummary",
summary: summary,
summary,
tokensBefore,
timestamp: new Date(timestamp).getTime(),
};
}
/** Convert CustomMessageEntry to AgentMessage format */
export function createHookMessage(
export function createCustomMessage(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details: unknown | undefined,
timestamp: string,
): HookMessage {
): CustomMessage {
return {
role: "hookMessage",
role: "custom",
customType,
content,
display,
@@ -155,18 +170,22 @@ export function createHookMessage(
* This is used by:
* - Agent's transormToLlm option (for prompt calls and queued messages)
* - Compaction's generateSummary (for summarization)
* - Custom hooks and tools
* - Custom extensions and tools
*/
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
if (m.excludeFromContext) {
return undefined;
}
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
case "custom":
case "hookMessage": {
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
return {
+117 -47
View File
@@ -35,8 +35,10 @@ const ModelDefinitionSchema = Type.Object({
Type.Union([
Type.Literal("openai-completions"),
Type.Literal("openai-responses"),
Type.Literal("openai-codex-responses"),
Type.Literal("anthropic-messages"),
Type.Literal("google-generative-ai"),
Type.Literal("google-vertex"),
]),
),
reasoning: Type.Boolean(),
@@ -54,19 +56,21 @@ const ModelDefinitionSchema = Type.Object({
});
const ProviderConfigSchema = Type.Object({
baseUrl: Type.String({ minLength: 1 }),
apiKey: Type.String({ minLength: 1 }),
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
apiKey: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(
Type.Union([
Type.Literal("openai-completions"),
Type.Literal("openai-responses"),
Type.Literal("openai-codex-responses"),
Type.Literal("anthropic-messages"),
Type.Literal("google-generative-ai"),
Type.Literal("google-vertex"),
]),
),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
authHeader: Type.Optional(Type.Boolean()),
models: Type.Array(ModelDefinitionSchema),
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
});
const ModelsConfigSchema = Type.Object({
@@ -75,6 +79,27 @@ const ModelsConfigSchema = Type.Object({
type ModelsConfig = Static<typeof ModelsConfigSchema>;
/** Provider override config (baseUrl, headers, apiKey) without custom models */
interface ProviderOverride {
baseUrl?: string;
headers?: Record<string, string>;
apiKey?: string;
}
/** Result of loading custom models from models.json */
interface CustomModelsResult {
models: Model<Api>[];
/** Providers with custom models (full replacement) */
replacedProviders: Set<string>;
/** Providers with only baseUrl/headers override (no custom models) */
overrides: Map<string, ProviderOverride>;
error: string | undefined;
}
function emptyCustomModelsResult(error?: string): CustomModelsResult {
return { models: [], replacedProviders: new Set(), overrides: new Map(), error };
}
/**
* Resolve an API key config value to an actual key.
* Checks environment variable first, then treats as literal.
@@ -111,8 +136,7 @@ export class ModelRegistry {
}
return undefined;
});
// Load models
// Load models synchronously in constructor
this.loadModels();
}
@@ -133,15 +157,10 @@ export class ModelRegistry {
}
private loadModels(): void {
// Load built-in models
const builtInModels: Model<Api>[] = [];
for (const provider of getProviders()) {
const providerModels = getModels(provider as KnownProvider);
builtInModels.push(...(providerModels as Model<Api>[]));
}
// Load custom models from models.json (check primary path, then fallbacks)
// Load custom models from models.json first (to know which providers to skip/override)
let customModels: Model<Api>[] = [];
let replacedProviders: Set<string> = new Set();
let overrides: Map<string, ProviderOverride> = new Map();
const pathsToCheck = this.modelsJsonPath ? [this.modelsJsonPath, ...this.fallbackPaths] : this.fallbackPaths;
if (pathsToCheck.length > 0) {
@@ -157,11 +176,14 @@ export class ModelRegistry {
// Keep built-in models even if custom models failed to load
} else {
customModels = result.models;
replacedProviders = result.replacedProviders;
overrides = result.overrides;
}
break; // Use first existing file
}
}
const builtInModels = this.loadBuiltInModels(replacedProviders, overrides);
const combined = [...builtInModels, ...customModels];
// Update github-copilot base URL based on OAuth credentials
@@ -177,9 +199,27 @@ export class ModelRegistry {
}
}
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
/** Load built-in models, skipping replaced providers and applying overrides */
private loadBuiltInModels(replacedProviders: Set<string>, overrides: Map<string, ProviderOverride>): Model<Api>[] {
return getProviders()
.filter((provider) => !replacedProviders.has(provider))
.flatMap((provider) => {
const models = getModels(provider as KnownProvider) as Model<Api>[];
const override = overrides.get(provider);
if (!override) return models;
// Apply baseUrl/headers override to all models of this provider
return models.map((m) => ({
...m,
baseUrl: override.baseUrl ?? m.baseUrl,
headers: override.headers ? { ...m.headers, ...override.headers } : m.headers,
}));
});
}
private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
if (!existsSync(modelsJsonPath)) {
return { models: [], error: undefined };
return emptyCustomModelsResult();
}
try {
@@ -193,38 +233,68 @@ export class ModelRegistry {
const errors =
validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
"Unknown schema error";
return {
models: [],
error: `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`,
};
return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`);
}
// Additional validation
this.validateConfig(config);
// Parse models
return { models: this.parseModels(config), error: undefined };
// Separate providers into "full replacement" (has models) vs "override-only" (no models)
const replacedProviders = new Set<string>();
const overrides = new Map<string, ProviderOverride>();
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
if (providerConfig.models && providerConfig.models.length > 0) {
// Has custom models -> full replacement
replacedProviders.add(providerName);
} else {
// No models -> just override baseUrl/headers on built-in
overrides.set(providerName, {
baseUrl: providerConfig.baseUrl,
headers: providerConfig.headers,
apiKey: providerConfig.apiKey,
});
// Store API key for fallback resolver
if (providerConfig.apiKey) {
this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
}
}
}
return { models: this.parseModels(config), replacedProviders, overrides, error: undefined };
} catch (error) {
if (error instanceof SyntaxError) {
return {
models: [],
error: `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`,
};
return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`);
}
return {
models: [],
error: `Failed to load models.json: ${
error instanceof Error ? error.message : error
}\n\nFile: ${modelsJsonPath}`,
};
return emptyCustomModelsResult(
`Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
);
}
}
private validateConfig(config: ModelsConfig): void {
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
const hasProviderApi = !!providerConfig.api;
const models = providerConfig.models ?? [];
for (const modelDef of providerConfig.models) {
if (models.length === 0) {
// Override-only config: just needs baseUrl (to override built-in)
if (!providerConfig.baseUrl) {
throw new Error(
`Provider ${providerName}: must specify either "baseUrl" (for override) or "models" (for replacement).`,
);
}
} else {
// Full replacement: needs baseUrl and apiKey
if (!providerConfig.baseUrl) {
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
}
if (!providerConfig.apiKey) {
throw new Error(`Provider ${providerName}: "apiKey" is required when defining custom models.`);
}
}
for (const modelDef of models) {
const hasModelApi = !!modelDef.api;
if (!hasProviderApi && !hasModelApi) {
@@ -247,10 +317,15 @@ export class ModelRegistry {
const models: Model<Api>[] = [];
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
// Store API key config for fallback resolver
this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
const modelDefs = providerConfig.models ?? [];
if (modelDefs.length === 0) continue; // Override-only, no custom models
for (const modelDef of providerConfig.models) {
// Store API key config for fallback resolver
if (providerConfig.apiKey) {
this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
}
for (const modelDef of modelDefs) {
const api = modelDef.api || providerConfig.api;
if (!api) continue;
@@ -261,19 +336,20 @@ export class ModelRegistry {
: undefined;
// If authHeader is true, add Authorization header with resolved API key
if (providerConfig.authHeader) {
if (providerConfig.authHeader && providerConfig.apiKey) {
const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey);
if (resolvedKey) {
headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
}
}
// baseUrl is validated to exist for providers with models
models.push({
id: modelDef.id,
name: modelDef.name,
api: api as Api,
provider: providerName,
baseUrl: providerConfig.baseUrl,
baseUrl: providerConfig.baseUrl!,
reasoning: modelDef.reasoning,
input: modelDef.input as ("text" | "image")[],
cost: modelDef.cost,
@@ -297,17 +373,11 @@ export class ModelRegistry {
}
/**
* Get only models that have valid API keys available.
* Get only models that have auth configured.
* This is a fast check that doesn't refresh OAuth tokens.
*/
async getAvailable(): Promise<Model<Api>[]> {
const available: Model<Api>[] = [];
for (const model of this.models) {
const apiKey = await this.authStorage.getApiKey(model.provider);
if (apiKey) {
available.push(model);
}
}
return available;
getAvailable(): Model<Api>[] {
return this.models.filter((m) => this.authStorage.hasAuth(m.provider));
}
/**
@@ -13,9 +13,11 @@ import type { ModelRegistry } from "./model-registry";
export const defaultModelPerProvider: Record<KnownProvider, string> = {
anthropic: "claude-sonnet-4-5",
openai: "gpt-5.1-codex",
"openai-codex": "codex-max",
google: "gemini-2.5-pro",
"google-gemini-cli": "gemini-2.5-pro",
"google-antigravity": "gemini-3-pro-high",
"google-vertex": "gemini-2.5-pro",
"github-copilot": "gpt-4o",
openrouter: "openai/gpt-5.1-codex",
xai: "grok-4-fast-non-reasoning",
@@ -192,7 +194,7 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
* strips colon-suffixes to find a match.
*/
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
const availableModels = await modelRegistry.getAvailable();
const availableModels = modelRegistry.getAvailable();
const scopedModels: ScopedModel[] = [];
for (const pattern of patterns) {
@@ -321,7 +323,7 @@ export async function findInitialModel(options: {
}
// 4. Try first available model with valid API key
const availableModels = await modelRegistry.getAvailable();
const availableModels = modelRegistry.getAvailable();
if (availableModels.length > 0) {
// Try to find a default model from known providers
@@ -382,7 +384,7 @@ export async function restoreModelFromSession(
}
// Try to find any available model
const availableModels = await modelRegistry.getAvailable();
const availableModels = modelRegistry.getAvailable();
if (availableModels.length > 0) {
// Try to find a default model from known providers
@@ -427,7 +429,7 @@ export async function findSmolModel(
modelRegistry: ModelRegistry,
savedModel?: string,
): Promise<Model<Api> | undefined> {
const availableModels = await modelRegistry.getAvailable();
const availableModels = modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
// 1. Try saved model from settings
@@ -466,7 +468,7 @@ export async function findSlowModel(
modelRegistry: ModelRegistry,
savedModel?: string,
): Promise<Model<Api> | undefined> {
const availableModels = await modelRegistry.getAvailable();
const availableModels = modelRegistry.getAvailable();
if (availableModels.length === 0) return undefined;
// 1. Try saved model from settings
@@ -0,0 +1,242 @@
import { join, resolve } from "node:path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config";
/**
* Represents a prompt template loaded from a markdown file
*/
export interface PromptTemplate {
name: string;
description: string;
content: string;
source: string; // e.g., "(user)", "(project)", "(project:frontend)"
}
/**
* Parse YAML frontmatter from markdown content
* Returns { frontmatter, content } where content has frontmatter stripped
*/
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; content: string } {
const frontmatter: Record<string, string> = {};
if (!content.startsWith("---")) {
return { frontmatter, content };
}
const endIndex = content.indexOf("\n---", 3);
if (endIndex === -1) {
return { frontmatter, content };
}
const frontmatterBlock = content.slice(4, endIndex);
const remainingContent = content.slice(endIndex + 4).trim();
// Simple YAML parsing - just key: value pairs
for (const line of frontmatterBlock.split("\n")) {
const match = line.match(/^(\w+):\s*(.*)$/);
if (match) {
frontmatter[match[1]] = match[2].trim();
}
}
return { frontmatter, content: remainingContent };
}
/**
* Parse command arguments respecting quoted strings (bash-style)
* Returns array of arguments
*/
export function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (inQuote) {
if (char === inQuote) {
inQuote = null;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (char === " " || char === "\t") {
if (current) {
args.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
/**
* Substitute argument placeholders in template content
* Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args
*
* Note: Replacement happens on the template string only. Argument values
* containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.
*/
export function substituteArgs(content: string, args: string[]): string {
let result = content;
// Replace $1, $2, etc. with positional args FIRST (before wildcards)
// This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
result = result.replace(/\$(\d+)/g, (_, num) => {
const index = parseInt(num, 10) - 1;
return args[index] ?? "";
});
// Pre-compute all args joined (optimization)
const allArgs = args.join(" ");
// Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode)
result = result.replace(/\$ARGUMENTS/g, allArgs);
// Replace $@ with all args joined (existing syntax)
result = result.replace(/\$@/g, allArgs);
return result;
}
/**
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
*/
async function loadTemplatesFromDir(
dir: string,
source: "user" | "project",
subdir: string = "",
): Promise<PromptTemplate[]> {
const templates: PromptTemplate[] = [];
try {
const stat = await Bun.file(`${dir}/.`).exists();
if (!stat) return templates;
} catch {
return templates;
}
try {
const glob = new Bun.Glob("**/*");
const entries = [];
for await (const entry of glob.scan({ cwd: dir, absolute: false, onlyFiles: false })) {
entries.push(entry);
}
// Group by path depth to process directories before deeply nested files
entries.sort((a, b) => a.split("/").length - b.split("/").length);
for (const entry of entries) {
const fullPath = join(dir, entry);
const file = Bun.file(fullPath);
try {
const stat = await file.exists();
if (!stat) continue;
if (entry.endsWith(".md")) {
const rawContent = await file.text();
const { frontmatter, content } = parseFrontmatter(rawContent);
const name = entry.split("/").pop()!.slice(0, -3); // Remove .md extension
// Build source string based on subdirectory structure
const entryDir = entry.includes("/") ? entry.split("/").slice(0, -1).join(":") : "";
const fullSubdir = subdir && entryDir ? `${subdir}:${entryDir}` : entryDir || subdir;
let sourceStr: string;
if (source === "user") {
sourceStr = fullSubdir ? `(user:${fullSubdir})` : "(user)";
} else {
sourceStr = fullSubdir ? `(project:${fullSubdir})` : "(project)";
}
// Get description from frontmatter or first non-empty line
let description = frontmatter.description || "";
if (!description) {
const firstLine = content.split("\n").find((line) => line.trim());
if (firstLine) {
// Truncate if too long
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
}
// Append source to description
description = description ? `${description} ${sourceStr}` : sourceStr;
templates.push({
name,
description,
content,
source: sourceStr,
});
}
} catch (_error) {
// Silently skip files that can't be read
}
}
} catch (_error) {
// Silently skip directories that can't be read
}
return templates;
}
export interface LoadPromptTemplatesOptions {
/** Working directory for project-local templates. Default: process.cwd() */
cwd?: string;
/** Agent config directory for global templates. Default: from getPromptsDir() */
agentDir?: string;
}
/**
* Load all prompt templates from:
* 1. Global: agentDir/prompts/
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
*/
export async function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): Promise<PromptTemplate[]> {
const resolvedCwd = options.cwd ?? process.cwd();
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
const templates: PromptTemplate[] = [];
// 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...(await loadTemplatesFromDir(globalPromptsDir, "user")));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...(await loadTemplatesFromDir(projectPromptsDir, "project")));
return templates;
}
/**
* Expand a prompt template if it matches a template name.
* Returns the expanded content or the original text if not a template.
*/
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
if (!text.startsWith("/")) return text;
const spaceIndex = text.indexOf(" ");
const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const template = templates.find((t) => t.name === templateName);
if (template) {
const args = parseCommandArgs(argsString);
return substituteArgs(template.content, args);
}
return text;
}
+371 -309
View File
@@ -9,12 +9,9 @@
* // Minimal - everything auto-discovered
* const session = await createAgentSession();
*
* // With custom hooks
* // With custom extensions
* const session = await createAgentSession({
* hooks: [
* ...await discoverHooks(),
* { factory: myHookFactory },
* ],
* extensions: [myExtensionFactory],
* });
*
* // Full control
@@ -22,7 +19,7 @@
* model: myModel,
* getApiKey: async () => process.env.MY_KEY,
* tools: [readTool, bashTool],
* hooks: [],
* extensions: [],
* skills: [],
* sessionFile: false,
* });
@@ -30,37 +27,45 @@
*/
import { join } from "node:path";
import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import chalk from "chalk";
// Import discovery to register all providers on startup
import "../discovery";
import { loadSync as loadCapability } from "../capability/index";
import { type Rule, ruleCapability } from "../capability/rule";
import { getAgentDir, getConfigDirPaths } from "../config";
import { initializeWithSettings } from "../discovery";
import { AgentSession } from "./agent-session";
import { AuthStorage } from "./auth-storage";
import {
type CustomCommandsLoadResult,
loadCustomCommands as loadCustomCommandsInternal,
} from "./custom-commands/index";
import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./custom-tools/types";
import { createEventBus, type EventBus } from "./event-bus";
import {
type CustomToolsLoadResult,
discoverAndLoadCustomTools,
type LoadedCustomTool,
wrapCustomTools,
} from "./custom-tools/index";
import type { CustomTool } from "./custom-tools/types";
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index";
import type { HookFactory } from "./hooks/types";
discoverAndLoadExtensions,
type ExtensionContext,
type ExtensionFactory,
ExtensionRunner,
type LoadExtensionsResult,
type LoadedExtension,
loadExtensionFromFactory,
type ToolDefinition,
wrapRegisteredTools,
wrapToolsWithExtensions,
} from "./extensions/index";
import { logger } from "./logger";
import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp/index";
import { convertToLlm } from "./messages";
import { ModelRegistry } from "./model-registry";
import { parseModelString } from "./model-resolver";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates";
import { SessionManager } from "./session-manager";
import { type CommandsSettings, type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
import { loadSkills as loadSkillsInternal, type Skill } from "./skills";
import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
import {
buildSystemPrompt as buildSystemPromptInternal,
loadProjectContextFiles as loadContextFilesInternal,
@@ -71,8 +76,10 @@ import { getGeminiImageTools } from "./tools/gemini-image";
import {
allTools,
applyBashInterception,
baseCodingToolNames,
bashTool,
codingTools,
createAllTools,
createBashTool,
createCodingTools,
createEditTool,
@@ -87,12 +94,14 @@ import {
editTool,
filterRulebookRules,
findTool,
getWebSearchTools,
gitTool,
grepTool,
lsTool,
readOnlyTools,
readTool,
type Tool,
type ToolName,
warmupLspServers,
writeTool,
} from "./tools/index";
@@ -121,24 +130,29 @@ export interface CreateAgentSessionOptions {
/** System prompt. String replaces default, function receives default and returns final. */
systemPrompt?: string | ((defaultPrompt: string) => string);
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
/** Built-in tools to use. Default: all coding tools (read, bash, edit, write, grep, find, ls, lsp, notebook, output, task, web_fetch, web_search) */
tools?: Tool[];
/** Custom tools (replaces discovery). */
customTools?: Array<{ path?: string; tool: CustomTool }>;
/** Additional custom tool paths to load (merged with discovery). */
additionalCustomToolPaths?: string[];
/** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
customTools?: (CustomTool | ToolDefinition)[];
/** Inline extensions (merged with discovery). */
extensions?: ExtensionFactory[];
/** Additional extension paths to load (merged with discovery). */
additionalExtensionPaths?: string[];
/**
* Pre-loaded extensions (skips file discovery).
* @internal Used by CLI when extensions are loaded early to parse custom flags.
*/
preloadedExtensions?: LoadedExtension[];
/** Hooks (replaces discovery). */
hooks?: Array<{ path?: string; factory: HookFactory }>;
/** Additional hook paths to load (merged with discovery). */
additionalHookPaths?: string[];
/** Shared event bus for tool/extension communication. Default: creates new bus. */
eventBus?: EventBus;
/** Skills. Default: discovered from multiple locations */
skills?: Skill[];
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
contextFiles?: Array<{ path: string; content: string }>;
/** Slash commands. Default: discovered from cwd/.omp/commands/ + agentDir/commands/ */
slashCommands?: FileSlashCommand[];
/** Prompt templates. Default: discovered from cwd/.omp/prompts/ + agentDir/prompts/ */
promptTemplates?: PromptTemplate[];
/** Enable MCP server discovery from .mcp.json files. Default: true */
enableMCP?: boolean;
@@ -160,8 +174,8 @@ export interface CreateAgentSessionOptions {
export interface CreateAgentSessionResult {
/** The created session */
session: AgentSession;
/** Custom tools result (for UI context setup in interactive mode) */
customToolsResult: CustomToolsLoadResult;
/** Extensions result (for UI context setup in interactive mode) */
extensionsResult: LoadExtensionsResult;
/** MCP manager for server lifecycle management (undefined if MCP disabled) */
mcpManager?: MCPManager;
/** Warning if session was restored with a different model than saved */
@@ -173,12 +187,18 @@ export interface CreateAgentSessionResult {
// Re-exports
export type { CustomCommand, CustomCommandFactory } from "./custom-commands/types";
export type { CustomTool } from "./custom-tools/types";
export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types";
export type { CustomTool, CustomToolFactory } from "./custom-tools/types";
export type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
ExtensionFactory,
ToolDefinition,
} from "./extensions/index";
export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp/index";
export type { PromptTemplate } from "./prompt-templates";
export type { Settings, SkillsSettings } from "./settings-manager";
export type { Skill } from "./skills";
export type { FileSlashCommand } from "./slash-commands";
export type { Tool } from "./tools/index";
export {
@@ -219,7 +239,7 @@ function getDefaultAgentDir(): string {
* Create an AuthStorage instance with fallback support.
* Reads from primary path first, then falls back to legacy paths (.pi, .claude).
*/
export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): AuthStorage {
export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise<AuthStorage> {
const primaryPath = join(agentDir, "auth.json");
// Get all auth.json paths (user-level only), excluding the primary
const allPaths = getConfigDirPaths("auth.json", { project: false });
@@ -227,14 +247,19 @@ export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Au
logger.debug("discoverAuthStorage", { agentDir, primaryPath, allPaths, fallbackPaths });
return new AuthStorage(primaryPath, fallbackPaths);
const storage = new AuthStorage(primaryPath, fallbackPaths);
await storage.reload();
return storage;
}
/**
* Create a ModelRegistry with fallback support.
* Reads from primary path first, then falls back to legacy paths (.pi, .claude).
*/
export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry {
export async function discoverModels(
authStorage: AuthStorage,
agentDir: string = getDefaultAgentDir(),
): Promise<ModelRegistry> {
const primaryPath = join(agentDir, "models.json");
// Get all models.json paths (user-level only), excluding the primary
const allPaths = getConfigDirPaths("models.json", { project: false });
@@ -242,51 +267,18 @@ export function discoverModels(authStorage: AuthStorage, agentDir: string = getD
logger.debug("discoverModels", { primaryPath, fallbackPaths });
return new ModelRegistry(authStorage, primaryPath, fallbackPaths);
const registry = new ModelRegistry(authStorage, primaryPath, fallbackPaths);
await registry.refresh();
return registry;
}
/**
* Discover hooks from cwd and agentDir.
* Discover extensions from cwd.
*/
export async function discoverHooks(
cwd?: string,
_agentDir?: string,
): Promise<Array<{ path: string; factory: HookFactory }>> {
export async function discoverExtensions(cwd?: string): Promise<LoadExtensionsResult> {
const resolvedCwd = cwd ?? process.cwd();
const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd);
// Log errors but don't fail
for (const { path, error } of errors) {
console.error(`Failed to load hook "${path}": ${error}`);
}
return hooks.map((h) => ({
path: h.path,
factory: createFactoryFromLoadedHook(h),
}));
}
/**
* Discover custom tools from cwd and agentDir.
*/
export async function discoverCustomTools(
cwd?: string,
_agentDir?: string,
): Promise<Array<{ path: string; tool: CustomTool }>> {
const resolvedCwd = cwd ?? process.cwd();
const { tools, errors } = await discoverAndLoadCustomTools([], resolvedCwd, Object.keys(allTools));
// Log errors but don't fail
for (const { path, error } of errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
}
return tools.map((t) => ({
path: t.path,
tool: t.tool,
}));
return discoverAndLoadExtensions([], resolvedCwd);
}
/**
@@ -314,15 +306,12 @@ export function discoverContextFiles(
}
/**
* Discover slash commands from cwd and agentDir.
* Discover prompt templates from cwd and agentDir.
*/
export function discoverSlashCommands(
cwd?: string,
_agentDir?: string,
_settings?: CommandsSettings,
): FileSlashCommand[] {
return loadSlashCommandsInternal({
export async function discoverPromptTemplates(cwd?: string, agentDir?: string): Promise<PromptTemplate[]> {
return await loadPromptTemplatesInternal({
cwd: cwd ?? process.cwd(),
agentDir: agentDir ?? getDefaultAgentDir(),
});
}
@@ -382,15 +371,16 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
return {
modelRoles: manager.getModelRoles(),
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
queueMode: manager.getQueueMode(),
steeringMode: manager.getSteeringMode(),
followUpMode: manager.getFollowUpMode(),
interruptMode: manager.getInterruptMode(),
theme: manager.getTheme(),
compaction: manager.getCompactionSettings(),
retry: manager.getRetrySettings(),
hideThinkingBlock: manager.getHideThinkingBlock(),
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
hooks: manager.getHookPaths(),
customTools: manager.getCustomToolPaths(),
extensions: manager.getExtensionPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
};
@@ -398,84 +388,85 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
// Internal Helpers
/**
* Create a HookFactory from a LoadedHook.
* This allows mixing discovered hooks with inline hooks.
*/
function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
return (api) => {
for (const [eventType, handlers] of loaded.handlers) {
for (const handler of handlers) {
api.on(eventType as any, handler as any);
}
}
function createCustomToolContext(ctx: ExtensionContext): CustomToolContext {
return {
sessionManager: ctx.sessionManager,
modelRegistry: ctx.modelRegistry,
model: ctx.model,
isIdle: ctx.isIdle,
hasQueuedMessages: ctx.hasPendingMessages,
abort: ctx.abort,
};
}
/**
* Convert hook definitions to LoadedHooks for the HookRunner.
*/
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
return definitions.map((def) => {
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>();
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({
cancelled: false,
});
function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
// To distinguish, we mark converted tools with a hidden symbol property.
// If the tool doesn't have this marker, it's a CustomTool that needs conversion.
return !(tool as any).__isToolDefinition;
}
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
sendMessage: (message: any, triggerTurn?: boolean) => {
sendMessageHandler(message, triggerTurn);
},
appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data);
},
registerMessageRenderer: (customType: string, renderer: any) => {
messageRenderers.set(customType, renderer);
},
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
newSession: (options?: any) => newSessionHandler(options),
branch: (entryId: string) => branchHandler(entryId),
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
function customToolToDefinition(tool: CustomTool): ToolDefinition {
const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters,
hidden: tool.hidden,
execute: (toolCallId, params, onUpdate, ctx, signal) =>
tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal),
onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,
renderCall: tool.renderCall,
renderResult: tool.renderResult
? (result, options, theme): Component => {
const component = tool.renderResult?.(
result,
{ expanded: options.expanded, isPartial: options.isPartial, spinnerFrame: options.spinnerFrame },
theme,
);
// Return empty component if undefined to match Component type requirement
return component ?? ({ render: () => [] } as unknown as Component);
}
: undefined,
[TOOL_DEFINITION_MARKER]: true,
};
return definition;
}
function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
return (api) => {
for (const tool of tools) {
api.registerTool(customToolToDefinition(tool));
}
const runOnSession = async (event: CustomToolSessionEvent, ctx: ExtensionContext) => {
for (const tool of tools) {
if (!tool.onSession) continue;
try {
await tool.onSession(event, createCustomToolContext(ctx));
} catch (err) {
logger.warn("Custom tool onSession error", { tool: tool.name, error: String(err) });
}
}
};
def.factory(api as any);
return {
path: def.path ?? "<inline>",
resolvedPath: def.path ?? "<inline>",
handlers,
messageRenderers,
commands,
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
appendEntryHandler = handler;
},
setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => {
newSessionHandler = handler;
},
setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => {
branchHandler = handler;
},
setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => {
navigateTreeHandler = handler;
},
};
});
api.on("session_start", async (_event, ctx) =>
runOnSession({ reason: "start", previousSessionFile: undefined }, ctx),
);
api.on("session_switch", async (event, ctx) =>
runOnSession({ reason: "switch", previousSessionFile: event.previousSessionFile }, ctx),
);
api.on("session_branch", async (event, ctx) =>
runOnSession({ reason: "branch", previousSessionFile: event.previousSessionFile }, ctx),
);
api.on("session_tree", async (_event, ctx) =>
runOnSession({ reason: "tree", previousSessionFile: undefined }, ctx),
);
api.on("session_shutdown", async (_event, ctx) =>
runOnSession({ reason: "shutdown", previousSessionFile: undefined }, ctx),
);
};
}
// Factory
@@ -506,7 +497,6 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
* getApiKey: async () => process.env.MY_KEY,
* systemPrompt: 'You are helpful.',
* tools: [readTool, bashTool],
* hooks: [],
* skills: [],
* sessionManager: SessionManager.inMemory(),
* });
@@ -515,17 +505,15 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir();
const eventBus = options.eventBus ?? createEventBus();
// Use provided or create AuthStorage and ModelRegistry
const authStorage = options.authStorage ?? discoverAuthStorage(agentDir);
const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir);
const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir));
time("discoverModels");
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
time("settingsManager");
// Initialize discovery system with settings for provider persistence
const { initializeWithSettings } = await import("../discovery");
initializeWithSettings(settingsManager);
time("initializeWithSettings");
@@ -543,17 +531,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// If session has data, try to restore model from it
const defaultModelStr = existingSession.models.default;
if (!model && hasExistingSession && defaultModelStr) {
const slashIdx = defaultModelStr.indexOf("/");
if (slashIdx > 0) {
const provider = defaultModelStr.slice(0, slashIdx);
const modelId = defaultModelStr.slice(slashIdx + 1);
const restoredModel = modelRegistry.find(provider, modelId);
const parsedModel = parseModelString(defaultModelStr);
if (parsedModel) {
const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
model = restoredModel;
}
if (!model) {
modelFallbackMessage = `Could not restore model ${defaultModelStr}`;
}
}
if (!model) {
modelFallbackMessage = `Could not restore model ${defaultModelStr}`;
}
}
@@ -561,11 +547,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!model) {
const settingsDefaultModel = settingsManager.getModelRole("default");
if (settingsDefaultModel) {
const slashIdx = settingsDefaultModel.indexOf("/");
if (slashIdx > 0) {
const provider = settingsDefaultModel.slice(0, slashIdx);
const modelId = settingsDefaultModel.slice(slashIdx + 1);
const settingsModel = modelRegistry.find(provider, modelId);
const parsedModel = parseModelString(settingsDefaultModel);
if (parsedModel) {
const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) {
model = settingsModel;
}
@@ -629,68 +613,35 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
time("discoverContextFiles");
// Hook runner - always created (needed for custom command context even without hooks)
let loadedHooks: LoadedHook[] = [];
if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
}
} else {
// Discover hooks, merging with additional paths
const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])];
const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd);
time("discoverAndLoadHooks");
for (const { path, error } of errors) {
console.error(`Failed to load hook "${path}": ${error}`);
}
loadedHooks = hooks;
}
const hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
const sessionContext = {
getSessionFile: () => sessionManager.getSessionFile() ?? null,
};
const builtInTools =
options.tools ??
createCodingTools(cwd, options.hasUI ?? false, sessionContext, {
lspFormatOnWrite: settingsManager.getLspFormatOnWrite(),
lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(),
lspDiagnosticsOnEdit: settingsManager.getLspDiagnosticsOnEdit(),
editFuzzyMatch: settingsManager.getEditFuzzyMatch(),
});
time("createCodingTools");
const allBuiltInToolsMap = await createAllTools(cwd, sessionContext, {
lspFormatOnWrite: settingsManager.getLspFormatOnWrite(),
lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(),
lspDiagnosticsOnEdit: settingsManager.getLspDiagnosticsOnEdit(),
editFuzzyMatch: settingsManager.getEditFuzzyMatch(),
readAutoResizeImages: settingsManager.getImageAutoResize(),
});
time("createAllTools");
let customToolsResult: CustomToolsLoadResult;
if (options.customTools !== undefined) {
// Use provided custom tools
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
path: ct.path ?? "<inline>",
resolvedPath: ct.path ?? "<inline>",
tool: ct.tool,
}));
customToolsResult = {
tools: loadedTools,
errors: [],
setUIContext: () => {},
};
} else {
// Discover custom tools, merging with additional paths
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools));
time("discoverAndLoadCustomTools");
for (const { path, error } of customToolsResult.errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
}
}
const initialActiveToolNames: ToolName[] = options.tools
? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap)
: baseCodingToolNames;
const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
// Discover MCP tools from .mcp.json files
let mcpManager: MCPManager | undefined;
const enableMCP = options.enableMCP ?? true;
const customTools: CustomTool[] = [];
if (enableMCP) {
const mcpResult = await discoverAndLoadMCPTools(cwd, {
onConnecting: (serverNames) => {
if (options.hasUI && serverNames.length > 0) {
process.stderr.write(chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}...\n`));
process.stderr.write(
chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}...
`),
);
}
},
enableProjectConfig: settingsManager.getMCPProjectConfigEnabled(),
@@ -710,19 +661,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
console.error(`MCP "${path}": ${error}`);
}
// Merge MCP tools into custom tools result
if (mcpResult.tools.length > 0) {
customToolsResult = {
...customToolsResult,
tools: [...customToolsResult.tools, ...mcpResult.tools],
};
// MCP tools are LoadedCustomTool, extract the tool property
customTools.push(...mcpResult.tools.map((loaded) => loaded.tool));
}
}
// Add Gemini image tools if GEMINI_API_KEY (or GOOGLE_API_KEY) is available
const geminiImageTools = await getGeminiImageTools();
if (geminiImageTools.length > 0) {
customTools.push(...(geminiImageTools as unknown as CustomTool[]));
}
time("getGeminiImageTools");
// Add specialized Exa web search tools if EXA_API_KEY is available
const exaSettings = settingsManager.getExaSettings();
if (exaSettings.enabled && exaSettings.enableSearch) {
const { getWebSearchTools } = await import("./tools/web-search/index.js");
const exaWebSearchTools = await getWebSearchTools({
enableLinkedin: exaSettings.enableLinkedin,
enableCompany: exaSettings.enableCompany,
@@ -730,34 +684,83 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Filter out the base web_search (already in built-in tools), add specialized Exa tools
const specializedTools = exaWebSearchTools.filter((t) => t.name !== "web_search");
if (specializedTools.length > 0) {
const loadedExaTools: LoadedCustomTool[] = specializedTools.map((tool) => ({
path: "<exa>",
resolvedPath: "<exa>",
tool,
source: { provider: "builtin", providerName: "builtin", level: "user" },
}));
customToolsResult = {
...customToolsResult,
tools: [...customToolsResult.tools, ...loadedExaTools],
};
customTools.push(...specializedTools);
}
time("getWebSearchTools");
}
// Add Gemini image tools if GEMINI_API_KEY (or GOOGLE_API_KEY) is available
const geminiImageTools = await getGeminiImageTools();
if (geminiImageTools.length > 0) {
const loadedGeminiTools: LoadedCustomTool[] = geminiImageTools.map((tool) => ({
path: "<gemini-image>",
resolvedPath: "<gemini-image>",
tool: tool as unknown as CustomTool,
source: { provider: "builtin", providerName: "builtin", level: "user" },
}));
customToolsResult = {
...customToolsResult,
tools: [...customToolsResult.tools, ...loadedGeminiTools],
const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
if (customTools.length > 0) {
inlineExtensions.push(createCustomToolsExtension(customTools));
}
// Load extensions (discovers from standard locations + configured paths)
let extensionsResult: LoadExtensionsResult;
if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 0) {
extensionsResult = {
extensions: options.preloadedExtensions,
errors: [],
setUIContext: () => {},
};
time("getGeminiImageTools");
} else {
// Merge CLI extension paths with settings extension paths
const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...settingsManager.getExtensionPaths()];
extensionsResult = await discoverAndLoadExtensions(
configuredPaths,
cwd,
eventBus,
settingsManager.getDisabledExtensions(),
);
time("discoverAndLoadExtensions");
for (const { path, error } of extensionsResult.errors) {
console.error(`Failed to load extension "${path}": ${error}`);
}
}
// Load inline extensions from factories
if (inlineExtensions.length > 0) {
const uiHolder: { ui: any; hasUI: boolean } = {
ui: {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return {} as any;
},
},
hasUI: false,
};
for (let i = 0; i < inlineExtensions.length; i++) {
const factory = inlineExtensions[i];
const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, `<inline-${i}>`);
extensionsResult.extensions.push(loaded);
}
const originalSetUIContext = extensionsResult.setUIContext;
extensionsResult.setUIContext = (uiContext, hasUI) => {
originalSetUIContext(uiContext, hasUI);
uiHolder.ui = uiContext;
uiHolder.hasUI = hasUI;
};
}
// Discover custom commands (TypeScript slash commands)
const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir });
time("discoverCustomCommands");
for (const { path, error } of customCommandsResult.errors) {
console.error(`Failed to load custom command "${path}": ${error}`);
}
let extensionRunner: ExtensionRunner | undefined;
if (extensionsResult.extensions.length > 0) {
extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
}
let agent: Agent;
@@ -773,90 +776,148 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
});
const toolContextStore = createToolContextStore(getSessionContext);
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, getSessionContext);
const baseSetUIContext = customToolsResult.setUIContext;
customToolsResult = {
...customToolsResult,
setUIContext: (uiContext, hasUI) => {
toolContextStore.setUIContext(uiContext, hasUI);
baseSetUIContext(uiContext, hasUI);
const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
const allCustomTools = [
...registeredTools,
...(options.customTools?.map((tool) => {
const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool;
return { definition, extensionPath: "<sdk>" };
}) ?? []),
];
const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, () => ({
ui: extensionRunner?.getUIContext() ?? {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setTitle: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return {} as any;
},
},
};
hasUI: extensionRunner?.getHasUI() ?? false,
cwd,
sessionManager,
modelRegistry,
model: agent.state.model,
isIdle: () => !session.isStreaming,
abort: () => {
session.abort();
},
hasPendingMessages: () => session.queuedMessageCount > 0,
hasQueuedMessages: () => session.queuedMessageCount > 0,
}));
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
// Add rulebook tool if there are rules with descriptions (always enabled, regardless of --tools)
if (rulebookRules.length > 0) {
allToolsArray.push(createRulebookTool(rulebookRules));
const toolRegistry = new Map<string, AgentTool>();
for (const [name, tool] of Object.entries(allBuiltInToolsMap)) {
toolRegistry.set(name, tool as AgentTool);
}
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedExtensionTools];
if (rulebookRules.length > 0) {
activeToolsArray.push(createRulebookTool(rulebookRules));
}
// Filter out hidden tools unless explicitly requested
if (options.explicitTools) {
const explicitSet = new Set(options.explicitTools);
allToolsArray = allToolsArray.filter((tool) => !tool.hidden || explicitSet.has(tool.name));
activeToolsArray = activeToolsArray.filter((tool) => !tool.hidden || explicitSet.has(tool.name));
} else {
allToolsArray = allToolsArray.filter((tool) => !tool.hidden);
activeToolsArray = activeToolsArray.filter((tool) => !tool.hidden);
}
time("combineTools");
// Apply bash interception to redirect common shell patterns to proper tools (if enabled)
if (settingsManager.getBashInterceptorEnabled()) {
allToolsArray = applyBashInterception(allToolsArray);
activeToolsArray = applyBashInterception(activeToolsArray);
}
time("applyBashInterception");
if (hookRunner) {
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
if (extensionRunner) {
activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner);
const allRegistryTools = Array.from(toolRegistry.values());
const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner);
wrappedToolRegistry = new Map<string, AgentTool>();
for (const tool of wrappedAllTools) {
wrappedToolRegistry.set(tool.name, tool);
}
}
let systemPrompt: string;
const defaultPrompt = buildSystemPromptInternal({
cwd,
skills,
contextFiles,
rulebookRules,
});
time("buildSystemPrompt");
if (options.systemPrompt === undefined) {
systemPrompt = defaultPrompt;
} else if (typeof options.systemPrompt === "string") {
systemPrompt = buildSystemPromptInternal({
const rebuildSystemPrompt = (toolNames: string[]): string => {
const validToolNames = toolNames.filter((n): n is ToolName => n in allBuiltInToolsMap);
const extraToolDescriptions = toolNames
.filter((name) => !(name in allBuiltInToolsMap))
.map((name) => {
const tool = toolRegistry.get(name);
if (!tool) return null;
return { name, description: tool.description || tool.label || "Custom tool" };
})
.filter((tool): tool is { name: string; description: string } => tool !== null);
const defaultPrompt = buildSystemPromptInternal({
cwd,
skills,
contextFiles,
rulebookRules,
customPrompt: options.systemPrompt,
selectedTools: validToolNames,
extraToolDescriptions,
skillsSettings: settingsManager.getSkillsSettings(),
});
} else {
systemPrompt = options.systemPrompt(defaultPrompt);
}
const commandsSettings = settingsManager.getCommandsSettings();
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir, commandsSettings);
time("discoverSlashCommands");
if (options.systemPrompt === undefined) {
return defaultPrompt;
}
if (typeof options.systemPrompt === "string") {
return buildSystemPromptInternal({
cwd,
skills,
contextFiles,
rulebookRules,
selectedTools: validToolNames,
extraToolDescriptions,
skillsSettings: settingsManager.getSkillsSettings(),
customPrompt: options.systemPrompt,
});
}
return options.systemPrompt(defaultPrompt);
};
// Discover custom commands (TypeScript slash commands)
const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir });
time("discoverCustomCommands");
for (const { path, error } of customCommandsResult.errors) {
console.error(`Failed to load custom command "${path}": ${error}`);
}
const systemPrompt = rebuildSystemPrompt(initialActiveToolNames);
time("buildSystemPrompt");
const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir));
time("discoverPromptTemplates");
const baseSetUIContext = extensionsResult.setUIContext;
extensionsResult.setUIContext = (uiContext, hasUI) => {
baseSetUIContext(uiContext, hasUI);
toolContextStore.setUIContext(uiContext, hasUI);
};
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel,
tools: allToolsArray,
tools: activeToolsArray,
},
convertToLlm,
transformContext: hookRunner
transformContext: extensionRunner
? async (messages) => {
return hookRunner.emitContext(messages);
return extensionRunner.emitContext(messages);
}
: undefined,
queueMode: settingsManager.getQueueMode(),
steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(),
interruptMode: settingsManager.getInterruptMode(),
getToolContext: toolContextStore.getContext,
getApiKey: async () => {
@@ -889,12 +950,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
sessionManager,
settingsManager,
scopedModels: options.scopedModels,
fileCommands: slashCommands,
hookRunner,
customTools: customToolsResult.tools,
promptTemplates,
extensionRunner,
customCommands: customCommandsResult.commands,
skillsSettings: settingsManager.getSkillsSettings(),
modelRegistry,
toolRegistry: wrappedToolRegistry ?? toolRegistry,
rebuildSystemPrompt,
ttsrManager,
});
time("createAgentSession");
@@ -913,7 +975,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return {
session,
customToolsResult,
extensionsResult,
mcpManager,
modelFallbackMessage,
lspServers,
@@ -1,19 +1,16 @@
import {
appendFileSync,
closeSync,
createWriteStream,
existsSync,
fsyncSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
readSync,
renameSync,
statSync,
unlinkSync,
type WriteStream,
writeFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
@@ -23,13 +20,14 @@ import sharp from "sharp";
import { getAgentDir as getDefaultAgentDir } from "../config";
import {
type BashExecutionMessage,
type CustomMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createHookMessage,
createCustomMessage,
type HookMessage,
} from "./messages";
export const CURRENT_SESSION_VERSION = 2;
export const CURRENT_SESSION_VERSION = 3;
export interface SessionHeader {
type: "session";
@@ -75,27 +73,27 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
/** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
fromHook?: boolean;
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
fromExtension?: boolean;
}
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
type: "branch_summary";
fromId: string;
summary: string;
/** Hook-specific data (not sent to LLM) */
/** Extension-specific data (not sent to LLM) */
details?: T;
/** True if generated by a hook, false if pi-generated */
fromHook?: boolean;
/** True if generated by an extension, false if pi-generated */
fromExtension?: boolean;
}
/**
* Custom entry for hooks to store hook-specific data in the session.
* Use customType to identify your hook's entries.
* Custom entry for extensions to store extension-specific data in the session.
* Use customType to identify your extension's entries.
*
* Purpose: Persist hook state across session reloads. On reload, hooks can
* Purpose: Persist extension state across session reloads. On reload, extensions can
* scan entries for their customType and reconstruct internal state.
*
* Does NOT participate in LLM context (ignored by buildSessionContext).
@@ -122,12 +120,12 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
}
/**
* Custom message entry for hooks to inject messages into LLM context.
* Use customType to identify your hook's entries.
* Custom message entry for extensions to inject messages into LLM context.
* Use customType to identify your extension's entries.
*
* Unlike CustomEntry, this DOES participate in LLM context.
* The content is converted to a user message in buildSessionContext().
* Use details for hook-specific metadata (not sent to LLM).
* Use details for extension-specific metadata (not sent to LLM).
*
* display controls TUI rendering:
* - false: hidden entirely
@@ -239,8 +237,22 @@ function migrateV1ToV2(entries: FileEntry[]): void {
}
}
// Add future migrations here:
// function migrateV2ToV3(entries: FileEntry[]): void { ... }
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
function migrateV2ToV3(entries: FileEntry[]): void {
for (const entry of entries) {
if (entry.type === "session") {
entry.version = 3;
continue;
}
if (entry.type === "message") {
const msg = entry.message as { role?: string };
if (msg.role === "hookMessage") {
(entry.message as { role: string }).role = "custom";
}
}
}
}
/**
* Run all necessary migrations to bring entries to current version.
@@ -253,7 +265,7 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
if (version >= CURRENT_SESSION_VERSION) return false;
if (version < 2) migrateV1ToV2(entries);
// if (version < 3) migrateV2ToV3(entries);
if (version < 3) migrateV2ToV3(entries);
return true;
}
@@ -380,7 +392,7 @@ export function buildSessionContext(
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
@@ -438,7 +450,7 @@ function getDefaultSessionDir(cwd: string): string {
export function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, "utf8");
const content = readFileSync(filePath, "utf-8");
const entries: FileEntry[] = [];
const lines = content.trim().split("\n");
@@ -535,10 +547,9 @@ function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
return header;
};
return readdirSync(sessionDir)
return Array.from(new Bun.Glob("*.jsonl").scanSync(sessionDir))
.map((f) => {
try {
if (!f.endsWith(".jsonl")) return null;
const path = join(sessionDir, f);
const fd = openSync(path, "r");
try {
@@ -927,8 +938,8 @@ export class SessionManager {
}
/** Initialize with a specific session file (used by factory methods) */
private async _initSessionFile(sessionFile: string): Promise<void> {
await this.setSessionFile(sessionFile);
private _initSessionFile(sessionFile: string): void {
this.setSessionFile(sessionFile);
}
/** Initialize with a new session (used by factory methods) */
@@ -937,23 +948,25 @@ export class SessionManager {
}
/** Switch to a different session file (used for resume and branching) */
async setSessionFile(sessionFile: string): Promise<void> {
await this._closePersistWriter();
setSessionFile(sessionFile: string): void {
void this._closePersistWriter();
this.persistError = undefined;
this.persistErrorReported = false;
this.sessionFile = resolve(sessionFile);
if (existsSync(this.sessionFile)) {
this.fileEntries = loadEntriesFromFile(this.sessionFile);
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
this.sessionId = header?.id ?? nanoid();
this.sessionTitle = header?.title;
void (async () => {
this.fileEntries = await loadEntriesFromFile(this.sessionFile!);
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
this.sessionId = header?.id ?? nanoid();
this.sessionTitle = header?.title;
if (migrateToCurrentVersion(this.fileEntries)) {
await this._rewriteFile();
}
if (migrateToCurrentVersion(this.fileEntries)) {
await this._rewriteFile();
}
this._buildIndex();
this.flushed = true;
this._buildIndex();
this.flushed = true;
})();
} else {
this._newSessionSync();
}
@@ -1178,7 +1191,7 @@ export class SessionManager {
fileHeader.title = title;
lines[0] = JSON.stringify(fileHeader);
const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`);
writeFileSync(tempPath, lines.join("\n"));
await Bun.write(tempPath, lines.join("\n"));
const fd = openSync(tempPath, "r");
try {
fsyncSync(fd);
@@ -1265,7 +1278,7 @@ export class SessionManager {
* so it is easier to find them.
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
*/
appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage): string {
const entry: SessionMessageEntry = {
type: "message",
id: generateId(this.byId),
@@ -1314,7 +1327,7 @@ export class SessionManager {
firstKeptEntryId: string,
tokensBefore: number,
details?: T,
fromHook?: boolean,
fromExtension?: boolean,
): string {
const entry: CompactionEntry<T> = {
type: "compaction",
@@ -1325,13 +1338,13 @@ export class SessionManager {
firstKeptEntryId,
tokensBefore,
details,
fromHook,
fromExtension,
};
this._appendEntry(entry);
return entry.id;
}
/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
appendCustomEntry(customType: string, data?: unknown): string {
const entry: CustomEntry = {
type: "custom",
@@ -1346,11 +1359,11 @@ export class SessionManager {
}
/**
* Append a custom message entry (for hooks) that participates in LLM context.
* Append a custom message entry (for extensions) that participates in LLM context.
* @param customType Hook identifier for filtering on reload
* @param content Message content (string or TextContent/ImageContent array)
* @param display Whether to show in TUI (true = styled display, false = hidden)
* @param details Optional hook-specific metadata (not sent to LLM)
* @param details Optional extension-specific metadata (not sent to LLM)
* @returns Entry id
*/
appendCustomMessageEntry<T = unknown>(
@@ -1589,7 +1602,7 @@ export class SessionManager {
* Same as branch(), but also appends a branch_summary entry that captures
* context from the abandoned conversation path.
*/
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
if (branchFromId !== null && !this.byId.has(branchFromId)) {
throw new Error(`Entry ${branchFromId} not found`);
}
@@ -1602,7 +1615,7 @@ export class SessionManager {
fromId: branchFromId ?? "root",
summary,
details,
fromHook,
fromExtension,
};
this._appendEntry(entry);
return entry.id;
@@ -1646,9 +1659,11 @@ export class SessionManager {
}
if (this.persist) {
appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
const file = Bun.file(newSessionFile);
const writer = file.writer();
writer.write(`${JSON.stringify(header)}\n`);
for (const entry of pathWithoutLabels) {
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
writer.write(`${JSON.stringify(entry)}\n`);
}
// Write fresh label entries at the end
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
@@ -1663,11 +1678,12 @@ export class SessionManager {
targetId,
label,
};
appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
writer.write(`${JSON.stringify(labelEntry)}\n`);
pathEntryIds.add(labelEntry.id);
labelEntries.push(labelEntry);
parentId = labelEntry.id;
}
writer.end();
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.sessionId = newSessionId;
this._buildIndex();
@@ -1714,13 +1730,13 @@ export class SessionManager {
*/
static async open(path: string, sessionDir?: string): Promise<SessionManager> {
// Extract cwd from session header if possible, otherwise use process.cwd()
const entries = loadEntriesFromFile(path);
const entries = await loadEntriesFromFile(path);
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
const cwd = header?.cwd ?? process.cwd();
// If no sessionDir provided, derive from file's parent directory
const dir = sessionDir ?? resolve(path, "..");
const manager = new SessionManager(cwd, dir, true);
await manager._initSessionFile(path);
manager._initSessionFile(path);
return manager;
}
@@ -1729,12 +1745,12 @@ export class SessionManager {
* @param cwd Working directory
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
*/
static async continueRecent(cwd: string, sessionDir?: string): Promise<SessionManager> {
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const mostRecent = findMostRecentSession(dir);
const manager = new SessionManager(cwd, dir, true);
if (mostRecent) {
await manager._initSessionFile(mostRecent);
manager._initSessionFile(mostRecent);
} else {
manager._initNewSession();
}
@@ -1758,13 +1774,11 @@ export class SessionManager {
const sessions: SessionInfo[] = [];
try {
const files = readdirSync(dir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(dir, f));
const files = Array.from(new Bun.Glob("*.jsonl").scanSync(dir)).map((f) => join(dir, f));
for (const file of files) {
try {
const content = readFileSync(file, "utf8");
const content = readFileSync(file, "utf-8");
const lines = content.trim().split("\n");
if (lines.length === 0) continue;
@@ -42,6 +42,10 @@ export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
}
export interface ImageSettings {
autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
}
export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off";
export interface NotificationSettings {
@@ -140,7 +144,9 @@ export interface Settings {
/** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
modelRoles?: Record<string, string>;
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
queueMode?: "all" | "one-at-a-time";
steeringMode?: "all" | "one-at-a-time";
followUpMode?: "all" | "one-at-a-time";
queueMode?: "all" | "one-at-a-time"; // legacy
interruptMode?: "immediate" | "wait";
theme?: string;
symbolPreset?: SymbolPreset; // default: uses theme's preset or "unicode"
@@ -150,11 +156,12 @@ export interface Settings {
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
hooks?: string[]; // Array of hook file paths
customTools?: string[]; // Array of custom tool file paths
doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
extensions?: string[]; // Array of extension file paths
skills?: SkillsSettings;
commands?: CommandsSettings;
terminal?: TerminalSettings;
images?: ImageSettings;
notifications?: NotificationSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
exa?: ExaSettings;
@@ -249,13 +256,24 @@ export class SettingsManager {
}
try {
const content = readFileSync(path, "utf-8");
return JSON.parse(content);
const settings = JSON.parse(content);
return SettingsManager.migrateSettings(settings as Record<string, unknown>);
} catch (error) {
console.error(`Warning: Could not read settings file ${path}: ${error}`);
return {};
}
}
/** Migrate old settings format to new format */
private static migrateSettings(settings: Record<string, unknown>): Settings {
// Migrate queueMode -> steeringMode
if ("queueMode" in settings && !("steeringMode" in settings)) {
settings.steeringMode = settings.queueMode;
delete settings.queueMode;
}
return settings as Settings;
}
private loadProjectSettings(): Settings {
if (!this.cwd) return {};
@@ -270,7 +288,7 @@ export class SettingsManager {
}
}
return merged;
return SettingsManager.migrateSettings(merged as Record<string, unknown>);
}
/** Apply additional overrides on top of current settings */
@@ -332,12 +350,21 @@ export class SettingsManager {
return { ...this.settings.modelRoles };
}
getQueueMode(): "all" | "one-at-a-time" {
return this.settings.queueMode || "one-at-a-time";
getSteeringMode(): "all" | "one-at-a-time" {
return this.settings.steeringMode || "one-at-a-time";
}
setQueueMode(mode: "all" | "one-at-a-time"): void {
this.globalSettings.queueMode = mode;
setSteeringMode(mode: "all" | "one-at-a-time"): void {
this.globalSettings.steeringMode = mode;
this.save();
}
getFollowUpMode(): "all" | "one-at-a-time" {
return this.settings.followUpMode || "one-at-a-time";
}
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
this.globalSettings.followUpMode = mode;
this.save();
}
@@ -458,21 +485,12 @@ export class SettingsManager {
this.save();
}
getHookPaths(): string[] {
return [...(this.settings.hooks ?? [])];
getExtensionPaths(): string[] {
return [...(this.settings.extensions ?? [])];
}
setHookPaths(paths: string[]): void {
this.globalSettings.hooks = paths;
this.save();
}
getCustomToolPaths(): string[] {
return [...(this.settings.customTools ?? [])];
}
setCustomToolPaths(paths: string[]): void {
this.globalSettings.customTools = paths;
setExtensionPaths(paths: string[]): void {
this.globalSettings.extensions = paths;
this.save();
}
@@ -533,6 +551,18 @@ export class SettingsManager {
this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}
setImageAutoResize(enabled: boolean): void {
if (!this.globalSettings.images) {
this.globalSettings.images = {};
}
this.globalSettings.images.autoResize = enabled;
this.save();
}
getEnabledModels(): string[] | undefined {
return this.settings.enabledModels;
}
@@ -914,4 +944,13 @@ export class SettingsManager {
this.globalSettings.statusLine.showHookStatus = show;
this.save();
}
getDoubleEscapeAction(): "branch" | "tree" {
return this.settings.doubleEscapeAction ?? "tree";
}
setDoubleEscapeAction(action: "branch" | "tree"): void {
this.globalSettings.doubleEscapeAction = action;
this.save();
}
}
@@ -77,9 +77,12 @@ const toolDescriptions: Record<ToolName, string> = {
ls: "List directory contents",
lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.",
notebook: "Edit Jupyter notebook cells",
output: "Output structured data to the user (bypasses tool result formatting)",
task: "Spawn a sub-agent to handle complex tasks",
web_fetch: "Fetch and render URLs into clean text for LLM consumption",
web_search: "Search the web for information",
report_finding: "Report a finding during code review",
submit_review: "Submit the final code review with all findings",
};
/**
@@ -231,6 +234,8 @@ export interface BuildSystemPromptOptions {
customPrompt?: string;
/** Tools to include in prompt. Default: [read, bash, edit, write] */
selectedTools?: ToolName[];
/** Extra tool descriptions to include in prompt (non built-in tools). */
extraToolDescriptions?: Array<{ name: string; description: string }>;
/** Text to append to system prompt. */
appendSystemPrompt?: string;
/** Skills settings for discovery. */
@@ -250,6 +255,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const {
customPrompt,
selectedTools,
extraToolDescriptions = [],
appendSystemPrompt,
skillsSettings,
cwd,
@@ -304,6 +310,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
}
}
// Append custom tool descriptions if provided
if (extraToolDescriptions.length > 0) {
prompt += "\n\n# Additional Tools\n\n";
prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
}
// Append git context if in a git repo
const gitContext = loadGitContext(resolvedCwd);
if (gitContext) {
@@ -335,7 +347,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
// Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
const builtInToolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");
const extraToolsList =
extraToolDescriptions.length > 0
? extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n")
: "";
const toolsList = [builtInToolsList, extraToolsList].filter(Boolean).join("\n");
// Generate anti-bash rules (returns null if not applicable)
const antiBashSection = generateAntiBashRules(tools);
@@ -413,6 +430,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
}
}
// Append custom tool descriptions if provided
if (extraToolDescriptions.length > 0) {
prompt += "\n\n# Additional Tools\n\n";
prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n");
}
// Append git context if in a git repo
const gitContext = loadGitContext(resolvedCwd);
if (gitContext) {
@@ -1,21 +1,21 @@
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import type { CustomToolContext } from "../custom-tools/types";
import type { HookUIContext } from "../hooks/types";
import type { ExtensionUIContext } from "../extensions/types";
declare module "@oh-my-pi/pi-agent-core" {
interface AgentToolContext extends CustomToolContext {
ui?: HookUIContext;
ui?: ExtensionUIContext;
hasUI?: boolean;
}
}
export interface ToolContextStore {
getContext(): AgentToolContext;
setUIContext(uiContext: HookUIContext, hasUI: boolean): void;
setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
}
export function createToolContextStore(getBaseContext: () => CustomToolContext): ToolContextStore {
let uiContext: HookUIContext | undefined;
let uiContext: ExtensionUIContext | undefined;
let hasUI = false;
return {
@@ -4,6 +4,8 @@
* Client for interacting with Exa MCP servers via JSON-RPC 2.0 over HTTPS.
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import type { TSchema } from "@sinclair/typebox";
import type { CustomTool } from "../../custom-tools/types";
import { logger } from "../../logger";
@@ -26,14 +28,13 @@ export async function findApiKey(): Promise<string | null> {
// Try loading from .env files in cwd and home
const cwd = process.cwd();
const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
const home = homedir();
for (const dir of [cwd, home]) {
const envPath = `${dir}/.env`;
try {
const file = Bun.file(envPath);
if (await file.exists()) {
const content = await file.text();
if (existsSync(envPath)) {
const content = readFileSync(envPath, "utf-8");
const match = content.match(/^EXA_API_KEY=(.+)$/m);
if (match?.[1]) {
return match[1].trim().replace(/^["']|["']$/g, "");
+24 -15
View File
@@ -21,7 +21,7 @@ export {
} from "./lsp/index";
export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook";
export { createOutputTool, type OutputToolDetails, outputTool } from "./output";
export { createReadTool, type ReadToolDetails, readTool } from "./read";
export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read";
export { createReportFindingTool, createSubmitReviewTool, reportFindingTool, submitReviewTool } from "./review";
export {
createRulebookTool,
@@ -89,17 +89,22 @@ export interface CodingToolsOptions {
lspFormatOnWrite?: boolean;
/** Whether to accept high-confidence fuzzy matches in edit tool (default: true) */
editFuzzyMatch?: boolean;
/** Whether to auto-resize images to 2000x2000 max in read tool (default: true) */
readAutoResizeImages?: boolean;
/** Set of tool names available to the agent (for cross-tool awareness) */
availableTools?: Set<string>;
}
// Factory function type
type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool;
type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool | Promise<Tool>;
// Tool definitions: static tools and their factory functions
const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
ask: { tool: askTool, create: createAskTool },
read: { tool: readTool, create: createReadTool },
read: {
tool: readTool,
create: (cwd, _ctx, options) => createReadTool(cwd, { autoResizeImages: options?.readAutoResizeImages ?? true }),
},
bash: { tool: bashTool, create: createBashTool },
edit: {
tool: editTool,
@@ -145,7 +150,7 @@ export type ToolName = keyof typeof toolDefs;
const uiToolNames: ToolName[] = ["ask"];
// Tool sets defined by name (base sets, without UI-only tools)
const baseCodingToolNames: ToolName[] = [
export const baseCodingToolNames: ToolName[] = [
"read",
"bash",
"edit",
@@ -182,15 +187,15 @@ export const allTools = Object.fromEntries(Object.entries(toolDefs).map(([name,
* @param sessionContext - Optional session context for tools that need it
* @param options - Options for tool configuration
*/
export function createCodingTools(
export async function createCodingTools(
cwd: string,
hasUI = false,
sessionContext?: SessionContext,
options?: CodingToolsOptions,
): Tool[] {
): Promise<Tool[]> {
const names = hasUI ? [...baseCodingToolNames, ...uiToolNames] : baseCodingToolNames;
const optionsWithTools = { ...options, availableTools: new Set(names) };
return names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools));
return Promise.all(names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools)));
}
/**
@@ -200,15 +205,15 @@ export function createCodingTools(
* @param sessionContext - Optional session context for tools that need it
* @param options - Options for tool configuration
*/
export function createReadOnlyTools(
export async function createReadOnlyTools(
cwd: string,
hasUI = false,
sessionContext?: SessionContext,
options?: CodingToolsOptions,
): Tool[] {
): Promise<Tool[]> {
const names = hasUI ? [...baseReadOnlyToolNames, ...uiToolNames] : baseReadOnlyToolNames;
const optionsWithTools = { ...options, availableTools: new Set(names) };
return names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools));
return Promise.all(names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools)));
}
/**
@@ -217,16 +222,20 @@ export function createReadOnlyTools(
* @param sessionContext - Optional session context for tools that need it
* @param options - Options for tool configuration
*/
export function createAllTools(
export async function createAllTools(
cwd: string,
sessionContext?: SessionContext,
options?: CodingToolsOptions,
): Record<ToolName, Tool> {
): Promise<Record<ToolName, Tool>> {
const names = Object.keys(toolDefs);
const optionsWithTools = { ...options, availableTools: new Set(names) };
return Object.fromEntries(
Object.entries(toolDefs).map(([name, def]) => [name, def.create(cwd, sessionContext, optionsWithTools)]),
) as Record<ToolName, Tool>;
const entries = await Promise.all(
Object.entries(toolDefs).map(async ([name, def]) => [
name,
await def.create(cwd, sessionContext, optionsWithTools),
]),
);
return Object.fromEntries(entries) as Record<ToolName, Tool>;
}
/**
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { extname, join } from "node:path";
import { getConfigDirPaths } from "../../../config.js";
import { getConfigDirPaths } from "../../../config";
import { createBiomeClient } from "./clients/biome-client";
import type { ServerConfig } from "./types";
@@ -618,7 +618,7 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
// Handle glob-like patterns (e.g., "*.cabal")
if (marker.includes("*")) {
try {
const { globSync } = require("node:fs");
const { globSync } = require("glob");
const matches = globSync(join(cwd, marker));
return matches.length > 0;
} catch {
@@ -626,7 +626,8 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
return false;
}
}
return existsSync(join(cwd, marker));
const filePath = join(cwd, marker);
return existsSync(filePath);
});
}
@@ -741,7 +742,7 @@ function getConfigPaths(cwd: string): string[] {
* }
* ```
*/
export function loadConfig(cwd: string): LspConfig {
export async function loadConfig(cwd: string): Promise<LspConfig> {
const configPaths = getConfigPaths(cwd);
for (const configPath of configPaths) {
@@ -1,4 +1,5 @@
import * as fs from "node:fs";
import type { Dirent } from "node:fs";
import { existsSync } from "node:fs";
import path from "node:path";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { BunFile } from "bun";
@@ -78,7 +79,7 @@ export interface LspWarmupResult {
* @returns Status of each server that was started
*/
export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
const config = loadConfig(cwd);
const config = await loadConfig(cwd);
setIdleTimeout(config.idleTimeoutMs);
const servers: LspWarmupResult["servers"] = [];
const lspServers = getLspServers(config);
@@ -173,10 +174,10 @@ async function notifyFileSaved(
// Cache config per cwd to avoid repeated file I/O
const configCache = new Map<string, LspConfig>();
function getConfig(cwd: string): LspConfig {
async function getConfig(cwd: string): Promise<LspConfig> {
let config = configCache.get(cwd);
if (!config) {
config = loadConfig(cwd);
config = await loadConfig(cwd);
setIdleTimeout(config.idleTimeoutMs);
configCache.set(cwd, config);
}
@@ -225,9 +226,13 @@ function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: n
const normalized = extensions.map((ext) => ext.toLowerCase());
const search = (dir: string, depth: number): string | null => {
if (depth > maxDepth) return null;
let entries: fs.Dirent[];
let entries: Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
entries = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false })).map((name) => ({
name,
isFile: () => !existsSync(path.join(dir, name)) || Bun.file(path.join(dir, name)).type !== "directory",
isDirectory: () => existsSync(path.join(dir, name)) && Bun.file(path.join(dir, name)).type === "directory",
})) as Dirent[];
} catch {
return null;
}
@@ -312,22 +317,22 @@ interface ProjectType {
/** Detect project type from root markers */
function detectProjectType(cwd: string): ProjectType {
// Check for Rust (Cargo.toml)
if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
if (existsSync(path.join(cwd, "Cargo.toml"))) {
return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" };
}
// Check for TypeScript (tsconfig.json)
if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
if (existsSync(path.join(cwd, "tsconfig.json"))) {
return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" };
}
// Check for Go (go.mod)
if (fs.existsSync(path.join(cwd, "go.mod"))) {
if (existsSync(path.join(cwd, "go.mod"))) {
return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" };
}
// Check for Python (pyproject.toml or pyrightconfig.json)
if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) {
if (existsSync(path.join(cwd, "pyproject.toml")) || existsSync(path.join(cwd, "pyrightconfig.json"))) {
return { type: "python", command: ["pyright"], description: "Python (pyright)" };
}
@@ -612,7 +617,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
return writethroughNoop;
}
return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => {
const config = getConfig(cwd);
const config = await getConfig(cwd);
const servers = getServersForFile(config, dst);
if (servers.length === 0) {
return writethroughNoop(dst, content, signal, file);
@@ -708,7 +713,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
include_declaration,
} = params;
const config = getConfig(cwd);
const config = await getConfig(cwd);
// Status action doesn't need a file
if (action === "status") {
+66 -29
View File
@@ -1,12 +1,11 @@
import { spawnSync } from "node:child_process";
import { constants, existsSync } from "node:fs";
import { access, readFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import { globSync } from "glob";
import readDescription from "../../prompts/tools/read.md" with { type: "text" };
import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
import { ensureTool } from "../../utils/tools-manager";
import { untilAborted } from "../utils";
@@ -49,9 +48,14 @@ async function findExistingDirectory(startDir: string): Promise<string | null> {
while (true) {
try {
const stats = await stat(current);
if (stats.isDirectory()) {
return current;
if (existsSync(current)) {
// Check if directory by trying to read it as dir
try {
await Bun.$`test -d ${current}`.quiet();
return current;
} catch {
// Not a directory, continue
}
}
} catch {
// Keep walking up.
@@ -300,17 +304,17 @@ function convertWithMarkitdown(filePath: string): { content: string; ok: boolean
return { content: "", ok: false, error: "markitdown not found" };
}
const result = spawnSync(cmd, [filePath], {
encoding: "utf-8",
timeout: 60000,
maxBuffer: 50 * 1024 * 1024,
const result = Bun.spawnSync([cmd, filePath], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
if (result.status === 0 && result.stdout && result.stdout.length > 0) {
return { content: result.stdout, ok: true };
if (result.exitCode === 0 && result.stdout && result.stdout.length > 0) {
return { content: result.stdout.toString(), ok: true };
}
return { content: "", ok: false, error: result.stderr || "Conversion failed" };
return { content: "", ok: false, error: result.stderr.toString() || "Conversion failed" };
}
const readSchema = Type.Object({
@@ -324,7 +328,13 @@ export interface ReadToolDetails {
redirectedTo?: "ls";
}
export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
export interface ReadToolOptions {
/** Whether to auto-resize images to 2000x2000 max. Default: true */
autoResizeImages?: boolean;
}
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
const autoResizeImages = options?.autoResizeImages ?? true;
const lsTool = createLsTool(cwd);
return {
name: "read",
@@ -339,9 +349,21 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
const absolutePath = resolveReadPath(readPath, cwd);
return untilAborted(signal, async () => {
let fileStat: Awaited<ReturnType<typeof stat>>;
let isDirectory = false;
let fileSize = 0;
try {
fileStat = await stat(absolutePath);
if (!existsSync(absolutePath)) {
throw { code: "ENOENT" };
}
const file = Bun.file(absolutePath);
fileSize = file.size;
// Check if directory
try {
await Bun.$`test -d ${absolutePath}`.quiet();
isDirectory = true;
} catch {
isDirectory = false;
}
} catch (error) {
if (isNotFoundError(error)) {
const suggestions = await findReadPathSuggestions(readPath, cwd);
@@ -366,7 +388,7 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
throw error;
}
if (fileStat.isDirectory()) {
if (isDirectory) {
const lsResult = await lsTool.execute(toolCallId, { path: readPath, limit }, signal);
return {
content: lsResult.content,
@@ -374,8 +396,6 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
};
}
await access(absolutePath, constants.R_OK);
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
const ext = path.extname(absolutePath).toLowerCase();
@@ -385,9 +405,8 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
if (mimeType) {
// Check image file size before reading to prevent OOM during serialization
const fileStat = await stat(absolutePath);
if (fileStat.size > MAX_IMAGE_SIZE) {
const sizeStr = formatSize(fileStat.size);
if (fileSize > MAX_IMAGE_SIZE) {
const sizeStr = formatSize(fileSize);
const maxStr = formatSize(MAX_IMAGE_SIZE);
content = [
{
@@ -397,13 +416,30 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
];
} else {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
const file = Bun.file(absolutePath);
const buffer = await file.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
if (autoResizeImages) {
// Resize image if needed
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const dimensionNote = formatDimensionNote(resized);
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
content = [
{ type: "text", text: textNote },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} else {
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
}
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
// Convert document via markitdown
@@ -431,7 +467,8 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
}
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const file = Bun.file(absolutePath);
const textContent = await file.text();
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
@@ -154,7 +154,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
*
* @param cwd - Current working directory for project agent discovery
*/
export function discoverAgents(cwd: string): DiscoveryResult {
export async function discoverAgents(cwd: string): Promise<DiscoveryResult> {
const resolvedCwd = path.resolve(cwd);
const agentSources = Array.from(new Set(getConfigDirs("", { project: false }).map((entry) => entry.source)));
@@ -167,7 +167,7 @@ export function discoverAgents(cwd: string): DiscoveryResult {
}));
// Get project directories by walking up from cwd (priority order)
const projectDirs = findAllNearestProjectConfigDirs("agents", resolvedCwd)
const projectDirs = (await findAllNearestProjectConfigDirs("agents", resolvedCwd))
.filter((entry) => agentSources.includes(entry.source))
.map((entry) => ({
...entry,
@@ -5,11 +5,9 @@
* Parses JSON events for progress tracking.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import * as readline from "node:readline";
import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
import { resolveModelPattern } from "./model-resolver";
import { resolveOmpCommand } from "./omp-command";
@@ -171,14 +169,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
// Write system prompt to temp file
const tempDir = os.tmpdir();
const tempDir = tmpdir();
const promptFile = path.join(
tempDir,
`omp-agent-${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`,
);
try {
fs.writeFileSync(promptFile, agent.systemPrompt, "utf-8");
writeFileSync(promptFile, agent.systemPrompt, "utf-8");
} catch (err) {
return {
index,
@@ -211,7 +209,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// Write input file immediately (real-time visibility)
try {
fs.writeFileSync(artifactPaths.inputPath, fullTask, "utf-8");
writeFileSync(artifactPaths.inputPath, fullTask, "utf-8");
} catch {
// Non-fatal, continue without input artifact
}
@@ -268,10 +266,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// Spawn subprocess
const ompCommand = resolveOmpCommand();
const proc = spawn(ompCommand.cmd, [...ompCommand.args, ...args], {
const proc = Bun.spawn([ompCommand.cmd, ...ompCommand.args, ...args], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
shell: ompCommand.shell,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env,
});
@@ -285,7 +284,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// Handle abort signal
const onAbort = () => {
if (!resolved) {
proc.kill("SIGTERM");
proc.kill(15); // SIGTERM
}
};
if (signal) {
@@ -293,9 +292,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
// Parse JSON events from stdout
const rl = readline.createInterface({ input: proc.stdout! });
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
let buffer = "";
rl.on("line", (line) => {
const processLine = (line: string) => {
if (resolved) return;
try {
@@ -362,7 +363,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
setTimeout(() => {
if (!resolved) {
resolved = true;
proc.kill("SIGTERM");
proc.kill(15); // SIGTERM
}
}, 2000);
}
@@ -406,7 +407,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// If pending termination, now we have tokens - terminate
if (pendingTermination && !resolved) {
resolved = true;
proc.kill("SIGTERM");
proc.kill(15); // SIGTERM
}
break;
}
@@ -433,45 +434,48 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
} catch {
// Ignore non-JSON lines
}
});
};
// Capture stderr
const stderrDecoder = new TextDecoder();
proc.stderr?.on("data", (chunk: Buffer) => {
stderr += stderrDecoder.decode(chunk, { stream: true });
});
// Wait for readline to finish BEFORE resolving
const exitCode = await new Promise<number>((resolve) => {
let code: number | null = null;
let rlClosed = false;
let procClosed = false;
const maybeResolve = () => {
if (rlClosed && procClosed) {
resolved = true;
resolve(code ?? 1);
// Read stdout asynchronously
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
};
// Process remaining buffer
if (buffer.trim()) {
processLine(buffer);
}
} catch {
// Ignore read errors
}
})();
rl.on("close", () => {
rlClosed = true;
maybeResolve();
});
// Capture stderr - Bun.spawn returns ReadableStream, convert to text
(async () => {
try {
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
stderr += decoder.decode(value, { stream: true });
}
} catch {
// Ignore stderr read errors
}
})();
proc.on("close", (c) => {
code = c;
procClosed = true;
maybeResolve();
});
proc.on("error", (err) => {
stderr += `\nProcess error: ${err.message}`;
code = 1;
procClosed = true;
maybeResolve();
});
});
// Wait for process to finish
resolved = true;
const exitCode = await proc.exited;
// Cleanup
if (signal) {
@@ -479,7 +483,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
}
try {
fs.unlinkSync(promptFile);
if (existsSync(promptFile)) {
unlinkSync(promptFile);
}
} catch {
// Ignore cleanup errors
}
@@ -493,7 +499,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
let outputMeta: { lineCount: number; charCount: number } | undefined;
if (artifactPaths) {
try {
fs.writeFileSync(artifactPaths.outputPath, rawOutput, "utf-8");
writeFileSync(artifactPaths.outputPath, rawOutput, "utf-8");
outputMeta = {
lineCount: rawOutput.split("\n").length,
charCount: rawOutput.length,
@@ -138,8 +138,8 @@ export { taskSchema } from "./types";
/**
* Build dynamic tool description listing available agents.
*/
function buildDescription(cwd: string): string {
const { agents } = discoverAgents(cwd);
async function buildDescription(cwd: string): Promise<string> {
const { agents } = await discoverAgents(cwd);
const lines: string[] = [];
@@ -258,11 +258,11 @@ function buildDescription(cwd: string): string {
/**
* Create the task tool configured for a specific working directory.
*/
export function createTaskTool(
export async function createTaskTool(
cwd: string,
sessionContext?: SessionContext,
options?: TaskToolOptions,
): AgentTool<typeof taskSchema, TaskToolDetails, Theme> {
): Promise<AgentTool<typeof taskSchema, TaskToolDetails, Theme>> {
const hasOutputTool = options?.availableTools?.has("output") ?? false;
// Check if subagents are completely inhibited (legacy recursion prevention)
if (process.env[OMP_NO_SUBAGENTS_ENV]) {
@@ -288,13 +288,13 @@ export function createTaskTool(
return {
name: "task",
label: "Task",
description: buildDescription(cwd),
description: await buildDescription(cwd),
parameters: taskSchema,
renderCall,
renderResult,
execute: async (_toolCallId, params, signal, onUpdate) => {
const startTime = Date.now();
const { agents, projectAgentsDir } = discoverAgents(cwd);
const { agents, projectAgentsDir } = await discoverAgents(cwd);
const context = params.context;
// Handle empty or missing tasks
@@ -548,5 +548,20 @@ export function createTaskTool(
};
}
// Default task tool using process.cwd()
export const taskTool = createTaskTool(process.cwd());
// Default task tool using process.cwd() - returns a placeholder sync tool
// Real implementations should use createTaskTool() which properly initializes the tool
export const taskTool: AgentTool<typeof taskSchema, TaskToolDetails, Theme> = {
name: "task",
label: "Task",
description:
"Launch a new agent to handle complex, multi-step tasks autonomously. (Agent discovery pending - use createTaskTool for full functionality)",
parameters: taskSchema,
execute: async () => ({
content: [{ type: "text", text: "Task tool not properly initialized. Use createTaskTool(cwd) instead." }],
details: {
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
},
}),
};
@@ -11,7 +11,6 @@
* - "omp/slow" → configured slow model from settings
*/
import { spawnSync } from "node:child_process";
import { type Settings, settingsCapability } from "../../../capability/settings";
import { loadSync } from "../../../discovery";
import { resolveOmpCommand } from "./omp-command";
@@ -37,20 +36,20 @@ export function getAvailableModels(): string[] {
try {
const ompCommand = resolveOmpCommand();
const result = spawnSync(ompCommand.cmd, [...ompCommand.args, "--list-models"], {
encoding: "utf-8",
timeout: 5000,
shell: ompCommand.shell,
const result = Bun.spawnSync([ompCommand.cmd, ...ompCommand.args, "--list-models"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
if (result.status !== 0 || !result.stdout) {
if (result.exitCode !== 0 || !result.stdout) {
cachedModels = [];
cacheExpiry = now + CACHE_TTL_MS;
return cachedModels;
}
// Parse output: skip header line, extract provider/model
const lines = result.stdout.trim().split("\n");
const lines = result.stdout.toString().trim().split("\n");
cachedModels = lines
.slice(1) // Skip header
.map((line) => {
@@ -1,6 +1,4 @@
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import { tmpdir } from "node:os";
import * as path from "node:path";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
@@ -197,18 +195,15 @@ function exec(
args: string[],
options?: { timeout?: number; input?: string | Buffer },
): { stdout: string; stderr: string; ok: boolean } {
const timeout = (options?.timeout ?? DEFAULT_TIMEOUT) * 1000;
const result = spawnSync(cmd, args, {
encoding: options?.input instanceof Buffer ? "buffer" : "utf-8",
timeout,
maxBuffer: MAX_BYTES,
input: options?.input,
shell: true,
const result = Bun.spawnSync([cmd, ...args], {
stdin: options?.input ? (options.input as any) : "ignore",
stdout: "pipe",
stderr: "pipe",
});
return {
stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "",
ok: result.status === 0,
ok: result.exitCode === 0,
};
}
@@ -217,8 +212,12 @@ function exec(
*/
function hasCommand(cmd: string): boolean {
const checkCmd = isWindows ? "where" : "which";
const result = spawnSync(checkCmd, [cmd], { encoding: "utf-8", shell: true });
return result.status === 0;
const result = Bun.spawnSync([checkCmd, cmd], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
return result.exitCode === 0;
}
/**
@@ -299,26 +298,27 @@ function looksLikeHtml(content: string): boolean {
/**
* Convert binary file to markdown using markitdown
*/
function convertWithMarkitdown(
async function convertWithMarkitdown(
content: Buffer,
extensionHint: string,
timeout: number,
): { content: string; ok: boolean } {
): Promise<{ content: string; ok: boolean }> {
if (!hasCommand("markitdown")) {
return { content: "", ok: false };
}
// Write to temp file with extension hint
const ext = extensionHint || ".bin";
const tmpFile = path.join(os.tmpdir(), `omp-convert-${Date.now()}${ext}`);
const tmpDir = tmpdir();
const tmpFile = path.join(tmpDir, `omp-convert-${Date.now()}${ext}`);
try {
fs.writeFileSync(tmpFile, content);
await Bun.write(tmpFile, content);
const result = exec("markitdown", [tmpFile], { timeout });
return { content: result.stdout, ok: result.ok };
} finally {
try {
fs.unlinkSync(tmpFile);
await Bun.$`rm ${tmpFile}`.quiet();
} catch {}
}
}
@@ -531,10 +531,11 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
/**
* Render HTML to text using lynx
*/
function renderWithLynx(html: string, timeout: number): { content: string; ok: boolean } {
const tmpFile = path.join(os.tmpdir(), `omp-render-${Date.now()}.html`);
async function renderWithLynx(html: string, timeout: number): Promise<{ content: string; ok: boolean }> {
const tmpDir = tmpdir();
const tmpFile = path.join(tmpDir, `omp-render-${Date.now()}.html`);
try {
fs.writeFileSync(tmpFile, html);
await Bun.write(tmpFile, html);
// Convert path to file URL (handles Windows paths correctly)
const normalizedPath = tmpFile.replace(/\\/g, "/");
const fileUrl = normalizedPath.startsWith("/") ? `file://${normalizedPath}` : `file:///${normalizedPath}`;
@@ -542,7 +543,7 @@ function renderWithLynx(html: string, timeout: number): { content: string; ok: b
return { content: result.stdout, ok: result.ok };
} finally {
try {
fs.unlinkSync(tmpFile);
await Bun.$`rm ${tmpFile}`.quiet();
} catch {}
}
}
@@ -1752,7 +1753,7 @@ async function handleArxiv(url: string, timeout: number): Promise<RenderResult |
notes.push("Fetching PDF for full content...");
const pdfResult = await fetchBinary(pdfLink, timeout);
if (pdfResult.ok) {
const converted = convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
if (converted.ok && converted.content.length > 500) {
md += `---\n\n## Full Paper\n\n${converted.content}\n`;
notes.push("PDF converted via markitdown");
@@ -1835,7 +1836,7 @@ async function handleIacr(url: string, timeout: number): Promise<RenderResult |
notes.push("Fetching PDF for full content...");
const pdfResult = await fetchBinary(pdfUrl, timeout);
if (pdfResult.ok) {
const converted = convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
if (converted.ok && converted.content.length > 500) {
md += `---\n\n## Full Paper\n\n${converted.content}\n`;
notes.push("PDF converted via markitdown");
@@ -1992,7 +1993,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
const binary = await fetchBinary(finalUrl, timeout);
if (binary.ok) {
const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
const converted = convertWithMarkitdown(binary.buffer, ext, timeout);
const converted = await convertWithMarkitdown(binary.buffer, ext, timeout);
if (converted.ok && converted.content.trim().length > 50) {
notes.push(`Converted with markitdown`);
const output = finalizeOutput(converted.content);
@@ -2174,7 +2175,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
};
}
const lynxResult = renderWithLynx(rawContent, timeout);
const lynxResult = await renderWithLynx(rawContent, timeout);
if (!lynxResult.ok) {
notes.push("lynx failed");
const output = finalizeOutput(rawContent);
@@ -2198,7 +2199,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr
const binary = await fetchBinary(docUrl, timeout);
if (binary.ok) {
const ext = getExtensionHint(docUrl, binary.contentDisposition);
const converted = convertWithMarkitdown(binary.buffer, ext, timeout);
const converted = await convertWithMarkitdown(binary.buffer, ext, timeout);
if (converted.ok && converted.content.trim().length > lynxResult.content.length) {
notes.push(`Extracted and converted document: ${docUrl}`);
const output = finalizeOutput(converted.content);

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