Read PR pages, comments, commits, and changed files.
UnderstandIdentified and reviewed linked issues.
UnderstandAnalyzed PR diffs and compared with main branch.
UnderstandChecked documentation and changelogs.
UnderstandCompiled detailed review summaries for all PRs.
BuildYou are given one or more GitHub PR URLs: https://github.com/badlogic/pi-mono/pulls
For each PR URL, do the following in order:
packages/*/CHANGELOG.md files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
### Breaking Changes, ### Added, ### Fixed, etc.)Fixed foo ([#123](https://github.com/badlogic/pi-mono/pull/123) by [@user](https://github.com/user))### Breaking Changes, not just ### FixedOutput format per PR: PR: Changelog:
If no issues are found, say so under Bad and Ugly.
>gh pr list --repo badlogic/pi-mono --state open --json number,title,url,author,labels[{"author":{"id":"U_kgDOCoBIHw","is_bot":false,"login":"4h9fbZ","name":""},"labels":[],"number":940,"title":"feat(coding-agent): add Qwen CLI OAuth provider","url":"https://github.com/badlogic/pi-mono/pull/940"},{"author":{"id":"MDQ6VXNlcjcwNjQwMTE=","is_bot":false,"login":"ogulcancelik","name":"Can Celik"},"labels":[],"number":908,"title":"feat(tui): add tmux support for kitty graphics protocol","url":"https://github.com/badlogic/pi-mono/pull/908"},{"author":{"id":"MDQ6VXNlcjczOTY=","is_bot":false,"login":"mitsuhiko","name":"Armin Ronacher"},"labels":[],"number":903,"title":"Add support for intercepting bash","url":"https://github.com/badlogic/pi-mono/pull/903"},{"author":{"id":"MDQ6VXNlcjIyMzYyNQ==","is_bot":false,"login":"kaofelix","name":"Kao Félix"},"labels":[],"number":899,"title":"Add zai-coding-plan provider and make zai point to regular API","url":"https://github.com/badlogic/pi-mono/pull/899"},{"author":{"id":"MDQ6VXNlcjcwNjQwMTE=","is_bot":false,"login":"ogulcancelik","name":"Can Celik"},"labels":[],"number":892,"title":"feat(coding-agent): add template variables to custom system prompts","url":"https://github.com/badlogic/pi-mono/pull/892"},{"author":{"id":"MDQ6VXNlcjIwNDczOTg=","is_bot":false,"login":"w-winter","name":"warren"},"labels":[],"number":868,"title":"feat(coding-agent): add named-only filter toggle to /resume picker","url":"https://github.com/badlogic/pi-mono/pull/868"},{"author":{"id":"MDQ6VXNlcjMxOTQxMzU5","is_bot":false,"login":"svkozak","name":"Sergii Kozak"},"labels":[],"number":863,"title":"Rename session from /resume session list","url":"https://github.com/badlogic/pi-mono/pull/863"},{"author":{"id":"MDQ6VXNlcjU5NjY3","is_bot":false,"login":"kim0","name":"Ahmed Kamal"},"labels":[],"number":345,"title":"Add http_request and http_response hooks for LLM provider HTTP interception","url":"https://github.com/badlogic/pi-mono/pull/345"}]>gh pr view 940 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"U_kgDOCoBIHw","is_bot":false,"login":"4h9fbZ","name":""},"baseRefName":"main","body":"Adds a Qwen CLI provider extension with OAuth device flow support, matching the official Qwen CLI.\r\n\r\nChanges:\r\n- Add custom-provider-qwen-cli extension (packages/coding-agent/examples/extensions/custom-provider-qwen-cli)\r\n- Add Qwen OAuth device flow with PKCE and refresh support\r\n- Register Qwen models (coder plus/flash + vision) with OpenAI-compatible API\r\n- Add thinkingFormat: \"qwen\" to core types (uses enable_thinking param)\r\n- Enable modifyModels hook for extension OAuth providers (documented in packages/coding-agent/docs/custom-provider.md but not implemented; enables dynamic baseUrl from OAuth credentials)\r\n- Register extension in example list + workspace\r\n\r\nModels:\r\n- qwen3-coder-plus: 1M context, 65K output\r\n- qwen3-coder-flash: 1M context, 65K output\r\n- vision-model (qwen3-vl-plus): 262K context, 32K output, vision + thinking\r\n\r\nTesting:\r\n- npm run check passes\r\n- ./test.sh passes\r\n- OAuth flow verified against official Qwen CLI source\r\n- API calls and thinking mode tested with live tokens","comments":[],"commits":[{"authoredDate":"2026-01-25T02:06:57Z","authors":[{"email":"[REDACTED]","id":"U_kgDOCoBIHw","login":"4h9fbZ","name":"4h9fbZ"}],"committedDate":"2026-01-25T02:06:57Z","messageBody":"","messageHeadline":"feat(coding-agent): add Qwen CLI OAuth provider","oid":"9f702f2b2de87616d0e64bf1446ff8271e945875"}],"files":[{"path":"package.json","additions":2,"deletions":1},{"path":"packages/ai/README.md","additions":1,"deletions":1},{"path":"packages/ai/src/providers/openai-completions.ts","additions":3,"deletions":0},{"path":"packages/ai/src/types.ts","additions":2,"deletions":2},{"path":"packages/coding-agent/examples/extensions/README.md","additions":1,"deletions":0},{"path":"packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore","additions":1,"deletions":0},{"path":"packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts","additions":345,"deletions":0},{"path":"packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json","additions":16,"deletions":0},{"path":"packages/coding-agent/src/core/model-registry.ts","additions":9,"deletions":1}],"headRefName":"feat/qwen-cli-extension","number":940,"reviews":[],"title":"feat(coding-agent): add Qwen CLI OAuth provider","url":"https://github.com/badlogic/pi-mono/pull/940"}>gh pr view 908 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjcwNjQwMTE=","is_bot":false,"login":"ogulcancelik","name":"Can Celik"},"baseRefName":"main","body":"Adds support for rendering inline images inside tmux using the Kitty graphics protocol's unicode placeholder mode.\n\n## Problem\n\nWhen running pi inside tmux + Ghostty/Kitty/WezTerm:\n- Direct Kitty graphics sequences get eaten by tmux (without passthrough) or cause \"stuck\" images (with passthrough)\n- Images orphan on screen redraws, pane switches, or scrolling\n\n## Solution\n\nImplement the protocol's multiplexer-safe mode:\n- Detect tmux via `TMUX` env var\n- Check `allow-passthrough` setting before attempting image display\n- Wrap sequences in tmux passthrough escapes\n- Use virtual placement (`U=1`) with unicode placeholders (`U+10EEEE`)\n- Encode row positions via diacritics (per official spec)\n\n## Backward Compatibility\n\n- Outside tmux: behavior unchanged, direct placement as before\n- Inside tmux without `allow-passthrough`: gracefully falls back to text description (`[Image: foo.png 800x600]`)\n- Inside tmux with `allow-passthrough all`: full image rendering\n\n## User Requirement\n\nFor images to render in tmux, users need in their tmux.conf:\n```\nset -g allow-passthrough all\n```","comments":[],"commits":[{"authoredDate":"2026-01-22T17:58:52Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjcwNjQwMTE=","login":"ogulcancelik","name":"Ogulcan Celik"}],"committedDate":"2026-01-22T18:01:29Z","messageBody":"use unicode placeholders and passthrough escapes to render\ninline images correctly inside tmux without orphaning on redraws","messageHeadline":"feat(tui): add tmux support for kitty graphics protocol","oid":"046ea9d9011dc02d7ea87ba81941a4ad58415701"}],"files":[{"path":"packages/tui/CHANGELOG.md","additions":4,"deletions":0},{"path":"packages/tui/src/components/image.ts","additions":23,"deletions":27},{"path":"packages/tui/src/diacritics.ts","additions":36,"deletions":0},{"path":"packages/tui/src/index.ts","additions":6,"deletions":3},{"path":"packages/tui/src/terminal-image.ts","additions":193,"deletions":33}],"headRefName":"feat/tmux-kitty-images","number":908,"reviews":[],"title":"feat(tui): add tmux support for kitty graphics protocol","url":"https://github.com/badlogic/pi-mono/pull/908"}>gh pr view 903 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjczOTY=","is_bot":false,"login":"mitsuhiko","name":"Armin Ronacher"},"baseRefName":"main","body":"Discussion:\r\n\r\n<img width=\"1020\" height=\"350\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a2a1633a-acee-40d8-a7ba-2d2ab9808514\" />\r\n\r\nThis allows hooking bash execution and it also allows `tool_result` hooks to manipulate error messages by \"abusing\" content to carry error messages. This feels somewhat consistent to how it works but a bit ugly.","comments":[{"id":"IC_kwDOPbFNk87huidp","author":{"login":"cv"},"authorAssociation":"CONTRIBUTOR","body":"Happy to modify/rewrite the examples/ssh.ts (and my own pi-ssh-remote) when this lands.","createdAt":"2026-01-22T22:32:17Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/903#issuecomment-3787073385","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87h1et-","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"@cv it should in theory work with it already, the question in a way is if ssh.ts can further benefit from the changes.","createdAt":"2026-01-23T08:01:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/903#issuecomment-3788893054","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87h5XIx","author":{"login":"dejanr"},"authorAssociation":"NONE","body":"This could be really nice for project purposes, instead of blowing agents.md and instructions on how to run sandboxed environment, we could just build custom extensions that are preparing env for tool, is that nix develop, or uv, or whatever else that someone uses.\r\n\r\nFrom architectural point of view i would prefer a middleware approach to tool wrapping, then dangling handlers.","createdAt":"2026-01-23T11:56:52Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/903#issuecomment-3789910577","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87iPMHC","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"@dejanr @Mic92 I think it would be nice if people other than me would test drive this and give some suggestions. It's quite narrowly scoped to what i tried so far.","createdAt":"2026-01-24T22:16:42Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/903#issuecomment-3795632578","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-01-22T18:47:03Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjczOTY=","login":"mitsuhiko","name":"Armin Ronacher"}],"committedDate":"2026-01-22T18:47:03Z","messageBody":"","messageHeadline":"feat(coding-agent): intercept bash execution","oid":"15b91544b5b9045ccaa283c6eb136951231fb89f"},{"authoredDate":"2026-01-22T19:00:15Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjczOTY=","login":"mitsuhiko","name":"Armin Ronacher"}],"committedDate":"2026-01-22T19:52:36Z","messageBody":"","messageHeadline":"Reuse and recycle","oid":"e25be49907f074dee15d00955de4010550af5190"},{"authoredDate":"2026-01-22T19:58:09Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjczOTY=","login":"mitsuhiko","name":"Armin Ronacher"}],"committedDate":"2026-01-22T19:58:09Z","messageBody":"","messageHeadline":"Fix test","oid":"95c03063adf375c82f4b43a2a3c17a9338538c88"}],"files":[{"path":"packages/coding-agent/README.md","additions":17,"deletions":1},{"path":"packages/coding-agent/docs/extensions.md","additions":29,"deletions":2},{"path":"packages/coding-agent/examples/extensions/uv.ts","additions":74,"deletions":0},{"path":"packages/coding-agent/src/core/agent-session.ts","additions":47,"deletions":8},{"path":"packages/coding-agent/src/core/bash-executor.ts","additions":45,"deletions":6},{"path":"packages/coding-agent/src/core/extensions/index.ts","additions":5,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/runner.ts","additions":76,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/types.ts","additions":42,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/wrapper.ts","additions":92,"deletions":9},{"path":"packages/coding-agent/src/core/messages.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/src/core/tools/bash.ts","additions":29,"deletions":7},{"path":"packages/coding-agent/src/index.ts","additions":5,"deletions":0},{"path":"packages/coding-agent/src/utils/shell.ts","additions":29,"deletions":2},{"path":"packages/coding-agent/test/tools.test.ts","additions":4,"deletions":3}],"headRefName":"intercept-bash","number":903,"reviews":[{"id":"PRR_kwDOPbFNk87cGVH3","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-22T14:12:18Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"e9a331d8e8d76f26005ce7f099ebe818b5981944"}},{"id":"PRR_kwDOPbFNk87cHqIy","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-22T15:14:07Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"48ec095272a3391dee2f2f8fd0dffc970d9d2482"}},{"id":"PRR_kwDOPbFNk87cIBSD","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-22T15:34:30Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"5816b2ec9db56222e767e081923f2b95adfe649a"}},{"id":"PRR_kwDOPbFNk87cMoJr","author":{"login":"mitsuhiko"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-22T19:53:27Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"e25be49907f074dee15d00955de4010550af5190"}},{"id":"PRR_kwDOPbFNk87coCHa","author":{"login":"Mic92"},"authorAssociation":"NONE","body":"","submittedAt":"2026-01-24T11:50:59Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"95c03063adf375c82f4b43a2a3c17a9338538c88"}}],"title":"Add support for intercepting bash","url":"https://github.com/badlogic/pi-mono/pull/903"}>gh pr view 899 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjIyMzYyNQ==","is_bot":false,"login":"kaofelix","name":"Kao Félix"},"baseRefName":"main","body":"Opening this as a draft here just to discuss a few points.\r\n\r\n@assagman I will pull your doc changes in here too and adapt them to whatever becomes the final idea here.\r\n\r\nBasically what I find annoying is that z.ai API keys work for both API and Coding Plan. The only difference is the endpoint itself. The way it currently is, whenever someone has `ZAI_API_KEY` defined, they will have both providers available, which is a bit noisy for what I assume to be the most cases of people only using one option.\r\n\r\nThe ideas that occurred to me were:\r\n\r\n1. Introduce a different environment var e.g. `ZAI_CODING_API_KEY` so that you know which provider you are configuring. The only caveat is that every other tool I encountered uses `ZAI_API_KEY` for the config\r\n2. Replace the `zai` provider entirely with `zai-coding-plan`\r\n\r\nI personally prefer 2 as it keeps things simpler while making it more aligned with models.dev and being explicit that we are supporting the Coding Plan specifically and not the regular API. So far, pi only worked with the coding plan anyways, since it always used coding plan endpoints. I can add examples to the docs on how to setup the regular API by hand. \r\n\r\nFrom the fact that it has always been Coding Plan and no one complained, I assume there are not many API users out there. API users tend to prefer other providers and even avoid zai, from what I've seen.","comments":[{"id":"IC_kwDOPbFNk87hgmc9","author":{"login":"assagman"},"authorAssociation":"NONE","body":"Hey @kaofelix , thank you!\r\n\r\nI also find it a bit confusing that z-ai team's decided that one api key can be used for both. Anyways, +1 for option (2) here. It's fine as soon as it's aligned with `models.dev`","createdAt":"2026-01-22T09:32:46Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/899#issuecomment-3783419709","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87hja5i","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"I'm OK with 2 as well! Which means we entirely rip out the zai provider. Can you amend the PR accordingly? Also needs a breaking changes changelog entry in that case.","createdAt":"2026-01-22T12:34:18Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/899#issuecomment-3784158818","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87hr__A","author":{"login":"kaofelix"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic cool, made all the changes! Did a quick test with GLM flash here with thinking on and off to make sure the thinking param still works: https://buildwithpi.ai/session/#2cc3a9696a480de2262751a35e692215\r\n\r\nOops, actually forgot about the breaking change changelog, will do that now","createdAt":"2026-01-22T19:57:42Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/899#issuecomment-3786407872","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-01-22T08:36:59Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIyMzYyNQ==","login":"kaofelix","name":"Kao Félix"}],"committedDate":"2026-01-22T08:36:59Z","messageBody":"","messageHeadline":"feat(ai): add zai-coding-plan provider and make zai point to regular API","oid":"b02b6a5c4691a6bc837f1bf46a9420b2c1e83c88"},{"authoredDate":"2026-01-20T13:20:45Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjEyOTk4MjEx","login":"assagman","name":"assagman"}],"committedDate":"2026-01-22T08:47:25Z","messageBody":"Add documentation for the new Z.AI GLM Coding Plan provider,\nincluding auth configuration, environment variables, and model\ndetails (GLM-4.7, GLM-4.6, GLM-4.5 and vision variants).\n\nSigned-off-by: assagman <[REDACTED]>","messageHeadline":"docs(coding-agent): add Z.AI provider documentation","oid":"cdb817d61e4ab0fe55832585fe427d701207394b"},{"authoredDate":"2026-01-22T19:37:19Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIyMzYyNQ==","login":"kaofelix","name":"Kao Félix"}],"committedDate":"2026-01-22T19:37:19Z","messageBody":"","messageHeadline":"replace zai with zai-coding-plan","oid":"c2459e463ee881ba6e5994d8799e9a11380d9d78"},{"authoredDate":"2026-01-22T19:51:54Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIyMzYyNQ==","login":"kaofelix","name":"Kao Félix"}],"committedDate":"2026-01-22T19:51:54Z","messageBody":"","messageHeadline":"simplify documentation for zai coding plan","oid":"af60ba5fd552a353d49e99f194a9087988ae7d90"},{"authoredDate":"2026-01-22T19:59:15Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIyMzYyNQ==","login":"kaofelix","name":"Kao Félix"}],"committedDate":"2026-01-22T19:59:15Z","messageBody":"","messageHeadline":"update changelog","oid":"6f10c0a37d794b60c7da74456b580d4fe801684d"}],"files":[{"path":"packages/agent/test/e2e.test.ts","additions":2,"deletions":2},{"path":"packages/ai/CHANGELOG.md","additions":4,"deletions":0},{"path":"packages/ai/README.md","additions":2,"deletions":1},{"path":"packages/ai/scripts/generate-models.ts","additions":22,"deletions":22},{"path":"packages/ai/src/models.generated.ts","additions":49,"deletions":48},{"path":"packages/ai/src/providers/openai-completions.ts","additions":29,"deletions":7},{"path":"packages/ai/src/stream.ts","additions":1,"deletions":1},{"path":"packages/ai/src/types.ts","additions":1,"deletions":1},{"path":"packages/ai/test/context-overflow.test.ts","additions":1,"deletions":1},{"path":"packages/ai/test/empty.test.ts","additions":1,"deletions":1},{"path":"packages/ai/test/stream.test.ts","additions":30,"deletions":2},{"path":"packages/ai/test/tokens.test.ts","additions":10,"deletions":2},{"path":"packages/ai/test/tool-call-without-result.test.ts","additions":1,"deletions":1},{"path":"packages/ai/test/total-tokens.test.ts","additions":1,"deletions":1},{"path":"packages/ai/test/unicode-surrogate.test.ts","additions":1,"deletions":1},{"path":"packages/coding-agent/CHANGELOG.md","additions":4,"deletions":0},{"path":"packages/coding-agent/README.md","additions":10,"deletions":3},{"path":"packages/coding-agent/src/cli/args.ts","additions":1,"deletions":1},{"path":"packages/coding-agent/src/core/model-resolver.ts","additions":1,"deletions":1},{"path":"packages/web-ui/README.md","additions":1,"deletions":1},{"path":"packages/web-ui/src/components/ProviderKeyInput.ts","additions":1,"deletions":1},{"path":"packages/web-ui/src/utils/proxy-utils.ts","additions":2,"deletions":2}],"headRefName":"zai-coding-plan-provider","number":899,"reviews":[],"title":"Add zai-coding-plan provider and make zai point to regular API","url":"https://github.com/badlogic/pi-mono/pull/899"}>gh pr view 892 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjcwNjQwMTE=","is_bot":false,"login":"ogulcancelik","name":"Can Celik"},"baseRefName":"main","body":"adds `{{tools}}`, `{{context}}`, `{{skills}}` template variables for SYSTEM.md.\n\nwhen present, replaced with dynamic content. no templates = full replacement (current behavior preserved).\n\nalso tracks injection metadata through sdk → session → ui, so the welcome screen only shows \"loaded context/skills\" when actually injected into the system prompt.","comments":[{"id":"IC_kwDOPbFNk87hastx","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Thanks! This touches a lot of stuff that's been changed in the great refactor, I will integrate this manually once the refactor is complete.","createdAt":"2026-01-22T00:35:33Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3781872497","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87hjd9f","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Also need to integrate this https://github.com/badlogic/pi-mono/pull/896","createdAt":"2026-01-22T12:36:54Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3784171359","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87hlFpg","author":{"login":"ogulcancelik"},"authorAssociation":"CONTRIBUTOR","body":"> Also need to integrate this #896\r\n\r\nlet me know if you'd like me to tackle that","createdAt":"2026-01-22T14:04:59Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3784596064","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87hn0st","author":{"login":"scutifer"},"authorAssociation":"CONTRIBUTOR","body":"The `.replace` is simple and robust, but I wonder if a minor extension would allow a lot more functionality without bringing in Moustache or Handlebars or NJK.\r\n\r\nSomething like this for example:\r\n```ts\r\nfunction render(template: string, context: Record<string, any>): string {\r\n // 1. Resolve nested paths (e.g., \"user.name\")\r\n const resolve = (path: string, ctx: any) => \r\n path.trim().split('.').reduce((obj, key) => obj?.[key], ctx);\r\n\r\n // 2. Handle Logic Blocks: {% if ... %} and {% for ... %}\r\n // This regex matches {% tag condition %} content {% endtag %}\r\n let output = template.replace(\r\n /{%\\s+(if|for)\\s+(.*?)\\s+%}([\\s\\S]*?){%\\s+end\\1\\s+%}/g,\r\n (_, type, condition, inner) => {\r\n if (type === \"if\") {\r\n return resolve(condition, context) ? render(inner, context) : \"\";\r\n } else {\r\n // For loops: expects syntax \"item in list\"\r\n const [itemKey, , listKey] = condition.split(\" \");\r\n const list = resolve(listKey, context);\r\n if (!Array.isArray(list)) return \"\";\r\n return list\r\n .map((item) => render(inner, { ...context, [itemKey]: item }))\r\n .join(\"\");\r\n }\r\n }\r\n );\r\n\r\n // 3. Handle Variable Interpolation: {{ variable }}\r\n return output.replace(/{{(.*?)}}/g, (_, path) => {\r\n const value = resolve(path, context);\r\n return value !== undefined && value !== null ? String(value) : \"\";\r\n });\r\n}\r\n```\r\n\r\nYou would use it like this\r\n\r\n```ts\r\nconst context = {\r\n cwd: resolvedCwd,\r\n tools: tools.map(t => ({ name: t, desc: toolDescriptions[t] })),\r\n hasSkills: skills.length > 0,\r\n skills: skills,\r\n contextFiles: contextFiles\r\n};\r\n\r\nconst template = `\r\nYou are an expert assistant working in {{ cwd }}.\r\n\r\nAvailable Tools:\r\n{% for tool in tools %}\r\n- {{ tool.name }}: {{ tool.desc }}\r\n{% endfor %}\r\n\r\n{% if hasSkills %}\r\n# Skills\r\n{{ skills }}\r\n{% endif %}\r\n\r\n# Project Context\r\n{% for file in contextFiles %}\r\n## {{ file.path }}\r\n{{ file.content }}\r\n{% endfor %}\r\n`;\r\n\r\nconst systemPrompt = render(template, context);\r\n```\r\n\r\nThen you move the actual string to a file. Users can even bring custom templates in the future via extensions.","createdAt":"2026-01-22T16:22:14Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3785313069","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87hrZYB","author":{"login":"ogulcancelik"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic rebased/refactored implementation after your resourceloader rework.\r\n\r\nregarding #896 - i can add that to this pr too, but wanted to check first since it feels like a design choice. currently system prompts aren't stored in sessions at all (no `SystemPromptEntry` type, nothing in `SessionContext`). adding persistence would mean storing the prompt when `--system-prompt` is used and restoring on resume. but then what happens if you want to resume a session with a different prompt? e.g. you updated `SYSTEM.md` and want existing sessions to pick up the new version. happy to implement either way, just wanted to confirm intended behavior.\r\n\r\n@scutifer yes that approach feels a lot comprehensive, though again design choice. i just want my custom system prompt in there for now, either way works for me.","createdAt":"2026-01-22T19:19:03Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3786249729","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87hxHMF","author":{"login":"airtonix"},"authorAssociation":"CONTRIBUTOR","body":"i'd prefer to have use of nunjucks tbh, means less core churn and more flexibility for end users to extend in their own way","createdAt":"2026-01-23T01:51:17Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3787748101","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87iG2Ht","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"@ogulcancelik thanks for the refactor, sorry for messing with main and having you do the work. I think we should go with what's in this PR for now.\r\n\r\nAs for persistent of system prompts in sessions, let's postpone that, too many changes already in the next release. I think you are right, that we need a way to override and system prompt baked into the session, not just with another custom one, but the default one. Can't think of a good way to do that at the moment, hence the postpone. @scutifer feel free to opena an issu for \"persisting custom system prompt in session files\" so we can discuss.\r\n\r\n@scutifer i don't want to introduce a template system, even a \"simple\" on. This can be done ... via extension :D","createdAt":"2026-01-24T02:09:37Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3793445357","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87iM3TZ","author":{"login":"ogulcancelik"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic rebased again, added `ResourceLoader.getSystemPromptTemplates()` to check if custom `SYSTEM.md` uses {{context}}/{{skills}} templates. the UI now only shows \"[Context]\" and \"[Skills]\" sections when they're actually injected into the system prompt. ptal\r\n\r\n","createdAt":"2026-01-24T16:36:37Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/892#issuecomment-3795023065","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-01-22T19:00:29Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjcwNjQwMTE=","login":"ogulcancelik","name":"Ogulcan Celik"}],"committedDate":"2026-01-24T16:33:24Z","messageBody":"adds {{tools}}, {{context}}, {{skills}} template variables for SYSTEM.md.\nwhen present, content is injected at variable location.\nno template variables = full replacement (no automatic appending).\n\nalso tracks injection state so UI only shows 'Loaded context/skills'\nwhen they were actually injected into the system prompt.","messageHeadline":"feat(coding-agent): add template variables to custom system prompts","oid":"e51cc98b4c3e30536e8dd0e2058f6379af912879"}],"files":[{"path":"packages/coding-agent/CHANGELOG.md","additions":1,"deletions":0},{"path":"packages/coding-agent/examples/sdk/12-full-control.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/src/core/agent-session.ts","additions":3,"deletions":1},{"path":"packages/coding-agent/src/core/resource-loader.ts","additions":18,"deletions":0},{"path":"packages/coding-agent/src/core/system-prompt.ts","additions":46,"deletions":17},{"path":"packages/coding-agent/src/index.ts","additions":6,"deletions":1},{"path":"packages/coding-agent/src/modes/interactive/interactive-mode.ts","additions":29,"deletions":15},{"path":"packages/coding-agent/test/sdk-skills.test.ts","additions":2,"deletions":0},{"path":"packages/coding-agent/test/system-prompt.test.ts","additions":196,"deletions":9},{"path":"packages/coding-agent/test/utilities.ts","additions":1,"deletions":0},{"path":"packages/mom/src/agent.ts","additions":1,"deletions":0}],"headRefName":"feat/system-prompt-template-variables","number":892,"reviews":[],"title":"feat(coding-agent): add template variables to custom system prompts","url":"https://github.com/badlogic/pi-mono/pull/892"}>gh pr view 868 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjIwNDczOTg=","is_bot":false,"login":"w-winter","name":"warren"},"baseRefName":"main","body":"Originating issue: https://github.com/badlogic/pi-mono/issues/862\r\n\r\nThe `/resume` picker already supports search, scope toggling, and sort modes, but named sessions can get lost easily sometimes. This adds a `Ctrl+N` toggle to filter the list down to sessions named via `/name`, while keeping the existing search/sort behavior intact (name filtering applies even when the query is empty).\r\n\r\nSummary of changes:\r\n- add `NameFilter` (`all | named`) to `filterAndSortSessions()` and apply it before query parsing/matching\r\n- add `Ctrl+N` in the session selector to toggle `Name: All` ↔ `Name: Named`, and show current state in the header\r\n- improve empty-state messaging when the named filter is active (including the escape hatch key)\r\n- tests: add `name filter` coverage (incl. whitespace-only names excluded via `trim()`)\r\n- add configurable app keybinding `toggleSessionNamedFilter` (default: `ctrl+n`) via `~/.pi/agent/keybindings.json`\r\n- session selector now uses `KeybindingsManager` for the named-filter toggle and UI hints/empty-state messaging, so users can remap it (e.g. for Emacs/readline `ctrl+n` = down)\r\n\r\nFiles:\r\n- `packages/coding-agent/README.md`\r\n- `packages/coding-agent/src/cli/session-picker.ts`\r\n- `packages/coding-agent/src/core/keybindings.ts`\r\n- `packages/coding-agent/src/modes/interactive/components/session-selector-search.ts`\r\n- `packages/coding-agent/src/modes/interactive/components/session-selector.ts`\r\n- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`\r\n- `packages/coding-agent/test/session-selector-path-delete.test.ts`\r\n- `packages/coding-agent/test/session-selector-search.test.ts`\r\n\r\nManual testing:\r\n- `/resume`: Ctrl+N toggles All/Named; header updates; hint shows `ctrl+n: named`\r\n- `/resume`: named filter works with empty search query and with an active query\r\n- `/resume`: empty state mentions Ctrl+N when no named sessions are available in the current scope\r\n- `/resume`: named filter toggle works; header updates; hint shows the configured keybinding\r\n- `keybindings.json`: remapping `toggleSessionNamedFilter` works (e.g. free up `ctrl+n` for `selectDown`)\r\n\r\n<p align=\"center\">\r\n<img width=\"360\" alt=\"all\" src=\"https://github.com/user-attachments/assets/5d7908d9-dba2-4067-85d3-735f84723ce7\" />\r\n\r\n<img width=\"360\" alt=\"named\" src=\"https://github.com/user-attachments/assets/ccf1f969-79b9-439f-b5b6-53731a777e90\" />\r\n</p>","comments":[],"commits":[{"authoredDate":"2026-01-20T07:30:51Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIwNDczOTg=","login":"w-winter","name":"warren"}],"committedDate":"2026-01-20T07:33:10Z","messageBody":"","messageHeadline":"feat(coding-agent): add named-only filter toggle to /resume picker","oid":"fdf7e82d2714da144965e1664f7969f6fea77b0c"},{"authoredDate":"2026-01-21T21:25:03Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIwNDczOTg=","login":"w-winter","name":"warren"}],"committedDate":"2026-01-21T21:25:03Z","messageBody":"","messageHeadline":"Merge branch 'main' into session-named-filter","oid":"5b967333e2f1447b3f3b46889f1a0fe076870eaa"},{"authoredDate":"2026-01-22T00:41:29Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIwNDczOTg=","login":"w-winter","name":"warren"}],"committedDate":"2026-01-22T00:47:29Z","messageBody":"…er toggle","messageHeadline":"fix(coding-agent): add configurable keybinding for /resume named filt…","oid":"3a8f5c514b3b87002c588072aa88fb8fa5a4448d"},{"authoredDate":"2026-01-22T00:52:00Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIwNDczOTg=","login":"w-winter","name":"warren"}],"committedDate":"2026-01-22T00:52:00Z","messageBody":"","messageHeadline":"Merge remote-tracking branch 'upstream/main' into session-named-filter","oid":"bb3cccc364a4d8c77b771637f7e1fb54e02f6ed0"},{"authoredDate":"2026-01-22T20:18:38Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIwNDczOTg=","login":"w-winter","name":"warren"}],"committedDate":"2026-01-22T20:18:38Z","messageBody":"","messageHeadline":"Merge branch 'main' into session-named-filter","oid":"6f1888214b59c9d443fab69353868c76e275cd1f"},{"authoredDate":"2026-01-22T20:54:22Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIwNDczOTg=","login":"w-winter","name":"warren"}],"committedDate":"2026-01-22T20:54:22Z","messageBody":"","messageHeadline":"Merge branch 'main' into session-named-filter","oid":"592e17c81db96e3d362c3ebf90c24af8abed1032"}],"files":[{"path":"packages/coding-agent/CHANGELOG.md","additions":2,"deletions":1},{"path":"packages/coding-agent/README.md","additions":2,"deletions":0},{"path":"packages/coding-agent/src/cli/session-picker.ts","additions":3,"deletions":0},{"path":"packages/coding-agent/src/core/keybindings.ts","additions":5,"deletions":0},{"path":"packages/coding-agent/src/modes/interactive/components/session-selector-search.ts","additions":23,"deletions":4},{"path":"packages/coding-agent/src/modes/interactive/components/session-selector.ts","additions":112,"deletions":16},{"path":"packages/coding-agent/src/modes/interactive/interactive-mode.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/test/session-selector-path-delete.test.ts","additions":7,"deletions":0},{"path":"packages/coding-agent/test/session-selector-search.test.ts","additions":68,"deletions":0}],"headRefName":"session-named-filter","number":868,"reviews":[{"id":"PRR_kwDOPbFNk87b7dwM","author":{"login":"aos"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-21T23:22:07Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"5b967333e2f1447b3f3b46889f1a0fe076870eaa"}},{"id":"PRR_kwDOPbFNk87b7eNW","author":{"login":"aos"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-21T23:22:49Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"5b967333e2f1447b3f3b46889f1a0fe076870eaa"}},{"id":"PRR_kwDOPbFNk87b8sQR","author":{"login":"w-winter"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-22T01:03:24Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"5b967333e2f1447b3f3b46889f1a0fe076870eaa"}}],"title":"feat(coding-agent): add named-only filter toggle to /resume picker","url":"https://github.com/badlogic/pi-mono/pull/868"}>gh pr view 863 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjMxOTQxMzU5","is_bot":false,"login":"svkozak","name":"Sergii Kozak"},"baseRefName":"main","body":"### Summary\r\n\r\nRecent update introduced `/name` command to add session names and this PR adds renaming support to the interactive `/resume` picker allowing to rename sessions in the list without opening them individually. Also modifies how session 'recency' is derived (if only metadata changed, session's 'modified' date remains the same).\r\n\r\n#### Interactive /resume rename support\r\n\r\n- Ctrl+R opens a full-page rename mode inside the session selector (no overlay).\r\n- Rename mode shows “Rename Session”, an input, and save/cancel hints.\r\n- Esc/Ctrl+C cancels rename; Enter saves.\r\n\r\n\r\n\r\n\r\n \r\n#### Get session 'modified' time based on latest activity\r\n\r\n- Session “modified” time is derived from last user/assistant message timestamp (with fallbacks), not file mtime, so renaming (or /name) doesn’t reorder the recent list. (`mtime` is kept as a fallback)\r\n\r\n#### Refresh/reload behaviour\r\n\r\n- After rename/delete, the session list refreshes so updated names appear immediately.\r\n- Small refactor of refresh logic into a shared `loadScope(...)` helper to reduce duplication.\r\n\r\n ### Tests\r\n\r\n - Added/updated tests to cover:\r\n - rename hint behavior (/resume vs --resume)\r\n - rename flow (Ctrl+R → rename UI → typing → Enter)\r\n - “modified timestamp uses last message timestamp” behavior\r\n - existing delete/path/scope behavior remains covered","comments":[],"commits":[{"authoredDate":"2026-01-20T00:08:48Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-20T00:08:48Z","messageBody":"Session list now displays last message timestamp as modified time\ninstead of file mtime. Ctrl+N enters rename mode in the interactive\nresume picker, allowing quick session renaming without leaving the\nselector. Rename hint is shown only in interactive mode, not in the\nCLI --resume picker./","messageHeadline":"Add session renaming in interactive mode resume picker","oid":"8076bf44df3a0d5e55b672b0e9ac96950d0dc5a1"},{"authoredDate":"2026-01-20T00:09:06Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-20T00:09:06Z","messageBody":"","messageHeadline":"Merge branch 'main' into rename-session","oid":"e98313acfdab374b28aee414b14a3509f6939acf"},{"authoredDate":"2026-01-20T01:19:29Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-20T01:19:29Z","messageBody":"","messageHeadline":"Add docs entry for renaming in picker","oid":"4dd1b5281580d8f8f4be078bb195e146925c9e15"},{"authoredDate":"2026-01-24T04:46:43Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-24T04:46:43Z","messageBody":"","messageHeadline":"Update shortcut to ctrl+r for session renaming","oid":"9410fe5f80cabc744c38f1588dcb5e2319520892"}],"files":[{"path":"packages/coding-agent/README.md","additions":1,"deletions":0},{"path":"packages/coding-agent/src/cli/session-picker.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/src/core/session-manager.ts","additions":41,"deletions":1},{"path":"packages/coding-agent/src/modes/interactive/components/session-selector.ts","additions":211,"deletions":87},{"path":"packages/coding-agent/src/modes/interactive/interactive-mode.ts","additions":11,"deletions":1},{"path":"packages/coding-agent/test/session-info-modified-timestamp.test.ts","additions":83,"deletions":0},{"path":"packages/coding-agent/test/session-selector-rename.test.ts","additions":101,"deletions":0}],"headRefName":"rename-session","number":863,"reviews":[{"id":"PRR_kwDOPbFNk87ckVjq","author":{"login":"w-winter"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-24T03:07:51Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"4dd1b5281580d8f8f4be078bb195e146925c9e15"}},{"id":"PRR_kwDOPbFNk87ckhSb","author":{"login":"svkozak"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-01-24T03:48:08Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"4dd1b5281580d8f8f4be078bb195e146925c9e15"}}],"title":"Rename session from /resume session list","url":"https://github.com/badlogic/pi-mono/pull/863"}>gh pr view 345 --repo badlogic/pi-mono --json number,title,body,author,url,baseRefName,headRefName,commits,comments,reviews,files{"author":{"id":"MDQ6VXNlcjU5NjY3","is_bot":false,"login":"kim0","name":"Ahmed Kamal"},"baseRefName":"main","body":"Adds HTTP-level hooks that allow intercepting requests to LLM providers for logging, adding custom headers, authentication injection, and request cancellation.\n\n## New Hook Events\n\n### `http_request`\nFires before each HTTP request to an LLM provider. Handlers receive:\n- `provider`, `modelId`, `url`, `method`\n- `headers` (sensitive headers redacted)\n- `body` (string bodies only)\n\nHandlers can return:\n- `{ headers: {...} }` to add/override headers\n- `{ cancel: true }` to abort the request\n\n### `http_response`\nFires after each HTTP response. Handlers receive:\n- `provider`, `modelId`, `status`, `headers`, `durationMs`\n\n## Example Hook\n\n```typescript\n// .pi/hooks/http-logger.ts\nimport type { HookAPI } from \"@mariozechner/pi-coding-agent\";\n\nexport default function(pi: HookAPI) {\n pi.on(\"http_request\", async (event) => {\n console.log(`→ ${event.method} ${event.url}`);\n return { headers: { \"X-Custom-Header\": \"value\" } };\n });\n \n pi.on(\"http_response\", async (event) => {\n console.log(`← ${event.status} (${event.durationMs}ms)`);\n });\n}\n```\n\n## Implementation\n\n- **packages/ai**: Added `fetch` option to `StreamOptions`, threaded through all provider functions\n- **packages/agent**: Added `createFetch` factory option to `AgentOptions` and `AgentLoopConfig`\n- **packages/coding-agent**: Hook event types, `HookRunner` methods, fetch wrapper factory in SDK\n\n## Provider Support\n\n| Provider | HTTP Hooks Support |\n|----------|-------------------|\n| anthropic | ✅ |\n| openai, github-copilot, xai, groq, cerebras, openrouter, zai, mistral | ✅ |\n| google-gemini-cli, google-antigravity | ✅ |\n| google | ❌ (SDK limitation) |\n\nThe `@google/genai` SDK does not expose custom fetch in its public API. Users needing HTTP hooks with Google models should use `google-gemini-cli` provider.\n\n## Security\n\nSensitive headers are automatically redacted in hook events:\n- Explicit: `authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `anthropic-api-key`, `proxy-authorization`, `cookie`, `set-cookie`\n- Heuristic: any header containing `auth`, `token`, `key`, `secret`, or `cookie`\n\n## Tests\n\nAdded 36 tests covering:\n- Hook event emission and handler behavior\n- Header injection, cancellation, redaction, normalization\n- Request object input, non-string bodies, network errors","comments":[{"id":"IC_kwDOPbFNk87cUupH","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Lovely, I planned on adding this, did an exploration, and also ran into the Google provider issue. I think it's fine to have it even if the Google provider doesn't support it. I'm finishing the session-tree refactor today, so this will have to be rebased. See #316 ","createdAt":"2025-12-29T12:40:47Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/345#issuecomment-3696421447","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87ceiNk","author":{"login":"kim0"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic This has been rebased, thx!","createdAt":"2025-12-30T10:49:47Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/345#issuecomment-3698991972","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87cs92p","author":{"login":"kim0"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic ready for `main`","createdAt":"2025-12-31T19:41:37Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/345#issuecomment-3702775209","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87c7xxr","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Sorry, had to finish the session-tree branch. Will give it a looksy asap.","createdAt":"2026-01-03T03:35:24Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/345#issuecomment-3706657899","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87dSn6x","author":{"login":"kim0"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic rebased `http_hooks` onto latest `main` and resolved the conflicts by porting the hook-based HTTP interception to the new **extensions** API.","createdAt":"2026-01-06T00:59:10Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/345#issuecomment-3712646833","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87daeaP","author":{"login":"kim0"},"authorAssociation":"CONTRIBUTOR","body":"@badlogic Please take a look before it conflicts again :)","createdAt":"2026-01-06T13:29:37Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/345#issuecomment-3714705039","viewerDidAuthor":false}],"commits":[{"authoredDate":"2025-12-28T21:58:52Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjU5NjY3","login":"kim0","name":"Ahmed Kamal"}],"committedDate":"2026-01-06T12:51:54Z","messageBody":"…eption","messageHeadline":"Add http_request and http_response hooks for LLM provider HTTP interc…","oid":"541f4b8712fe8ddaf2f85ffc8572a4809050f2f9"},{"authoredDate":"2026-01-06T12:55:44Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjU5NjY3","login":"kim0","name":"Ahmed Kamal"}],"committedDate":"2026-01-06T12:55:44Z","messageBody":"","messageHeadline":"coding-agent: fix http hooks tests for LoadedExtension API","oid":"2e70919cbd20f096f6ddfa5e0960e146ad83282a"}],"files":[{"path":"packages/agent/src/agent-loop.ts","additions":4,"deletions":0},{"path":"packages/agent/src/agent.ts","additions":9,"deletions":0},{"path":"packages/agent/src/types.ts","additions":6,"deletions":0},{"path":"packages/ai/src/providers/anthropic.ts","additions":9,"deletions":1},{"path":"packages/ai/src/providers/google-gemini-cli.ts","additions":2,"deletions":1},{"path":"packages/ai/src/providers/google.ts","additions":8,"deletions":0},{"path":"packages/ai/src/providers/openai-codex-responses.ts","additions":2,"deletions":1},{"path":"packages/ai/src/providers/openai-completions.ts","additions":8,"deletions":2},{"path":"packages/ai/src/providers/openai-responses.ts","additions":8,"deletions":2},{"path":"packages/ai/src/stream.ts","additions":1,"deletions":0},{"path":"packages/ai/src/types.ts","additions":6,"deletions":0},{"path":"packages/coding-agent/CHANGELOG.md","additions":7,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/http.ts","additions":150,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/index.ts","additions":3,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/runner.ts","additions":75,"deletions":0},{"path":"packages/coding-agent/src/core/extensions/types.ts","additions":45,"deletions":1},{"path":"packages/coding-agent/src/core/sdk.ts","additions":2,"deletions":1},{"path":"packages/coding-agent/test/hooks/http-hooks-integration.test.ts","additions":852,"deletions":0},{"path":"packages/coding-agent/test/hooks/http-hooks.test.ts","additions":490,"deletions":0}],"headRefName":"http_hooks","number":345,"reviews":[],"title":"Add http_request and http_response hooks for LLM provider HTTP interception","url":"https://github.com/badlogic/pi-mono/pull/345"}Now let me get the diffs for all PRs:
>gh pr diff 940 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/package.json b/package.json
index 713f0ed66..d53cbebe6 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"packages/web-ui/example",
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
- "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo"
+ "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
+ "packages/coding-agent/examples/extensions/custom-provider-qwen-cli"
],
"scripts": {
"clean": "npm run clean --workspaces",
diff --git a/packages/ai/README.md b/packages/ai/README.md
index cf58728b6..52c2af952 100644
--- a/packages/ai/README.md
+++ b/packages/ai/README.md
@@ -731,7 +731,7 @@ interface OpenAICompletionsCompat {
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
- thinkingFormat?: 'openai' | 'zai'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" } (default: openai)
+ thinkingFormat?: 'openai' | 'zai' | 'qwen'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" }, 'qwen' uses enable_thinking: boolean (default: openai)
}
interface OpenAIResponsesCompat {
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
index 46c62e9c5..38049f5f3 100644
--- a/packages/ai/src/providers/openai-completions.ts
+++ b/packages/ai/src/providers/openai-completions.ts
@@ -440,6 +440,9 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
// Z.ai uses binary thinking: { type: "enabled" | "disabled" }
// Must explicitly disable since z.ai defaults to thinking enabled
(params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
+ } else if (compat.thinkingFormat === "qwen" && model.reasoning) {
+ // Qwen uses enable_thinking: boolean
+ (params as any).enable_thinking = !!options?.reasoningEffort;
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
params.reasoning_effort = options.reasoningEffort;
diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts
index 75c36c3c8..4f836cfbd 100644
--- a/packages/ai/src/types.ts
+++ b/packages/ai/src/types.ts
@@ -212,8 +212,8 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean;
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
requiresMistralToolIds?: boolean;
- /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */
- thinkingFormat?: "openai" | "zai";
+ /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses enable_thinking: boolean. Default: "openai". */
+ thinkingFormat?: "openai" | "zai" | "qwen";
}
/** Compatibility settings for OpenAI Responses APIs. */
diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md
index 60a2afe2c..4e017d77b 100644
--- a/packages/coding-agent/examples/extensions/README.md
+++ b/packages/coding-agent/examples/extensions/README.md
@@ -94,6 +94,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|-----------|-------------|
| `custom-provider-anthropic/` | Custom Anthropic provider with OAuth support and custom streaming implementation |
| `custom-provider-gitlab-duo/` | GitLab Duo provider using pi-ai's built-in Anthropic/OpenAI streaming via proxy |
+| `custom-provider-qwen-cli/` | Qwen CLI provider with OAuth device flow and OpenAI-compatible models |
### External Dependencies
diff --git a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore
new file mode 100644
index 000000000..c2658d7d1
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts
new file mode 100644
index 000000000..57deb8af7
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts
@@ -0,0 +1,345 @@
+/**
+ * Qwen CLI Provider Extension
+ *
+ * Provides access to Qwen models via OAuth authentication with chat.qwen.ai.
+ * Uses device code flow with PKCE for secure browser-based authentication.
+ *
+ * Usage:
+ * pi -e ./packages/coding-agent/examples/extensions/custom-provider-qwen-cli
+ * # Then /login qwen-cli, or set QWEN_CLI_API_KEY=[REDACTED]
+ */
+
+import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code";
+const QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token";
+const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
+const QWEN_SCOPE = "openid profile email model.completion";
+const QWEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
+const QWEN_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
+const QWEN_POLL_INTERVAL_MS = 2000;
+
+// =============================================================================
+// PKCE Helpers
+// =============================================================================
+
+async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
+ const array = new Uint8Array(32);
+ crypto.getRandomValues(array);
+ const verifier = btoa(String.fromCharCode(...array))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+
+ const encoder = new TextEncoder();
+ const data = encoder.encode(verifier);
+ const hash = await crypto.subtle.digest("SHA-256", data);
+ const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+
+ return { verifier, challenge };
+}
+
+// =============================================================================
+// OAuth Implementation
+// =============================================================================
+
+interface DeviceCodeResponse {
+ device_code: string;
+ user_code: string;
+ verification_uri: string;
+ verification_uri_complete?: string;
+ expires_in: number;
+ interval?: number;
+}
+
+interface TokenResponse {
+ access_token: string;
+ refresh_token?: string;
+ token_type: string;
+ expires_in: number;
+ resource_url?: string;
+}
+
+function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(new Error("Login cancelled"));
+ return;
+ }
+ const timeout = setTimeout(resolve, ms);
+ signal?.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timeout);
+ reject(new Error("Login cancelled"));
+ },
+ { once: true },
+ );
+ });
+}
+
+async function startDeviceFlow(): Promise<{ deviceCode: DeviceCodeResponse; verifier: string }> {
+ const { verifier, challenge } = await generatePKCE();
+
+ const body = new URLSearchParams({
+ client_id: QWEN_CLIENT_ID,
+ scope: QWEN_SCOPE,
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ });
+
+ const headers: Record<string, string> = {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ };
+ const requestId = globalThis.crypto?.randomUUID?.();
+ if (requestId) headers["x-request-id"] = requestId;
+
+ const response = await fetch(QWEN_DEVICE_CODE_ENDPOINT, {
+ method: "POST",
+ headers,
+ body: body.toString(),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Device code request failed: ${response.status} ${text}`);
+ }
+
+ const data = (await response.json()) as DeviceCodeResponse;
+
+ if (!data.device_code || !data.user_code || !data.verification_uri) {
+ throw new Error("Invalid device code response: missing required fields");
+ }
+
+ return { deviceCode: data, verifier };
+}
+
+async function pollForToken(
+ deviceCode: string,
+ verifier: string,
+ intervalSeconds: number | undefined,
+ expiresIn: number,
+ signal?: AbortSignal,
+): Promise<TokenResponse> {
+ const deadline = Date.now() + expiresIn * 1000;
+ const resolvedIntervalSeconds =
+ typeof intervalSeconds === "number" && Number.isFinite(intervalSeconds) && intervalSeconds > 0
+ ? intervalSeconds
+ : QWEN_POLL_INTERVAL_MS / 1000;
+ let intervalMs = Math.max(1000, Math.floor(resolvedIntervalSeconds * 1000));
+
+ const handleTokenError = async (error: string, description?: string): Promise<boolean> => {
+ switch (error) {
+ case "authorization_pending":
+ await abortableSleep(intervalMs, signal);
+ return true;
+ case "slow_down":
+ intervalMs = Math.min(intervalMs + 5000, 10000);
+ await abortableSleep(intervalMs, signal);
+ return true;
+ case "expired_token":
+ throw new Error("Device code expired. Please restart authentication.");
+ case "access_denied":
+ throw new Error("Authorization denied by user.");
+ default:
+ throw new Error(`Token request failed: ${error} - ${description || ""}`);
+ }
+ };
+
+ while (Date.now() < deadline) {
+ if (signal?.aborted) {
+ throw new Error("Login cancelled");
+ }
+
+ const body = new URLSearchParams({
+ grant_type: QWEN_GRANT_TYPE,
+ client_id: QWEN_CLIENT_ID,
+ device_code: deviceCode,
+ code_verifier: verifier,
+ });
+
+ const response = await fetch(QWEN_TOKEN_ENDPOINT, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ },
+ body: body.toString(),
+ });
+
+ const responseText = await response.text();
+ let data: (TokenResponse & { error?: string; error_description?: string }) | null = null;
+ if (responseText) {
+ try {
+ data = JSON.parse(responseText) as TokenResponse & { error?: string; error_description?: string };
+ } catch {
+ data = null;
+ }
+ }
+
+ const error = data?.error;
+ const errorDescription = data?.error_description;
+
+ if (!response.ok) {
+ if (error && (await handleTokenError(error, errorDescription))) {
+ continue;
+ }
+ throw new Error(`Token request failed: ${response.status} ${response.statusText}. Response: ${responseText}`);
+ }
+
+ if (data?.access_token) {
+ return data;
+ }
+
+ if (error && (await handleTokenError(error, errorDescription))) {
+ continue;
+ }
+
+ throw new Error("Token request failed: missing access token in response");
+ }
+
+ throw new Error("Authentication timed out. Please try again.");
+}
+
+async function loginQwen(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
+ const { deviceCode, verifier } = await startDeviceFlow();
+
+ // Show verification URL and user code to user
+ const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri;
+ const instructions = deviceCode.verification_uri_complete
+ ? undefined // Code is already embedded in the URL
+ : `Enter code: ${deviceCode.user_code}`;
+ callbacks.onAuth({ url: authUrl, instructions });
+
+ // Poll for token
+ const tokenResponse = await pollForToken(
+ deviceCode.device_code,
+ verifier,
+ deviceCode.interval,
+ deviceCode.expires_in,
+ callbacks.signal,
+ );
+
+ // Calculate expiry with 5-minute buffer
+ const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000;
+
+ return {
+ refresh: tokenResponse.refresh_token || "",
+ access: tokenResponse.access_token,
+ expires: expiresAt,
+ // Store resource_url for API base URL if provided
+ enterpriseUrl: tokenResponse.resource_url,
+ };
+}
+
+async function refreshQwenToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: credentials.refresh,
+ client_id: QWEN_CLIENT_ID,
+ });
+
+ const response = await fetch(QWEN_TOKEN_ENDPOINT, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ },
+ body: body.toString(),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Token refresh failed: ${response.status} ${text}`);
+ }
+
+ const data = (await response.json()) as TokenResponse;
+
+ if (!data.access_token) {
+ throw new Error("Token refresh failed: no access token in response");
+ }
+
+ const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
+
+ return {
+ refresh: data.refresh_token || credentials.refresh,
+ access: data.access_token,
+ expires: expiresAt,
+ enterpriseUrl: data.resource_url ?? credentials.enterpriseUrl,
+ };
+}
+
+function getQwenBaseUrl(resourceUrl?: string): string {
+ if (!resourceUrl) {
+ return QWEN_DEFAULT_BASE_URL;
+ }
+
+ let url = resourceUrl.startsWith("http") ? resourceUrl : `https://${resourceUrl}`;
+ if (!url.endsWith("/v1")) {
+ url = `${url}/v1`;
+ }
+ return url;
+}
+
+// =============================================================================
+// Extension Entry Point
+// =============================================================================
+
+export default function (pi: ExtensionAPI) {
+ pi.registerProvider("qwen-cli", {
+ baseUrl: QWEN_DEFAULT_BASE_URL,
+ apiKey: "QWEN_CLI_API_KEY",
+ api: "openai-completions",
+
+ models: [
+ {
+ id: "qwen3-coder-plus",
+ name: "Qwen3 Coder Plus",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 1000000,
+ maxTokens: 65536,
+ },
+ {
+ id: "qwen3-coder-flash",
+ name: "Qwen3 Coder Flash",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 1000000,
+ maxTokens: 65536,
+ },
+ {
+ id: "vision-model",
+ name: "Qwen3 VL Plus",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 262144,
+ maxTokens: 32768,
+ compat: { supportsDeveloperRole: false, thinkingFormat: "qwen" },
+ },
+ ],
+
+ oauth: {
+ name: "Qwen CLI",
+ login: loginQwen,
+ refreshToken: refreshQwenToken,
+ getApiKey: (cred) => cred.access,
+ modifyModels: (models, cred) => {
+ const baseUrl = getQwenBaseUrl(cred.enterpriseUrl as string | undefined);
+ return models.map((m) => (m.provider === "qwen-cli" ? { ...m, baseUrl } : m));
+ },
+ },
+ });
+}
diff --git a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json
new file mode 100644
index 000000000..ad94d97c7
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "pi-extension-custom-provider-qwen-cli",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "clean": "echo 'nothing to clean'",
+ "build": "echo 'nothing to build'",
+ "check": "echo 'nothing to check'"
+ },
+ "pi": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts
index 61c67c6e2..98c7b3505 100644
--- a/packages/coding-agent/src/core/model-registry.ts
+++ b/packages/coding-agent/src/core/model-registry.ts
@@ -36,7 +36,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
requiresThinkingAsText: Type.Optional(Type.Boolean()),
requiresMistralToolIds: Type.Optional(Type.Boolean()),
- thinkingFormat: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai")])),
+ thinkingFormat: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai"), Type.Literal("qwen")])),
});
const OpenAIResponsesCompatSchema = Type.Object({
@@ -530,6 +530,14 @@ export class ModelRegistry {
compat: modelDef.compat,
} as Model<Api>);
}
+
+ // Apply OAuth modifyModels if credentials exist (e.g., to update baseUrl)
+ if (config.oauth?.modifyModels) {
+ const cred = this.authStorage.get(providerName);
+ if (cred?.type === "oauth") {
+ this.models = config.oauth.modifyModels(this.models, cred);
+ }
+ }
} else if (config.baseUrl) {
// Override-only: update baseUrl/headers for existing models
const resolvedHeaders = resolveHeaders(config.headers);>gh pr diff 908 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md
index a45af6ed7..716c9c708 100644
--- a/packages/tui/CHANGELOG.md
+++ b/packages/tui/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Added
+
+- Added tmux support for Kitty graphics protocol using unicode placeholders, enabling inline images to render correctly inside tmux without getting stuck during screen redraws ([#908](https://github.com/badlogic/pi-mono/pull/908) by [@ogulcancelik](https://github.com/ogulcancelik))
+
## [0.49.3] - 2026-01-22
### Added
diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts
index ca76cddde..2a1f9acc6 100644
--- a/packages/tui/src/components/image.ts
+++ b/packages/tui/src/components/image.ts
@@ -15,8 +15,6 @@ export interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
- /** Kitty image ID. If provided, reuses this ID (for animations/updates). */
- imageId?: number;
}
export class Image implements Component {
@@ -25,7 +23,6 @@ export class Image implements Component {
private dimensions: ImageDimensions;
private theme: ImageTheme;
private options: ImageOptions;
- private imageId?: number;
private cachedLines?: string[];
private cachedWidth?: number;
@@ -42,12 +39,6 @@ export class Image implements Component {
this.theme = theme;
this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
- this.imageId = options.imageId;
- }
-
- /** Get the Kitty image ID used by this image (if any). */
- getImageId(): number | undefined {
- return this.imageId;
}
invalidate(): void {
@@ -66,27 +57,32 @@ export class Image implements Component {
let lines: string[];
if (caps.images) {
- const result = renderImage(this.base64Data, this.dimensions, {
- maxWidthCells: maxWidth,
- imageId: this.imageId,
- });
+ const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth });
if (result) {
- // Store the image ID for later cleanup
- if (result.imageId) {
- this.imageId = result.imageId;
- }
-
- // Return `rows` lines so TUI accounts for image height
- // First (rows-1) lines are empty (TUI clears them)
- // Last line: move cursor back up, then output image sequence
- lines = [];
- for (let i = 0; i < result.rows - 1; i++) {
- lines.push("");
+ if (result.placeholderLines) {
+ // tmux mode: output image sequence first (hidden), then placeholder lines
+ // The sequence transmits the image, placeholders display it
+ lines = [];
+ // First line includes the image transmission sequence (invisible) + first placeholder row
+ lines.push(result.sequence + result.placeholderLines[0]);
+ // Rest of the placeholder rows
+ for (let i = 1; i < result.placeholderLines.length; i++) {
+ lines.push(result.placeholderLines[i]);
+ }
+ } else {
+ // Direct placement mode (non-tmux)
+ // Return `rows` lines so TUI accounts for image height
+ // First (rows-1) lines are empty (TUI clears them)
+ // Last line: move cursor back up, then output image sequence
+ lines = [];
+ for (let i = 0; i < result.rows - 1; i++) {
+ lines.push("");
+ }
+ // Move cursor up to first row, then output image
+ const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
+ lines.push(moveUp + result.sequence);
}
- // Move cursor up to first row, then output image
- const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
- lines.push(moveUp + result.sequence);
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
diff --git a/packages/tui/src/diacritics.ts b/packages/tui/src/diacritics.ts
new file mode 100644
index 000000000..9eb7da22a
--- /dev/null
+++ b/packages/tui/src/diacritics.ts
@@ -0,0 +1,36 @@
+/**
+ * Diacritics used for encoding row/column values in Kitty graphics protocol Unicode placeholders.
+ *
+ * The index in this array IS the encoded value (0-296).
+ * These are Unicode combining characters (class 230, "above base") that were carefully
+ * chosen to avoid normalization issues.
+ *
+ * Source: https://sw.kovidgoyal.net/kitty/_downloads/f0a0de9ec8d9ff4456206db8e0814937/rowcolumn-diacritics.txt
+ *
+ * Used by terminals like Kitty, Ghostty, and WezTerm to decode row/column positions
+ * for virtual image placements in multiplexer-safe rendering mode.
+ */
+export const KITTY_DIACRITICS: readonly number[] = [
+ 0x0305, 0x030d, 0x030e, 0x0310, 0x0312, 0x033d, 0x033e, 0x033f, 0x0346, 0x034a, 0x034b, 0x034c, 0x0350, 0x0351,
+ 0x0352, 0x0357, 0x035b, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, 0x036a, 0x036b, 0x036c, 0x036d,
+ 0x036e, 0x036f, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599,
+ 0x059c, 0x059d, 0x059e, 0x059f, 0x05a0, 0x05a1, 0x05a8, 0x05a9, 0x05ab, 0x05ac, 0x05af, 0x05c4, 0x0610, 0x0611,
+ 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065a, 0x065b, 0x065d, 0x065e, 0x06d6,
+ 0x06d7, 0x06d8, 0x06d9, 0x06da, 0x06db, 0x06dc, 0x06df, 0x06e0, 0x06e1, 0x06e2, 0x06e4, 0x06e7, 0x06e8, 0x06eb,
+ 0x06ec, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073a, 0x073d, 0x073f, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747,
+ 0x0749, 0x074a, 0x07eb, 0x07ec, 0x07ed, 0x07ee, 0x07ef, 0x07f0, 0x07f1, 0x07f3, 0x0816, 0x0817, 0x0818, 0x0819,
+ 0x081b, 0x081c, 0x081d, 0x081e, 0x081f, 0x0820, 0x0821, 0x0822, 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082a,
+ 0x082b, 0x082c, 0x082d, 0x0951, 0x0953, 0x0954, 0x0f82, 0x0f83, 0x0f86, 0x0f87, 0x135d, 0x135e, 0x135f, 0x17dd,
+ 0x193a, 0x1a17, 0x1a75, 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f,
+ 0x1b70, 0x1b71, 0x1b72, 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4,
+ 0x1dc5, 0x1dc6, 0x1dc7, 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7,
+ 0x1dd8, 0x1dd9, 0x1dda, 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5,
+ 0x1de6, 0x1dfe, 0x20d0, 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0,
+ 0x2cef, 0x2cf0, 0x2cf1, 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea,
+ 0x2deb, 0x2dec, 0x2ded, 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8,
+ 0x2df9, 0x2dfa, 0x2dfb, 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1,
+ 0xa8e2, 0xa8e3, 0xa8e4, 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef,
+ 0xa8f0, 0xa8f1, 0xaab0, 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23,
+ 0xfe24, 0xfe25, 0xfe26, 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac,
+ 0x1d1ad, 0x1d242, 0x1d243, 0x1d244,
+];
diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts
index 44665da85..b55f8bcb5 100644
--- a/packages/tui/src/index.ts
+++ b/packages/tui/src/index.ts
@@ -51,27 +51,30 @@ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "
export { ProcessTerminal, type Terminal } from "./terminal.js";
// Terminal image support
export {
- allocateImageId,
type CellDimensions,
calculateImageRows,
- deleteAllKittyImages,
- deleteKittyImage,
detectCapabilities,
encodeITerm2,
encodeKitty,
+ generatePlaceholderRows,
getCapabilities,
getCellDimensions,
getGifDimensions,
getImageDimensions,
getJpegDimensions,
+ getNextImageId,
getPngDimensions,
getWebpDimensions,
type ImageDimensions,
type ImageProtocol,
type ImageRenderOptions,
+ type ImageRenderResult,
imageFallback,
+ isInsideTmux,
+ isTmuxPassthroughEnabled,
renderImage,
resetCapabilitiesCache,
+ resetTmuxPassthroughCache,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.js";
diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts
index ef48e21b6..0a6eebed5 100644
--- a/packages/tui/src/terminal-image.ts
+++ b/packages/tui/src/terminal-image.ts
@@ -1,3 +1,6 @@
+import { execSync } from "node:child_process";
+import { KITTY_DIACRITICS } from "./diacritics.js";
+
export type ImageProtocol = "kitty" | "iterm2" | null;
export interface TerminalCapabilities {
@@ -20,8 +23,6 @@ export interface ImageRenderOptions {
maxWidthCells?: number;
maxHeightCells?: number;
preserveAspectRatio?: boolean;
- /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
- imageId?: number;
}
let cachedCapabilities: TerminalCapabilities | null = null;
@@ -82,13 +83,144 @@ export function resetCapabilitiesCache(): void {
}
/**
- * Generate a random image ID for Kitty graphics protocol.
- * Uses random IDs to avoid collisions between different module instances
- * (e.g., main app vs extensions).
+ * Check if we're running inside tmux.
+ */
+export function isInsideTmux(): boolean {
+ return !!process.env.TMUX;
+}
+
+/**
+ * Cache for tmux passthrough check.
+ * null = not checked yet, true/false = cached result
*/
-export function allocateImageId(): number {
- // Use random ID in range [1, 0xffffffff] to avoid collisions
- return Math.floor(Math.random() * 0xfffffffe) + 1;
+let tmuxPassthroughEnabled: boolean | null = null;
+
+/**
+ * Check if tmux has allow-passthrough enabled.
+ * This is required for images to work in tmux.
+ * Result is cached after first check.
+ */
+export function isTmuxPassthroughEnabled(): boolean {
+ if (!isInsideTmux()) {
+ return false;
+ }
+
+ if (tmuxPassthroughEnabled !== null) {
+ return tmuxPassthroughEnabled;
+ }
+
+ try {
+ const result = execSync("tmux show-options -gv allow-passthrough 2>/dev/null", {
+ encoding: "utf-8",
+ timeout: 1000,
+ }).trim();
+ // allow-passthrough can be "on", "all", or "off"
+ // "on" allows passthrough only for visible panes
+ // "all" allows passthrough for all panes including invisible ones
+ // Both "on" and "all" work for our purposes
+ tmuxPassthroughEnabled = result === "on" || result === "all";
+ } catch {
+ // If tmux command fails, assume passthrough is not enabled
+ tmuxPassthroughEnabled = false;
+ }
+
+ return tmuxPassthroughEnabled;
+}
+
+/**
+ * Reset the tmux passthrough cache.
+ * Useful for testing or when tmux config might have changed.
+ */
+export function resetTmuxPassthroughCache(): void {
+ tmuxPassthroughEnabled = null;
+}
+
+/**
+ * Wrap a sequence in tmux passthrough escapes.
+ * Inside tmux, escape sequences need to be wrapped so they pass through to the outer terminal.
+ * Format: \x1bPtmux;<escaped_sequence>\x1b\\
+ * Every \x1b inside the sequence must be doubled.
+ */
+function wrapTmuxPassthrough(sequence: string): string {
+ // Double every ESC (\x1b) in the sequence
+ const escaped = sequence.replace(/\x1b/g, "\x1b\x1b");
+ return `\x1bPtmux;${escaped}\x1b\\`;
+}
+
+/**
+ * Unicode placeholder character for Kitty graphics protocol.
+ * This character is in the Unicode Private Use Area and is used by terminals
+ * that support the Kitty graphics protocol to mark where images should appear.
+ */
+const KITTY_PLACEHOLDER = "\u{10EEEE}";
+
+/**
+ * Auto-incrementing image ID counter for Kitty graphics protocol.
+ * IDs must be non-zero, so we start at 1.
+ */
+let nextImageId = 1;
+
+/**
+ * Get the next available image ID.
+ */
+export function getNextImageId(): number {
+ const id = nextImageId;
+ nextImageId = (nextImageId % 0xffffff) + 1; // Wrap at 24 bits, skip 0
+ return id;
+}
+
+/**
+ * Encode image_id into RGB foreground color escape sequence.
+ * The image_id is encoded in the 24-bit RGB value.
+ */
+function encodeImageIdAsFgColor(imageId: number): string {
+ const r = (imageId >> 16) & 0xff;
+ const g = (imageId >> 8) & 0xff;
+ const b = imageId & 0xff;
+ return `\x1b[38;2;${r};${g};${b}m`;
+}
+
+/**
+ * Maximum row/column value that can be encoded with diacritics.
+ * The KITTY_DIACRITICS array has 297 entries (indices 0-296).
+ */
+const MAX_DIACRITIC_VALUE = KITTY_DIACRITICS.length - 1;
+
+/**
+ * Get the diacritic codepoint for a given row/column value.
+ * Returns the character for the diacritic at the given index.
+ */
+function getDiacritic(value: number): string {
+ if (value < 0 || value > MAX_DIACRITIC_VALUE) {
+ // Clamp to valid range
+ value = Math.max(0, Math.min(value, MAX_DIACRITIC_VALUE));
+ }
+ return String.fromCodePoint(KITTY_DIACRITICS[value]);
+}
+
+/**
+ * Generate unicode placeholder rows for an image.
+ * Uses inference optimization: only the first cell of each row includes the row diacritic,
+ * subsequent cells are just the placeholder character (row and column inferred from left cell).
+ * The foreground color encodes the image_id.
+ */
+export function generatePlaceholderRows(imageId: number, columns: number, rows: number): string[] {
+ // Clamp to valid range: at least 1, at most diacritic limit
+ const clampedRows = Math.max(1, Math.min(rows, MAX_DIACRITIC_VALUE + 1));
+ const clampedCols = Math.max(1, Math.min(columns, MAX_DIACRITIC_VALUE + 1));
+
+ const colorStart = encodeImageIdAsFgColor(imageId);
+ const colorEnd = "\x1b[39m"; // Reset foreground color
+
+ const result: string[] = [];
+ for (let row = 0; row < clampedRows; row++) {
+ // First cell: placeholder + row diacritic (column 0 inferred)
+ // Subsequent cells: just placeholder (row and column inferred from left)
+ const firstCell = KITTY_PLACEHOLDER + getDiacritic(row);
+ const otherCells = clampedCols > 1 ? KITTY_PLACEHOLDER.repeat(clampedCols - 1) : "";
+ result.push(`${colorStart}${firstCell}${otherCells}${colorEnd}`);
+ }
+ return result;
}
export function encodeKitty(
@@ -97,18 +229,29 @@ export function encodeKitty(
columns?: number;
rows?: number;
imageId?: number;
+ virtual?: boolean; // Use virtual placement (for tmux unicode placeholders)
} = {},
): string {
const CHUNK_SIZE = 4096;
- const params: string[] = ["a=T", "f=100", "q=2"];
+ const params: string[] = ["f=100", "q=2"];
+
+ // a=T means transmit and display
+ // U=1 enables unicode placeholder mode (virtual placement)
+ params.unshift("a=T");
+ if (options.virtual) {
+ params.push("U=1"); // Enable unicode placeholder mode
+ }
if (options.columns) params.push(`c=${options.columns}`);
if (options.rows) params.push(`r=${options.rows}`);
if (options.imageId) params.push(`i=${options.imageId}`);
+ const inTmux = isInsideTmux();
+
if (base64Data.length <= CHUNK_SIZE) {
- return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
+ const seq = `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
+ return inTmux ? wrapTmuxPassthrough(seq) : seq;
}
const chunks: string[] = [];
@@ -119,37 +262,23 @@ export function encodeKitty(
const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
const isLast = offset + CHUNK_SIZE >= base64Data.length;
+ let seq: string;
if (isFirst) {
- chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
+ seq = `\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`;
isFirst = false;
} else if (isLast) {
- chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
+ seq = `\x1b_Gm=0;${chunk}\x1b\\`;
} else {
- chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
+ seq = `\x1b_Gm=1;${chunk}\x1b\\`;
}
+ chunks.push(inTmux ? wrapTmuxPassthrough(seq) : seq);
offset += CHUNK_SIZE;
}
return chunks.join("");
}
-/**
- * Delete a Kitty graphics image by ID.
- * Uses uppercase 'I' to also free the image data.
- */
-export function deleteKittyImage(imageId: number): string {
- return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`;
-}
-
-/**
- * Delete all visible Kitty graphics images.
- * Uses uppercase 'A' to also free the image data.
- */
-export function deleteAllKittyImages(): string {
- return `\x1b_Ga=d,d=A\x1b\\`;
-}
-
export function encodeITerm2(
base64Data: string,
options: {
@@ -328,11 +457,18 @@ export function getImageDimensions(base64Data: string, mimeType: string): ImageD
return null;
}
+export interface ImageRenderResult {
+ sequence: string;
+ rows: number;
+ /** For tmux unicode placeholder mode: lines containing placeholder characters */
+ placeholderLines?: string[];
+}
+
export function renderImage(
base64Data: string,
imageDimensions: ImageDimensions,
options: ImageRenderOptions = {},
-): { sequence: string; rows: number; imageId?: number } | null {
+): ImageRenderResult | null {
const caps = getCapabilities();
if (!caps.images) {
@@ -343,9 +479,33 @@ export function renderImage(
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
if (caps.images === "kitty") {
- // Only use imageId if explicitly provided - static images don't need IDs
- const sequence = encodeKitty(base64Data, { columns: maxWidth, rows, imageId: options.imageId });
- return { sequence, rows, imageId: options.imageId };
+ const inTmux = isInsideTmux();
+
+ if (inTmux) {
+ // Check if passthrough is enabled - if not, fall back to text
+ if (!isTmuxPassthroughEnabled()) {
+ return null; // Will trigger fallback in Image component
+ }
+
+ // Clamp dimensions to diacritic limits for tmux unicode placeholder mode
+ const tmuxMaxWidth = Math.max(1, Math.min(maxWidth, MAX_DIACRITIC_VALUE + 1));
+ const tmuxRows = Math.max(1, Math.min(rows, MAX_DIACRITIC_VALUE + 1));
+
+ // Use virtual placement with unicode placeholders for tmux
+ const imageId = getNextImageId();
+ const sequence = encodeKitty(base64Data, {
+ columns: tmuxMaxWidth,
+ rows: tmuxRows,
+ imageId,
+ virtual: true,
+ });
+ const placeholderLines = generatePlaceholderRows(imageId, tmuxMaxWidth, tmuxRows);
+ return { sequence, rows: tmuxRows, placeholderLines };
+ } else {
+ // Direct placement for non-tmux (no diacritic limits)
+ const sequence = encodeKitty(base64Data, { columns: maxWidth, rows });
+ return { sequence, rows };
+ }
}
if (caps.images === "iterm2") {>gh pr diff 903 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 79d78c435..edb7f880a 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -1101,7 +1101,23 @@ export default function (pi: ExtensionAPI) {
pi.on("tool_result", async (event, ctx) => {
if (event.toolName === "read") {
// Redact secrets from file contents
- return { modifiedResult: event.result.replace(/API_KEY=[REDACTED] "API_KEY=[REDACTED] };
+ return {
+ content: event.content.map((item) =>
+ item.type === "text"
+ ? { ...item, text: item.text.replace(/API_KEY=[REDACTED] "API_KEY=[REDACTED] }
+ : item
+ ),
+ };
+ }
+
+ if (event.isError) {
+ // Override the thrown error message (optional)
+ return { content: [{ type: "text", text: "Custom error message" }], isError: true };
+ }
+
+ if (event.toolName === "bash" && event.content.length > 0) {
+ // Force a successful tool to be treated as an error
+ return { content: [{ type: "text", text: "Tool output rejected by policy" }], isError: true };
}
});
diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md
index 5149ef95b..44aec04b3 100644
--- a/packages/coding-agent/docs/extensions.md
+++ b/packages/coding-agent/docs/extensions.md
@@ -549,9 +549,34 @@ pi.on("tool_call", async (event, ctx) => {
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
+#### before_bash_exec
+
+Fired before a bash command executes (tool calls and user `!`/`!!`). Use it to rewrite commands or override execution settings. You can also block execution by returning `{ block: true, reason?: string }`. For follow-up hints based on output, pair this with `tool_result`.
+
+```typescript
+pi.on("before_bash_exec", async (event) => {
+ if (event.command.includes("rm -rf")) {
+ return { block: true, reason: "Blocked by policy" };
+ }
+
+ if (event.source === "tool") {
+ return {
+ cwd: "/tmp",
+ env: {
+ ...event.env,
+ MY_VAR: "1",
+ PATH: undefined, // remove PATH
+ },
+ };
+ }
+});
+```
+
+Return a `BashExecOverrides` object to override fields, or return `{ block: true, reason?: string }` to reject the command. Any field set to a non-undefined value replaces the original (`command`, `cwd`, `env`, `shell`, `args`, `timeout`). For `env`, set a key to `undefined` to remove it.
+
#### tool_result
-Fired after tool executes. **Can modify result.**
+Fired after tool executes. **Can modify result.** Use this to post-process outputs (for example, append hints or redact secrets) before the result is sent to the model.
```typescript
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
@@ -565,10 +590,12 @@ pi.on("tool_result", async (event, ctx) => {
}
// Modify result:
- return { content: [...], details: {...}, isError: false };
+ return { content: [...], details: {...} };
});
```
+If `event.isError` is true, return `{ content: [...], isError: true }` to override the thrown error message (the text content becomes the error string). Returning `isError: true` on a successful tool result forces the tool to be treated as an error.
+
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
### User Bash Events
diff --git a/packages/coding-agent/examples/extensions/uv.ts b/packages/coding-agent/examples/extensions/uv.ts
new file mode 100644
index 000000000..a1a240713
--- /dev/null
+++ b/packages/coding-agent/examples/extensions/uv.ts
@@ -0,0 +1,74 @@
+/**
+ * uv Python Interceptor
+ *
+ * Demonstrates before_bash_exec by redirecting python invocations through uv.
+ * This is a simple example that assumes basic whitespace-separated arguments.
+ *
+ * Usage:
+ * pi -e examples/extensions/uv.ts
+ */
+
+import { type ExtensionAPI, isBashToolResult } from "@mariozechner/pi-coding-agent";
+
+const PYTHON_PREFIX = /^python3?(\s+|$)/;
+const UV_RUN_PYTHON_PREFIX = /^uv\s+run\s+python3?(\s+|$)/;
+const PIP_PREFIX = /^pip3?(\s+|$)/;
+const PIP_MODULE_PATTERN = /\s-m\s+pip3?(\s|$)/;
+const TRACEBACK_PATTERN = /Traceback \(most recent call last\):/;
+const IMPORT_ERROR_PATTERN = /\b(ModuleNotFoundError|ImportError):/;
+const MODULE_NOT_FOUND_PATTERN = /No module named ['"]([^'"]+)['"]/;
+
+const PIP_BLOCK_REASON =
+ "pip is disabled. Use uv run instead, particularly --with and --script for throwaway work. Do not use uv pip!";
+
+export default function (pi: ExtensionAPI) {
+ pi.on("before_bash_exec", (event) => {
+ const trimmed = event.originalCommand.trim();
+ const isPythonCommand = PYTHON_PREFIX.test(trimmed);
+ const isUvRunPythonCommand = UV_RUN_PYTHON_PREFIX.test(trimmed);
+ const isPipModule = PIP_MODULE_PATTERN.test(trimmed);
+
+ if (PIP_PREFIX.test(trimmed) || (isPipModule && (isPythonCommand || isUvRunPythonCommand))) {
+ return {
+ block: true,
+ reason: PIP_BLOCK_REASON,
+ };
+ }
+
+ if (!isPythonCommand) {
+ return;
+ }
+
+ const normalizedCommand = trimmed.replace(PYTHON_PREFIX, "python ").trimEnd();
+ const uvCommand = `uv run ${normalizedCommand}`;
+
+ return {
+ command: uvCommand,
+ };
+ });
+
+ pi.on("tool_result", (event) => {
+ if (!isBashToolResult(event)) return;
+
+ const text = event.content
+ .filter((item) => item.type === "text")
+ .map((item) => item.text)
+ .join("");
+
+ if (!TRACEBACK_PATTERN.test(text) || !IMPORT_ERROR_PATTERN.test(text)) {
+ return;
+ }
+
+ const moduleMatch = text.match(MODULE_NOT_FOUND_PATTERN);
+ const moduleName = moduleMatch?.[1];
+ const hintTarget = moduleName ? ` --with ${moduleName}` : "";
+ const hint =
+ "\n\nHint: Python import failed. Use uv to fetch dependencies automatically without changing the system, " +
+ `e.g. \`uv run${hintTarget} python -c '...'\` or \`uv run --script\` for throwaway scripts.`;
+
+ return {
+ content: [...event.content, { type: "text", text: hint }],
+ isError: true,
+ };
+ });
+}
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index f5c86f033..de4cba355 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -27,6 +27,7 @@ import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/
import { getAuthPath } from "../config.js";
import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
+import { getShellConfig, getShellEnv } from "../utils/shell.js";
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
import {
type CompactionResult,
@@ -41,6 +42,7 @@ import {
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import {
+ type BeforeBashExecEvent,
type ContextUsage,
type ExtensionCommandContextActions,
type ExtensionErrorListener,
@@ -2018,22 +2020,54 @@ export class AgentSession {
): Promise<BashResult> {
this._bashAbortController = new AbortController();
- // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
- const prefix = this.settingsManager.getShellCommandPrefix();
- const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
-
try {
+ // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
+ const prefix = this.settingsManager.getShellCommandPrefix();
+ const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
+ const shellConfig = getShellConfig();
+ const baseEvent: BeforeBashExecEvent = {
+ type: "before_bash_exec",
+ source: "user_bash",
+ command: resolvedCommand,
+ originalCommand: command,
+ cwd: process.cwd(),
+ env: { ...getShellEnv() },
+ shell: shellConfig.shell,
+ args: [...shellConfig.args],
+ };
+ const execEvent = this._extensionRunner?.hasHandlers("before_bash_exec")
+ ? await this._extensionRunner.emitBeforeBashExec(baseEvent)
+ : baseEvent;
+ const execCommand = execEvent.command;
+ const execCwd = execEvent.cwd;
+ const execEnv = execEvent.env;
+ const execShell = execEvent.shell;
+ const execArgs = execEvent.args;
+ const execTimeout = execEvent.timeout;
+
const result = options?.operations
- ? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, {
+ ? await executeBashWithOperations(execCommand, execCwd, options.operations, {
onChunk,
signal: this._bashAbortController.signal,
+ env: execEnv,
+ shell: execShell,
+ args: execArgs,
+ timeout: execTimeout,
})
- : await executeBashCommand(resolvedCommand, {
+ : await executeBashCommand(execCommand, {
onChunk,
signal: this._bashAbortController.signal,
+ cwd: execCwd,
+ env: execEnv,
+ shell: execShell,
+ args: execArgs,
+ timeout: execTimeout,
});
- this.recordBashResult(command, result, options);
+ this.recordBashResult(command, result, {
+ excludeFromContext: options?.excludeFromContext,
+ executedCommand: execCommand === command ? undefined : execCommand,
+ });
return result;
} finally {
this._bashAbortController = undefined;
@@ -2044,10 +2078,15 @@ export class AgentSession {
* Record a bash execution result in session history.
* Used by executeBash and by extensions that handle bash execution themselves.
*/
- recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
+ recordBashResult(
+ command: string,
+ result: BashResult,
+ options?: { excludeFromContext?: boolean; executedCommand?: string },
+ ): void {
const bashMessage: BashExecutionMessage = {
role: "bashExecution",
command,
+ executedCommand: options?.executedCommand,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts
index b24982186..0d753426f 100644
--- a/packages/coding-agent/src/core/bash-executor.ts
+++ b/packages/coding-agent/src/core/bash-executor.ts
@@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { type ChildProcess, spawn } from "child_process";
import stripAnsi from "strip-ansi";
-import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
+import { killProcessTree, resolveShellExecutionOptions, sanitizeBinaryOutput } from "../utils/shell.js";
import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
@@ -25,6 +25,16 @@ export interface BashExecutorOptions {
onChunk?: (chunk: string) => void;
/** AbortSignal for cancellation */
signal?: AbortSignal;
+ /** Working directory override */
+ cwd?: string;
+ /** Environment override */
+ env?: NodeJS.ProcessEnv;
+ /** Shell executable override */
+ shell?: string;
+ /** Shell argument override */
+ args?: string[];
+ /** Timeout in seconds */
+ timeout?: number;
}
export interface BashResult {
@@ -60,13 +70,30 @@ export interface BashResult {
*/
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
return new Promise((resolve, reject) => {
- const { shell, args } = getShellConfig();
- const child: ChildProcess = spawn(shell, [...args, command], {
+ const resolvedCwd = options?.cwd ?? process.cwd();
+ const { resolvedShell, resolvedArgs, resolvedEnv } = resolveShellExecutionOptions({
+ shell: options?.shell,
+ args: options?.args,
+ env: options?.env,
+ });
+ const child: ChildProcess = spawn(resolvedShell, [...resolvedArgs, command], {
+ cwd: resolvedCwd,
+ env: resolvedEnv,
detached: true,
- env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
+ let timedOut = false;
+ let timeoutHandle: NodeJS.Timeout | undefined;
+ if (options?.timeout !== undefined && options.timeout > 0) {
+ timeoutHandle = setTimeout(() => {
+ timedOut = true;
+ if (child.pid) {
+ killProcessTree(child.pid);
+ }
+ }, options.timeout * 1000);
+ }
+
// Track sanitized output for truncation
const outputChunks: string[] = [];
let outputBytes = 0;
@@ -88,6 +115,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
if (options.signal.aborted) {
// Already aborted, don't even start
child.kill();
+ if (timeoutHandle) {
+ clearTimeout(timeoutHandle);
+ }
resolve({
output: "",
exitCode: undefined,
@@ -144,6 +174,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
if (options?.signal) {
options.signal.removeEventListener("abort", abortHandler);
}
+ if (timeoutHandle) {
+ clearTimeout(timeoutHandle);
+ }
if (tempFileStream) {
tempFileStream.end();
@@ -153,8 +186,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
- // code === null means killed (cancelled)
- const cancelled = code === null;
+ const cancelled = code === null || timedOut;
resolve({
output: truncationResult.truncated ? truncationResult.content : fullOutput,
@@ -170,6 +202,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
if (options?.signal) {
options.signal.removeEventListener("abort", abortHandler);
}
+ if (timeoutHandle) {
+ clearTimeout(timeoutHandle);
+ }
if (tempFileStream) {
tempFileStream.end();
@@ -238,6 +273,10 @@ export async function executeBashWithOperations(
const result = await operations.exec(command, cwd, {
onData,
signal: options?.signal,
+ timeout: options?.timeout,
+ env: options?.env,
+ shell: options?.shell,
+ args: options?.args,
});
if (tempFileStream) {
diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts
index d10d0a1a5..d3ccbe248 100644
--- a/packages/coding-agent/src/core/extensions/index.ts
+++ b/packages/coding-agent/src/core/extensions/index.ts
@@ -25,9 +25,14 @@ export type {
// App keybindings (for custom editors)
AppAction,
AppendEntryHandler,
+ BashExecEvent,
+ BashExecOverrides,
+ BashExecSource,
BashToolResultEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
+ BeforeBashExecEvent,
+ BeforeBashExecEventResult,
// Context
CompactOptions,
// Events - Agent
diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts
index 289400a0b..31eb0410d 100644
--- a/packages/coding-agent/src/core/extensions/runner.ts
+++ b/packages/coding-agent/src/core/extensions/runner.ts
@@ -10,8 +10,12 @@ import type { KeyAction, KeybindingsConfig } from "../keybindings.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type {
+ BashExecBlockResult,
+ BashExecOverrides,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
+ BeforeBashExecEvent,
+ BeforeBashExecEventResult,
CompactOptions,
ContextEvent,
ContextEventResult,
@@ -83,6 +87,34 @@ const buildBuiltinKeybindings = (effectiveKeybindings: Required<KeybindingsConfi
return builtinKeybindings;
};
+const applyBashExecOverrides = (event: BeforeBashExecEvent, overrides: BashExecOverrides): BeforeBashExecEvent => {
+ let nextEnv = event.env;
+ if (overrides.env) {
+ nextEnv = { ...event.env };
+ for (const [key, value] of Object.entries(overrides.env)) {
+ if (value === undefined) {
+ delete nextEnv[key];
+ } else {
+ nextEnv[key] = value;
+ }
+ }
+ }
+
+ return {
+ ...event,
+ command: overrides.command ?? event.command,
+ cwd: overrides.cwd ?? event.cwd,
+ env: nextEnv,
+ shell: overrides.shell ?? event.shell,
+ args: overrides.args ?? event.args,
+ timeout: overrides.timeout ?? event.timeout,
+ };
+};
+
+const isBashExecBlockResult = (result: BeforeBashExecEventResult): result is BashExecBlockResult => {
+ return "block" in result && result.block;
+};
+
/** Combined result from all before_agent_start handlers */
interface BeforeAgentStartCombinedResult {
messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
@@ -474,6 +506,50 @@ export class ExtensionRunner {
return result;
}
+ async emitBeforeBashExec(event: BeforeBashExecEvent): Promise<BeforeBashExecEvent> {
+ const ctx = this.createContext();
+ let currentEvent: BeforeBashExecEvent = {
+ ...event,
+ env: { ...event.env },
+ args: [...event.args],
+ };
+
+ for (const ext of this.extensions) {
+ const handlers = ext.handlers.get("before_bash_exec");
+ if (!handlers || handlers.length === 0) continue;
+
+ for (const handler of handlers) {
+ let handlerResult: unknown;
+ try {
+ handlerResult = await handler(currentEvent, ctx);
+ } 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_bash_exec",
+ error: message,
+ stack,
+ });
+ continue;
+ }
+
+ if (!handlerResult) {
+ continue;
+ }
+
+ const overrideResult = handlerResult as BeforeBashExecEventResult;
+ if (isBashExecBlockResult(overrideResult)) {
+ const reason = overrideResult.reason ?? "Bash execution was blocked by an extension";
+ throw new Error(reason);
+ }
+ currentEvent = applyBashExecOverrides(currentEvent, overrideResult);
+ }
+ }
+
+ return currentEvent;
+ }
+
async emitUserBash(event: UserBashEvent): Promise<UserBashEventResult | undefined> {
const ctx = this.createContext();
diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts
index 2a058160e..e0c5bbe89 100644
--- a/packages/coding-agent/src/core/extensions/types.ts
+++ b/packages/coding-agent/src/core/extensions/types.ts
@@ -488,6 +488,43 @@ export interface UserBashEvent {
cwd: string;
}
+// ============================================================================
+// Bash Execution Events
+// ============================================================================
+
+export type BashExecSource = "tool" | "user_bash";
+
+export interface BashExecOverrides {
+ command?: string;
+ cwd?: string;
+ env?: Record<string, string | undefined>;
+ shell?: string;
+ args?: string[];
+ timeout?: number;
+}
+
+export interface BashExecBlockResult {
+ block: true;
+ reason?: string;
+}
+
+export interface BashExecEvent {
+ source: BashExecSource;
+ command: string;
+ originalCommand: string;
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+ shell: string;
+ args: string[];
+ toolCallId?: string;
+ timeout?: number;
+}
+
+/** Fired before spawning a bash command (tool + user bash). */
+export interface BeforeBashExecEvent extends BashExecEvent {
+ type: "before_bash_exec";
+}
+
// ============================================================================
// Input Events
// ============================================================================
@@ -617,6 +654,7 @@ export type ExtensionEvent =
| TurnEndEvent
| ModelSelectEvent
| UserBashEvent
+ | BeforeBashExecEvent
| InputEvent
| ToolCallEvent
| ToolResultEvent;
@@ -642,6 +680,9 @@ export interface UserBashEventResult {
result?: BashResult;
}
+/** Result from before_bash_exec event handler */
+export type BeforeBashExecEventResult = BashExecOverrides | BashExecBlockResult;
+
export interface ToolResultEventResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
@@ -749,6 +790,7 @@ export interface ExtensionAPI {
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
+ on(event: "before_bash_exec", handler: ExtensionHandler<BeforeBashExecEvent, BeforeBashExecEventResult>): void;
on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
// =========================================================================
diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts
index 0626afaf5..f38293e75 100644
--- a/packages/coding-agent/src/core/extensions/wrapper.ts
+++ b/packages/coding-agent/src/core/extensions/wrapper.ts
@@ -3,8 +3,10 @@
*/
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
+import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
+import { getShellConfig, getShellEnv } from "../../utils/shell.js";
import type { ExtensionRunner } from "./runner.js";
-import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types.js";
+import type { BeforeBashExecEvent, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types.js";
/**
* Wrap a RegisteredTool into an AgentTool.
@@ -36,6 +38,60 @@ export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: E
* - Emits tool_result event after execution (can modify result)
*/
export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: ExtensionRunner): AgentTool<any, T> {
+ type BashToolParams = {
+ command: string;
+ timeout?: number;
+ };
+ type BashExecParams = BashToolParams & {
+ cwd?: string;
+ env?: NodeJS.ProcessEnv;
+ shell?: string;
+ args?: string[];
+ };
+ const applyBeforeBashExecOverrides = async (
+ toolCallId: string,
+ params: BashToolParams,
+ runner: ExtensionRunner,
+ ): Promise<BashExecParams> => {
+ const shellConfig = getShellConfig();
+ const context = runner.createContext();
+ const baseEvent: BeforeBashExecEvent = {
+ type: "before_bash_exec",
+ source: "tool",
+ command: params.command,
+ originalCommand: params.command,
+ cwd: context.cwd,
+ env: { ...getShellEnv() },
+ shell: shellConfig.shell,
+ args: [...shellConfig.args],
+ toolCallId,
+ timeout: params.timeout,
+ };
+ const execEvent = await runner.emitBeforeBashExec(baseEvent);
+ return {
+ ...params,
+ command: execEvent.command,
+ cwd: execEvent.cwd,
+ env: execEvent.env,
+ shell: execEvent.shell,
+ args: execEvent.args,
+ timeout: execEvent.timeout,
+ };
+ };
+ const toolResultContentToErrorMessage = (
+ content: (TextContent | ImageContent)[] | undefined,
+ fallback: string,
+ ): string => {
+ if (!content || content.length === 0) return fallback;
+ const text = content
+ .filter((item): item is TextContent => item.type === "text" && !!item.text)
+ .map((item) => item.text)
+ .join("")
+ .trim();
+ if (text) return text;
+ return `${fallback} [non-text content]`;
+ };
+
return {
...tool,
execute: async (
@@ -44,6 +100,9 @@ export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: Exten
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<T>,
) => {
+ let effectiveParams = params;
+ let forcedError = false;
+
// Emit tool_call event - extensions can block execution
if (runner.hasHandlers("tool_call")) {
try {
@@ -66,9 +125,13 @@ export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: Exten
}
}
+ if (tool.name === "bash" && runner.hasHandlers("before_bash_exec")) {
+ effectiveParams = await applyBeforeBashExecOverrides(toolCallId, params as BashToolParams, runner);
+ }
+
// Execute the actual tool
try {
- const result = await tool.execute(toolCallId, params, signal, onUpdate);
+ const result = await tool.execute(toolCallId, effectiveParams, signal, onUpdate);
// Emit tool_result event - extensions can modify the result
if (runner.hasHandlers("tool_result")) {
@@ -76,33 +139,53 @@ export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: Exten
type: "tool_result",
toolName: tool.name,
toolCallId,
- input: params,
+ input: effectiveParams,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
if (resultResult) {
+ const nextContent = resultResult.content ?? result.content;
+ const nextDetails = (resultResult.details ?? result.details) as T;
+ if (resultResult.isError) {
+ forcedError = true;
+ throw new Error(toolResultContentToErrorMessage(nextContent, "Tool execution failed."));
+ }
+
return {
- content: resultResult.content ?? result.content,
- details: (resultResult.details ?? result.details) as T,
+ content: nextContent,
+ details: nextDetails,
};
}
}
return result;
} catch (err) {
+ if (forcedError) {
+ throw err;
+ }
// Emit tool_result event for errors
if (runner.hasHandlers("tool_result")) {
- await runner.emit({
+ const fallbackMessage = err instanceof Error ? err.message : String(err);
+ const content = [{ type: "text" as const, text: fallbackMessage }];
+ const resultResult = (await runner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
- input: params,
- content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
+ input: effectiveParams,
+ content,
details: undefined,
isError: true,
- });
+ })) as ToolResultEventResult | undefined;
+
+ if (resultResult) {
+ if (!resultResult.isError) {
+ throw err;
+ }
+ const nextContent = resultResult.content ?? content;
+ throw new Error(toolResultContentToErrorMessage(nextContent, fallbackMessage));
+ }
}
throw err;
}
diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts
index f5a645e66..24106f3b4 100644
--- a/packages/coding-agent/src/core/messages.ts
+++ b/packages/coding-agent/src/core/messages.ts
@@ -29,6 +29,7 @@ export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
export interface BashExecutionMessage {
role: "bashExecution";
command: string;
+ executedCommand?: string;
output: string;
exitCode: number | undefined;
cancelled: boolean;
diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts
index 3c312e52b..b9ea6a9e2 100644
--- a/packages/coding-agent/src/core/tools/bash.ts
+++ b/packages/coding-agent/src/core/tools/bash.ts
@@ -5,7 +5,7 @@ import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
-import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
+import { killProcessTree, resolveShellExecutionOptions } from "../../utils/shell.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
@@ -45,6 +45,9 @@ export interface BashOperations {
onData: (data: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
+ env?: NodeJS.ProcessEnv;
+ shell?: string;
+ args?: string[];
},
) => Promise<{ exitCode: number | null }>;
}
@@ -53,19 +56,23 @@ export interface BashOperations {
* Default bash operations using local shell
*/
const defaultBashOperations: BashOperations = {
- exec: (command, cwd, { onData, signal, timeout }) => {
+ exec: (command, cwd, { onData, signal, timeout, env, shell, args }) => {
return new Promise((resolve, reject) => {
- const { shell, args } = getShellConfig();
+ const { resolvedShell, resolvedArgs, resolvedEnv } = resolveShellExecutionOptions({
+ shell,
+ args,
+ env,
+ });
if (!existsSync(cwd)) {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
- const child = spawn(shell, [...args, command], {
+ const child = spawn(resolvedShell, [...resolvedArgs, command], {
cwd,
+ env: resolvedEnv,
detached: true,
- env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
@@ -151,12 +158,27 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
parameters: bashSchema,
execute: async (
_toolCallId: string,
- { command, timeout }: { command: string; timeout?: number },
+ {
+ command,
+ timeout,
+ cwd: overrideCwd,
+ env,
+ shell,
+ args,
+ }: {
+ command: string;
+ timeout?: number;
+ cwd?: string;
+ env?: NodeJS.ProcessEnv;
+ shell?: string;
+ args?: string[];
+ },
signal?: AbortSignal,
onUpdate?,
) => {
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
+ const resolvedCwd = overrideCwd ?? cwd;
return new Promise((resolve, reject) => {
// We'll stream to a temp file if output gets large
@@ -213,7 +235,7 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
}
};
- ops.exec(resolvedCommand, cwd, { onData: handleData, signal, timeout })
+ ops.exec(resolvedCommand, resolvedCwd, { onData: handleData, signal, timeout, env, shell, args })
.then(({ exitCode }) => {
// Close temp file stream
if (tempFileStream) {
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index ecf08a77e..dfdb79d38 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -44,7 +44,12 @@ export type {
AgentToolResult,
AgentToolUpdateCallback,
AppAction,
+ BashExecEvent,
+ BashExecOverrides,
+ BashExecSource,
BeforeAgentStartEvent,
+ BeforeBashExecEvent,
+ BeforeBashExecEventResult,
CompactOptions,
ContextEvent,
ContextUsage,
diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts
index 62ff558af..3c157c8d3 100644
--- a/packages/coding-agent/src/utils/shell.ts
+++ b/packages/coding-agent/src/utils/shell.ts
@@ -1,8 +1,7 @@
import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process";
-import { getSettingsPath } from "../config.js";
-import { getBinDir } from "../config.js";
+import { getBinDir, getSettingsPath } from "../config.js";
import { SettingsManager } from "../core/settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
@@ -111,6 +110,34 @@ export function getShellEnv(): NodeJS.ProcessEnv {
};
}
+export interface ShellExecutionOptions {
+ shell?: string;
+ args?: string[];
+ env?: NodeJS.ProcessEnv;
+}
+
+export interface ResolvedShellExecutionOptions {
+ resolvedShell: string;
+ resolvedArgs: string[];
+ resolvedEnv: NodeJS.ProcessEnv;
+}
+
+export function resolveShellExecutionOptions(options?: ShellExecutionOptions): ResolvedShellExecutionOptions {
+ const shellConfig = getShellConfig();
+ const resolvedEnv = options?.env ? { ...options.env } : { ...getShellEnv() };
+ for (const [key, value] of Object.entries(resolvedEnv)) {
+ if (value === undefined) {
+ delete resolvedEnv[key];
+ }
+ }
+
+ return {
+ resolvedShell: options?.shell ?? shellConfig.shell,
+ resolvedArgs: options?.args ?? shellConfig.args,
+ resolvedEnv,
+ };
+}
+
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts
index 851633d22..83bfc44ce 100644
--- a/packages/coding-agent/test/tools.test.ts
+++ b/packages/coding-agent/test/tools.test.ts
@@ -290,9 +290,10 @@ describe("Coding Agent Tools", () => {
});
it("should handle process spawn errors", async () => {
- vi.spyOn(shellModule, "getShellConfig").mockReturnValueOnce({
- shell: "/nonexistent-shell-path-xyz123",
- args: ["-c"],
+ vi.spyOn(shellModule, "resolveShellExecutionOptions").mockReturnValueOnce({
+ resolvedShell: "/nonexistent-shell-path-xyz123",
+ resolvedArgs: ["-c"],
+ resolvedEnv: shellModule.getShellEnv(),
});
const bashWithBadShell = createBashTool(testDir);>gh pr diff 899 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts
index 075de86c8..57e691d3c 100644
--- a/packages/agent/test/e2e.test.ts
+++ b/packages/agent/test/e2e.test.ts
@@ -302,8 +302,8 @@ describe("Agent E2E Tests", () => {
});
});
- describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air)", () => {
- const model = getModel("zai", "glm-4.5-air");
+ describe.skipIf(!process.env.ZAI_API_KEY)("zAI Coding Plan Provider (glm-4.5-air)", () => {
+ const model = getModel("zai-coding-plan", "glm-4.5-air");
it("should handle basic text prompt", async () => {
await basicPrompt(model);
diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md
index 7ecdc53aa..09bb48fb7 100644
--- a/packages/ai/CHANGELOG.md
+++ b/packages/ai/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Breaking Changes
+
+- Renamed `zai` provider to `zai-coding-plan` to align with naming in models.dev and be explicit about which endpoint we use
+
## [0.49.3] - 2026-01-22
### Added
diff --git a/packages/ai/README.md b/packages/ai/README.md
index c033da383..313636124 100644
--- a/packages/ai/README.md
+++ b/packages/ai/README.md
@@ -55,6 +55,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- **Groq**
- **Cerebras**
- **xAI**
+- **zAI Coding Plan**
- **OpenRouter**
- **Vercel AI Gateway**
- **MiniMax**
@@ -883,7 +884,7 @@ In Node.js environments, you can set environment variables to avoid passing API
| xAI | `XAI_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` |
-| zAI | `ZAI_API_KEY` |
+| zAI Coding Plan | `ZAI_API_KEY` |
| MiniMax | `MINIMAX_API_KEY` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts
index 41e34de45..f13ff9a0e 100644
--- a/packages/ai/scripts/generate-models.ts
+++ b/packages/ai/scripts/generate-models.ts
@@ -417,33 +417,33 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
- // Process zAi models
- if (data.zai?.models) {
- for (const [modelId, model] of Object.entries(data.zai.models)) {
+ // Process zAi Coding Plan models
+ if (data["zai-coding-plan"]?.models) {
+ for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const supportsImage = m.modalities?.input?.includes("image")
models.push({
- id: modelId,
- name: m.name || modelId,
- api: "openai-completions",
- provider: "zai",
- baseUrl: "https://api.z.ai/api/coding/paas/v4",
- reasoning: m.reasoning === true,
- input: supportsImage ? ["text", "image"] : ["text"],
- cost: {
- input: m.cost?.input || 0,
- output: m.cost?.output || 0,
- cacheRead: m.cost?.cache_read || 0,
- cacheWrite: m.cost?.cache_write || 0,
- },
- compat: {
- supportsDeveloperRole: false,
- thinkingFormat: "zai",
- },
- contextWindow: m.limit?.context || 4096,
- maxTokens: m.limit?.output || 4096,
+ id: modelId,
+ name: m.name || modelId,
+ api: "openai-completions",
+ provider: "zai-coding-plan",
+ baseUrl: "https://api.z.ai/api/coding/paas/v4",
+ reasoning: m.reasoning === true,
+ input: supportsImage ? ["text", "image"] : ["text"],
+ cost: {
+ input: m.cost?.input || 0,
+ output: m.cost?.output || 0,
+ cacheRead: m.cost?.cache_read || 0,
+ cacheWrite: m.cost?.cache_write || 0,
+ },
+ compat: {
+ supportsDeveloperRole: false,
+ thinkingFormat: "zai",
+ },
+ contextWindow: m.limit?.context || 4096,
+ maxTokens: m.limit?.output || 4096,
});
}
}
diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts
index f41f8c41c..23abedede 100644
--- a/packages/ai/src/models.generated.ts
+++ b/packages/ai/src/models.generated.ts
@@ -4374,7 +4374,7 @@ export const MODELS = {
input: ["text"],
cost: {
input: 0.09,
- output: 0.39999999999999997,
+ output: 0.44999999999999996,
cacheRead: 0,
cacheWrite: 0,
},
@@ -5056,7 +5056,7 @@ export const MODELS = {
input: 0.09999999999999999,
output: 0.39999999999999997,
cacheRead: 0.024999999999999998,
- cacheWrite: 0.0833,
+ cacheWrite: 0.08333333333333334,
},
contextWindow: 1048576,
maxTokens: 8192,
@@ -5124,7 +5124,7 @@ export const MODELS = {
input: 0.09999999999999999,
output: 0.39999999999999997,
cacheRead: 0.01,
- cacheWrite: 0.0833,
+ cacheWrite: 0.08333333333333334,
},
contextWindow: 1048576,
maxTokens: 65535,
@@ -5141,7 +5141,7 @@ export const MODELS = {
input: 0.09999999999999999,
output: 0.39999999999999997,
cacheRead: 0.01,
- cacheWrite: 0.0833,
+ cacheWrite: 0.08333333333333334,
},
contextWindow: 1048576,
maxTokens: 65535,
@@ -5158,7 +5158,7 @@ export const MODELS = {
input: 0.3,
output: 2.5,
cacheRead: 0.03,
- cacheWrite: 0.0833,
+ cacheWrite: 0.08333333333333334,
},
contextWindow: 1048576,
maxTokens: 65535,
@@ -7271,23 +7271,6 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
- "qwen/qwen2.5-vl-72b-instruct": {
- id: "qwen/qwen2.5-vl-72b-instruct",
- name: "Qwen: Qwen2.5 VL 72B Instruct",
- api: "openai-completions",
- provider: "openrouter",
- baseUrl: "https://openrouter.ai/api/v1",
- reasoning: false,
- input: ["text", "image"],
- cost: {
- input: 0.15,
- output: 0.6,
- cacheRead: 0,
- cacheWrite: 0,
- },
- contextWindow: 32768,
- maxTokens: 32768,
- } satisfies Model<"openai-completions">,
"qwen/qwen3-14b": {
id: "qwen/qwen3-14b",
name: "Qwen: Qwen3 14B",
@@ -8372,7 +8355,7 @@ export const MODELS = {
cost: {
input: 1,
output: 5,
- cacheRead: 0,
+ cacheRead: 0.19999999999999998,
cacheWrite: 0,
},
contextWindow: 1000000,
@@ -10208,7 +10191,7 @@ export const MODELS = {
cost: {
input: 0.19999999999999998,
output: 1.1,
- cacheRead: 0,
+ cacheRead: 0.03,
cacheWrite: 0,
},
contextWindow: 128000,
@@ -10693,20 +10676,20 @@ export const MODELS = {
maxTokens: 4096,
} satisfies Model<"openai-completions">,
},
- "zai": {
+ "zai-coding-plan": {
"glm-4.5": {
id: "glm-4.5",
name: "GLM-4.5",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
input: ["text"],
cost: {
- input: 0.6,
- output: 2.2,
- cacheRead: 0.11,
+ input: 0,
+ output: 0,
+ cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
@@ -10716,15 +10699,15 @@ export const MODELS = {
id: "glm-4.5-air",
name: "GLM-4.5-Air",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
input: ["text"],
cost: {
- input: 0.2,
- output: 1.1,
- cacheRead: 0.03,
+ input: 0,
+ output: 0,
+ cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
@@ -10734,7 +10717,7 @@ export const MODELS = {
id: "glm-4.5-flash",
name: "GLM-4.5-Flash",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
@@ -10752,14 +10735,14 @@ export const MODELS = {
id: "glm-4.5v",
name: "GLM-4.5V",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
input: ["text", "image"],
cost: {
- input: 0.6,
- output: 1.8,
+ input: 0,
+ output: 0,
cacheRead: 0,
cacheWrite: 0,
},
@@ -10770,15 +10753,15 @@ export const MODELS = {
id: "glm-4.6",
name: "GLM-4.6",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
input: ["text"],
cost: {
- input: 0.6,
- output: 2.2,
- cacheRead: 0.11,
+ input: 0,
+ output: 0,
+ cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 204800,
@@ -10788,14 +10771,14 @@ export const MODELS = {
id: "glm-4.6v",
name: "GLM-4.6V",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
input: ["text", "image"],
cost: {
- input: 0.3,
- output: 0.9,
+ input: 0,
+ output: 0,
cacheRead: 0,
cacheWrite: 0,
},
@@ -10806,19 +10789,37 @@ export const MODELS = {
id: "glm-4.7",
name: "GLM-4.7",
api: "openai-completions",
- provider: "zai",
+ provider: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
reasoning: true,
input: ["text"],
cost: {
- input: 0.6,
- output: 2.2,
- cacheRead: 0.11,
+ input: 0,
+ output: 0,
+ cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
+ "glm-4.7-flash": {
+ id: "glm-4.7-flash",
+ name: "GLM-4.7-Flash",
+ api: "openai-completions",
+ provider: "zai-coding-plan",
+ baseUrl: "https://api.z.ai/api/coding/paas/v4",
+ compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai"},
+ reasoning: true,
+ input: ["text"],
+ cost: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ },
+ contextWindow: 200000,
+ maxTokens: 131072,
+ } satisfies Model<"openai-completions">,
},
} as const;
diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts
index 42576f0fa..737fa7c5a 100644
--- a/packages/ai/src/providers/openai-completions.ts
+++ b/packages/ai/src/providers/openai-completions.ts
@@ -102,7 +102,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
const client = createClient(model, context, apiKey, options?.headers);
const params = buildParams(model, context, options);
options?.onPayload?.(params);
- const openaiStream = await client.chat.completions.create(params, { signal: options?.signal });
+ const openaiStream = await client.chat.completions.create(params, {
+ signal: options?.signal,
+ });
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | (ToolCall & { partialArgs?: string }) | null = null;
@@ -180,7 +182,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
- stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
+ stream.push({
+ type: "text_start",
+ contentIndex: blockIndex(),
+ partial: output,
+ });
}
if (currentBlock.type === "text") {
@@ -222,7 +228,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
thinkingSignature: foundReasoningField,
};
output.content.push(currentBlock);
- stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
+ stream.push({
+ type: "thinking_start",
+ contentIndex: blockIndex(),
+ partial: output,
+ });
}
if (currentBlock.type === "thinking") {
@@ -253,7 +263,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
partialArgs: "",
};
output.content.push(currentBlock);
- stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
+ stream.push({
+ type: "toolcall_start",
+ contentIndex: blockIndex(),
+ partial: output,
+ });
}
if (currentBlock.type === "toolCall") {
@@ -417,7 +431,9 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
if (compat.thinkingFormat === "zai" && model.reasoning) {
// Z.ai uses binary thinking: { type: "enabled" | "disabled" }
// Must explicitly disable since z.ai defaults to thinking enabled
- (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
+ (params as any).thinking = {
+ type: options?.reasoningEffort ? "enabled" : "disabled",
+ };
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
params.reasoning_effort = options.reasoningEffort;
@@ -481,7 +497,10 @@ function convertMessages(
if (context.systemPrompt) {
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
const role = useDeveloperRole ? "developer" : "system";
- params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) });
+ params.push({
+ role: role,
+ content: sanitizeSurrogates(context.systemPrompt),
+ });
}
let lastRole: string | null = null;
@@ -557,7 +576,10 @@ function convertMessages(
if (compat.requiresThinkingAsText) {
// Convert thinking blocks to plain text (no tags to avoid model mimicking them)
const thinkingText = nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n\n");
- const textContent = assistantMsg.content as Array<{ type: "text"; text: string }> | null;
+ const textContent = assistantMsg.content as Array<{
+ type: "text";
+ text: string;
+ }> | null;
if (textContent) {
textContent.unshift({ type: "text", text: thinkingText });
} else {
diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts
index c3804617d..94129fdff 100644
--- a/packages/ai/src/stream.ts
+++ b/packages/ai/src/stream.ts
@@ -124,7 +124,7 @@ export function getEnvApiKey(provider: any): string | undefined {
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
- zai: "ZAI_API_KEY",
+ "zai-coding-plan": "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
minimax: "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts
index 4463ec5f8..65290849e 100644
--- a/packages/ai/src/types.ts
+++ b/packages/ai/src/types.ts
@@ -57,7 +57,7 @@ export type KnownProvider =
| "cerebras"
| "openrouter"
| "vercel-ai-gateway"
- | "zai"
+ | "zai-coding-plan"
| "mistral"
| "minimax"
| "minimax-cn"
diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts
index fae10364d..c9a942ef9 100644
--- a/packages/ai/test/context-overflow.test.ts
+++ b/packages/ai/test/context-overflow.test.ts
@@ -361,7 +361,7 @@ describe("Context overflow error handling", () => {
describe.skipIf(!process.env.ZAI_API_KEY)("z.ai", () => {
it("glm-4.5-flash - should detect overflow via isContextOverflow (silent overflow or rate limit)", async () => {
- const model = getModel("zai", "glm-4.5-flash");
+ const model = getModel("zai-coding-plan", "glm-4.5-flash");
const result = await testContextOverflow(model, process.env.ZAI_API_KEY!);
logResult(result);
diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts
index 12415f6c7..f7c95635f 100644
--- a/packages/ai/test/empty.test.ts
+++ b/packages/ai/test/empty.test.ts
@@ -283,7 +283,7 @@ describe("AI Providers Empty Message Tests", () => {
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Empty Messages", () => {
- const llm = getModel("zai", "glm-4.5-air");
+ const llm = getModel("zai-coding-plan", "glm-4.5-air");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts
index 2a140292a..db54bc33e 100644
--- a/packages/ai/test/stream.test.ts
+++ b/packages/ai/test/stream.test.ts
@@ -688,7 +688,7 @@ describe("Generate E2E Tests", () => {
);
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air via OpenAI Completions)", () => {
- const llm = getModel("zai", "glm-4.5-air");
+ const llm = getModel("zai-coding-plan", "glm-4.5-air");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
@@ -712,7 +712,35 @@ describe("Generate E2E Tests", () => {
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5v via OpenAI Completions)", () => {
- const llm = getModel("zai", "glm-4.5v");
+ const llm = getModel("zai-coding-plan", "glm-4.5v");
+
+ it("should complete basic text generation", { retry: 3 }, async () => {
+ await basicTextGeneration(llm);
+ });
+
+ it("should handle tool calling", { retry: 3 }, async () => {
+ await handleToolCall(llm);
+ });
+
+ it("should handle streaming", { retry: 3 }, async () => {
+ await handleStreaming(llm);
+ });
+
+ it("should handle thinking mode", { retry: 3 }, async () => {
+ await handleThinking(llm, { reasoningEffort: "medium" });
+ });
+
+ it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
+ await multiTurn(llm, { reasoningEffort: "medium" });
+ });
+
+ it("should handle image input", { retry: 3 }, async () => {
+ await handleImage(llm);
+ });
+ });
+
+ describe.skipIf(!process.env.ZAI_API_KEY)("zAI Coding Plan Provider (glm-4.5v via OpenAI Completions)", () => {
+ const llm = getModel("zai-coding-plan", "glm-4.5v");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts
index dc897bb58..f358c3fec 100644
--- a/packages/ai/test/tokens.test.ts
+++ b/packages/ai/test/tokens.test.ts
@@ -54,7 +54,7 @@ async function testTokensOnAbort<TApi extends Api>(llm: Model<TApi>, options: Op
llm.api === "openai-responses" ||
llm.api === "openai-codex-responses" ||
llm.provider === "google-gemini-cli" ||
- llm.provider === "zai" ||
+ llm.provider === "zai-coding-plan" ||
llm.provider === "amazon-bedrock" ||
llm.provider === "vercel-ai-gateway" ||
(llm.provider === "google-antigravity" && llm.id.includes("gpt-oss"))
@@ -140,7 +140,15 @@ describe("Token Statistics on Abort", () => {
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider", () => {
- const llm = getModel("zai", "glm-4.5-flash");
+ const llm = getModel("zai-coding-plan", "glm-4.5-flash");
+
+ it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
+ await testTokensOnAbort(llm);
+ });
+ });
+
+ describe.skipIf(!process.env.ZAI_API_KEY)("zAI Coding Plan Provider", () => {
+ const llm = getModel("zai-coding-plan", "glm-4.5-flash");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts
index 8cc61e90a..16d6f608a 100644
--- a/packages/ai/test/tool-call-without-result.test.ts
+++ b/packages/ai/test/tool-call-without-result.test.ts
@@ -158,7 +158,7 @@ describe("Tool Call Without Result Tests", () => {
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider", () => {
- const model = getModel("zai", "glm-4.5-flash");
+ const model = getModel("zai-coding-plan", "glm-4.5-flash");
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
await testToolCallWithoutResult(model);
diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts
index b0a40ebd2..dba08239c 100644
--- a/packages/ai/test/total-tokens.test.ts
+++ b/packages/ai/test/total-tokens.test.ts
@@ -290,7 +290,7 @@ describe("totalTokens field", () => {
"glm-4.5-flash - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
- const llm = getModel("zai", "glm-4.5-flash");
+ const llm = getModel("zai-coding-plan", "glm-4.5-flash");
console.log(`\nz.ai / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.ZAI_API_KEY });
diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts
index 4087d306e..65f31de16 100644
--- a/packages/ai/test/unicode-surrogate.test.ts
+++ b/packages/ai/test/unicode-surrogate.test.ts
@@ -590,7 +590,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Unicode Handling", () => {
- const llm = getModel("zai", "glm-4.5-air");
+ const llm = getModel("zai-coding-plan", "glm-4.5-air");
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm);
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index e9ee71354..29de187c8 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -31,6 +31,10 @@
## [0.49.2] - 2026-01-19
+### Changed
+
+- Changed `zai` provider to use the Coding Plan API for better coding performance.
+
### Added
- Added widget placement option for extension widgets via `widgetPlacement` in `pi.addWidget()` ([#850](https://github.com/badlogic/pi-mono/pull/850) by [@marckrenn](https://github.com/marckrenn))
diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index a6f35ee36..aa33706cc 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -187,7 +187,8 @@ Add API keys to `~/.pi/agent/auth.json`:
{
"anthropic": { "type": "api_key", "key": "sk-ant-..." },
"openai": { "type": "api_key", "key": "sk-..." },
- "google": { "type": "api_key", "key": "..." }
+ "google": { "type": "api_key", "key": "..." },
+ "zai-coding-plan": { "type": "api_key", "key": "..." }
}
```
@@ -204,7 +205,7 @@ Add API keys to `~/.pi/agent/auth.json`:
| xAI | `xai` | `XAI_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` |
-| ZAI | `zai` | `ZAI_API_KEY` |
+| ZAI Coding Plan | `zai-coding-plan` | `ZAI_API_KEY` |
| OpenCode Zen | `opencode` | `OPENCODE_API_KEY` |
| MiniMax | `minimax` | `MINIMAX_API_KEY` |
| MiniMax (China) | `minimax-cn` | `MINIMAX_CN_API_KEY` |
@@ -245,6 +246,12 @@ pi
- Prompt cache stored under `~/.pi/agent/cache/openai-codex/`
- Intended for personal use with your own subscription; not for resale or multi-user services. For production, use the OpenAI Platform API.
+**Z.AI GLM Coding Plan notes:**
+- Pi uses the dedicated coding plan endpoint: `https://api.z.ai/api/coding/paas/v4`
+- Requires Z.AI API key from [Z.AI Open Platform](https://z.ai) with an active [Coding Plan](https://z.ai/subscribe) subscription
+- Z.AI API keys are the same for both the subscription and regular API. In order to use the regular API, you need to configure a custom provider pointing at the [regular endpoint](https://docs.z.ai/api-reference/llm/chat-completion)
+- Consult the [docs](https://docs.z.ai/guides/llm/glm-4.7) to learn more about the models
+
Credentials stored in `~/.pi/agent/auth.json`. Use `/logout` to clear.
**Troubleshooting (OAuth):**
@@ -1218,7 +1225,7 @@ pi [options] [@files...] [messages...]
| Option | Description |
|--------|-------------|
-| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`, `amazon-bedrock`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `vercel-ai-gateway`, `zai`, `opencode`, `minimax`, `minimax-cn`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
+| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`, `amazon-bedrock`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `vercel-ai-gateway`, `zai-coding-plan`, `opencode`, `minimax`, `minimax-cn`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
| `--model <id>` | Model ID |
| `--api-key <key>` | API key (overrides environment) |
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) |
diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts
index 3d24c77dc..f9aa36572 100644
--- a/packages/coding-agent/src/cli/args.ts
+++ b/packages/coding-agent/src/cli/args.ts
@@ -243,7 +243,7 @@ ${chalk.bold("Environment Variables:")}
XAI_API_KEY - xAI Grok API key
OPENROUTER_API_KEY - OpenRouter API key
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
- ZAI_API_KEY - ZAI API key
+ ZAI_API_KEY - ZAI Coding Plan API key
MISTRAL_API_KEY - Mistral API key
MINIMAX_API_KEY - MiniMax API key
AWS_PROFILE - AWS profile for Amazon Bedrock
diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts
index c28a8b0eb..d3e27a4aa 100644
--- a/packages/coding-agent/src/core/model-resolver.ts
+++ b/packages/coding-agent/src/core/model-resolver.ts
@@ -25,7 +25,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",
cerebras: "zai-glm-4.6",
- zai: "glm-4.6",
+ "zai-coding-plan": "glm-4.6",
mistral: "devstral-medium-latest",
minimax: "MiniMax-M2.1",
"minimax-cn": "MiniMax-M2.1",
diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md
index 684caac61..79328e401 100644
--- a/packages/web-ui/README.md
+++ b/packages/web-ui/README.md
@@ -508,7 +508,7 @@ agent.streamFn = createStreamFn(async () => {
});
// Providers requiring proxy:
-// - zai: always
+// - zai-coding-plan: always
// - anthropic: only OAuth tokens (sk-ant-oat-*)
```
diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts
index 011af2004..ebdc1a379 100644
--- a/packages/web-ui/src/components/ProviderKeyInput.ts
+++ b/packages/web-ui/src/components/ProviderKeyInput.ts
@@ -18,7 +18,7 @@ const TEST_MODELS: Record<string, string> = {
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
cerebras: "gpt-oss-120b",
xai: "grok-4-fast-non-reasoning",
- zai: "glm-4.5-air",
+ "zai-coding-plan": "glm-4.5-air",
};
@customElement("provider-key-input")
diff --git a/packages/web-ui/src/utils/proxy-utils.ts b/packages/web-ui/src/utils/proxy-utils.ts
index 27816b18d..7901a15e5 100644
--- a/packages/web-ui/src/utils/proxy-utils.ts
+++ b/packages/web-ui/src/utils/proxy-utils.ts
@@ -12,13 +12,13 @@ import { streamSimple } from "@mariozechner/pi-ai";
/**
* Check if a provider/API key combination requires a CORS proxy.
*
- * @param provider - Provider name (e.g., "anthropic", "openai", "zai")
+ * @param provider - Provider name (e.g., "anthropic", "openai", "zai-coding-plan")
* @param apiKey - API key for the provider
* @returns true if proxy is required, false otherwise
*/
export function shouldUseProxyForProvider(provider: string, apiKey: string): boolean {
switch (provider.toLowerCase()) {
- case "zai":
+ case "zai-coding-plan":
// Z-AI always requires proxy
return true;>gh pr diff 892 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 893c25683..eec18ff8a 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -15,6 +15,7 @@
- Exposed `copyToClipboard` utility for extensions ([#926](https://github.com/badlogic/pi-mono/issues/926) by [@mitsuhiko](https://github.com/mitsuhiko))
- Skill invocation messages are now collapsible in chat output, showing collapsed by default with skill name and expand hint ([#894](https://github.com/badlogic/pi-mono/issues/894))
- Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909))
+- Template variables (`{{tools}}`, `{{context}}`, `{{skills}}`) for custom system prompts in SYSTEM.md, allowing precise control over where dynamic content is injected
- `markdown.codeBlockIndent` setting to customize code block indentation in rendered output
- Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645))
- Package filtering: selectively load resources from packages using object form in `packages` array ([#645](https://github.com/badlogic/pi-mono/issues/645))
diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts
index 66823c83b..b1ea198a9 100644
--- a/packages/coding-agent/examples/sdk/12-full-control.ts
+++ b/packages/coding-agent/examples/sdk/12-full-control.ts
@@ -52,6 +52,7 @@ const resourceLoader: ResourceLoader = {
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => `You are a minimal assistant.
Available: read, bash. Be concise.`,
+ getSystemPromptTemplates: () => undefined,
getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
reload: async () => {},
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index 270545534..4d4931f29 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -610,7 +610,7 @@ export class AgentSession {
const loadedSkills = this._resourceLoader.getSkills().skills;
const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
- return buildSystemPrompt({
+ const result = buildSystemPrompt({
cwd: this._cwd,
skills: loadedSkills,
contextFiles: loadedContextFiles,
@@ -618,6 +618,8 @@ export class AgentSession {
appendSystemPrompt,
selectedTools: validToolNames,
});
+
+ return result.prompt;
}
// =========================================================================
diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts
index 56a1bd6ae..c9ee2ba12 100644
--- a/packages/coding-agent/src/core/resource-loader.ts
+++ b/packages/coding-agent/src/core/resource-loader.ts
@@ -23,6 +23,13 @@ import { SettingsManager } from "./settings-manager.js";
import type { Skill } from "./skills.js";
import { loadSkills } from "./skills.js";
+/** Template variable usage in custom SYSTEM.md */
+export interface SystemPromptTemplates {
+ tools: boolean;
+ context: boolean;
+ skills: boolean;
+}
+
export interface ResourceLoader {
getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
@@ -30,6 +37,8 @@ export interface ResourceLoader {
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };
getSystemPrompt(): string | undefined;
+ /** Returns template variable usage if custom SYSTEM.md exists, undefined otherwise */
+ getSystemPromptTemplates(): SystemPromptTemplates | undefined;
getAppendSystemPrompt(): string[];
getPathMetadata(): Map<string, PathMetadata>;
reload(): Promise<void>;
@@ -258,6 +267,15 @@ export class DefaultResourceLoader implements ResourceLoader {
return this.systemPrompt;
}
+ getSystemPromptTemplates(): SystemPromptTemplates | undefined {
+ if (!this.systemPrompt) return undefined;
+ return {
+ tools: this.systemPrompt.includes("{{tools}}"),
+ context: this.systemPrompt.includes("{{context}}"),
+ skills: this.systemPrompt.includes("{{skills}}"),
+ };
+ }
+
getAppendSystemPrompt(): string[] {
return this.appendSystemPrompt;
}
diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts
index dd13d3a9d..f18c4cf43 100644
--- a/packages/coding-agent/src/core/system-prompt.ts
+++ b/packages/coding-agent/src/core/system-prompt.ts
@@ -31,8 +31,17 @@ export interface BuildSystemPromptOptions {
skills?: Skill[];
}
+export interface BuildSystemPromptResult {
+ /** The built system prompt */
+ prompt: string;
+ /** Whether context files were injected (always true for default prompt, depends on {{context}} for custom) */
+ contextInjected: boolean;
+ /** Whether skills were injected (always true for default prompt, depends on {{skills}} for custom) */
+ skillsInjected: boolean;
+}
+
/** Build the system prompt with tools, guidelines, and context */
-export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
+export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): BuildSystemPromptResult {
const {
customPrompt,
selectedTools,
@@ -63,30 +72,48 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
if (customPrompt) {
let prompt = customPrompt;
- if (appendSection) {
- prompt += appendSection;
+ // Template variable replacement (opt-in injection)
+ // No template variables = full replacement mode (no automatic appending)
+ const contextInjected = prompt.includes("{{context}}");
+ const skillsInjected = prompt.includes("{{skills}}");
+
+ if (prompt.includes("{{tools}}")) {
+ const tools = selectedTools || ["read", "bash", "edit", "write"];
+ const toolsList =
+ tools.length > 0
+ ? tools.map((t) => `- ${t}: ${toolDescriptions[t] ?? "Custom tool"}`).join("\n")
+ : "(none)";
+ prompt = prompt.replace("{{tools}}", toolsList);
}
- // Append project context files
- if (contextFiles.length > 0) {
- prompt += "\n\n# Project Context\n\n";
- prompt += "Project-specific instructions and guidelines:\n\n";
- for (const { path: filePath, content } of contextFiles) {
- prompt += `## ${filePath}\n\n${content}\n\n`;
+ if (contextInjected) {
+ let contextStr = "";
+ if (contextFiles.length > 0) {
+ contextStr = "# Project Context\n\n";
+ contextStr += "Project-specific instructions and guidelines:\n\n";
+ for (const { path: filePath, content } of contextFiles) {
+ contextStr += `## ${filePath}\n\n${content}\n\n`;
+ }
}
+ prompt = prompt.replace("{{context}}", contextStr);
}
- // Append skills section (only if read tool is available)
- const customPromptHasRead = !selectedTools || selectedTools.includes("read");
- if (customPromptHasRead && skills.length > 0) {
- prompt += formatSkillsForPrompt(skills);
+ if (skillsInjected) {
+ const customPromptHasRead = !selectedTools || selectedTools.includes("read");
+ const skillsStr = customPromptHasRead && skills.length > 0 ? formatSkillsForPrompt(skills) : "";
+ prompt = prompt.replace("{{skills}}", skillsStr);
+ }
+
+ // Append section always applies (for --append-system-prompt)
+ if (appendSection) {
+ prompt += appendSection;
}
- // Add date/time and working directory last
+ // Add date/time and working directory last (always)
prompt += `\nCurrent date and time: ${dateTime}`;
prompt += `\nCurrent working directory: ${resolvedCwd}`;
- return prompt;
+ return { prompt, contextInjected, skillsInjected };
}
// Get absolute paths to documentation and examples
@@ -175,7 +202,8 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
}
// Append skills section (only if read tool is available)
- if (hasRead && skills.length > 0) {
+ const skillsInjected = hasRead;
+ if (skillsInjected && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}
@@ -183,5 +211,6 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
prompt += `\nCurrent date and time: ${dateTime}`;
prompt += `\nCurrent working directory: ${resolvedCwd}`;
- return prompt;
+ // Default prompt always injects context, skills depend on read tool
+ return { prompt, contextInjected: true, skillsInjected };
}
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index 5ababa022..ed8f84ffe 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -126,7 +126,12 @@ export type {
ResolvedPaths,
} from "./core/package-manager.js";
export { DefaultPackageManager } from "./core/package-manager.js";
-export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
+export type {
+ ResourceCollision,
+ ResourceDiagnostic,
+ ResourceLoader,
+ SystemPromptTemplates,
+} from "./core/resource-loader.js";
export { DefaultResourceLoader } from "./core/resource-loader.js";
// SDK for programmatic usage
export {
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index 0bca4ea6b..c71068800 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -866,26 +866,40 @@ export class InteractiveMode {
}
const metadata = this.session.resourceLoader.getPathMetadata();
+ // Check if custom SYSTEM.md uses template variables
+ // undefined = no custom prompt (default behavior, show all)
+ // defined = custom prompt, only show if template is used
+ const promptTemplates = this.session.resourceLoader.getSystemPromptTemplates();
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
- const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
- if (contextFiles.length > 0) {
- const contextList = contextFiles.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)).join("\n");
- this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0));
- this.chatContainer.addChild(new Spacer(1));
+ // Show context only if: no custom prompt OR custom prompt uses {{context}}
+ const showContext = !promptTemplates || promptTemplates.context;
+ if (showContext) {
+ const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
+ if (contextFiles.length > 0) {
+ const contextList = contextFiles
+ .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
+ .join("\n");
+ this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0));
+ this.chatContainer.addChild(new Spacer(1));
+ }
}
- const skills = this.session.resourceLoader.getSkills().skills;
- if (skills.length > 0) {
- const skillPaths = skills.map((s) => s.filePath);
- const groups = this.buildScopeGroups(skillPaths, metadata);
- const skillList = this.formatScopeGroups(groups, {
- formatPath: (p) => this.formatDisplayPath(p),
- formatPackagePath: (p, source) => this.getShortPath(p, source),
- });
- this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
- this.chatContainer.addChild(new Spacer(1));
+ // Show skills only if: no custom prompt OR custom prompt uses {{skills}}
+ const showSkills = !promptTemplates || promptTemplates.skills;
+ if (showSkills) {
+ const skills = this.session.resourceLoader.getSkills().skills;
+ if (skills.length > 0) {
+ const skillPaths = skills.map((s) => s.filePath);
+ const groups = this.buildScopeGroups(skillPaths, metadata);
+ const skillList = this.formatScopeGroups(groups, {
+ formatPath: (p) => this.formatDisplayPath(p),
+ formatPackagePath: (p, source) => this.getShortPath(p, source),
+ });
+ this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
+ this.chatContainer.addChild(new Spacer(1));
+ }
}
const skillDiagnostics = this.session.resourceLoader.getSkills().diagnostics;
diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts
index 48c96cdd7..ef4d45701 100644
--- a/packages/coding-agent/test/sdk-skills.test.ts
+++ b/packages/coding-agent/test/sdk-skills.test.ts
@@ -57,6 +57,7 @@ This is a test skill.
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
+ getSystemPromptTemplates: () => undefined,
getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
reload: async () => {},
@@ -90,6 +91,7 @@ This is a test skill.
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
+ getSystemPromptTemplates: () => undefined,
getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
reload: async () => {},
diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts
index af20f1554..9a80b89de 100644
--- a/packages/coding-agent/test/system-prompt.test.ts
+++ b/packages/coding-agent/test/system-prompt.test.ts
@@ -4,37 +4,224 @@ import { buildSystemPrompt } from "../src/core/system-prompt.js";
describe("buildSystemPrompt", () => {
describe("empty tools", () => {
test("shows (none) for empty tools list", () => {
- const prompt = buildSystemPrompt({
+ const result = buildSystemPrompt({
selectedTools: [],
contextFiles: [],
skills: [],
});
- expect(prompt).toContain("Available tools:\n(none)");
+ expect(result.prompt).toContain("Available tools:\n(none)");
});
test("shows file paths guideline even with no tools", () => {
- const prompt = buildSystemPrompt({
+ const result = buildSystemPrompt({
selectedTools: [],
contextFiles: [],
skills: [],
});
- expect(prompt).toContain("Show file paths clearly");
+ expect(result.prompt).toContain("Show file paths clearly");
});
});
describe("default tools", () => {
test("includes all default tools", () => {
- const prompt = buildSystemPrompt({
+ const result = buildSystemPrompt({
contextFiles: [],
skills: [],
});
- expect(prompt).toContain("- read:");
- expect(prompt).toContain("- bash:");
- expect(prompt).toContain("- edit:");
- expect(prompt).toContain("- write:");
+ expect(result.prompt).toContain("- read:");
+ expect(result.prompt).toContain("- bash:");
+ expect(result.prompt).toContain("- edit:");
+ expect(result.prompt).toContain("- write:");
+ });
+ });
+
+ describe("template variables", () => {
+ test("replaces {{tools}} with tool list", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "My tools:\n{{tools}}",
+ selectedTools: ["read", "bash"],
+ contextFiles: [],
+ skills: [],
+ });
+
+ expect(result.prompt).toContain("My tools:");
+ expect(result.prompt).toContain("- read:");
+ expect(result.prompt).toContain("- bash:");
+ expect(result.prompt).not.toContain("{{tools}}");
+ });
+
+ test("replaces {{context}} with context files", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "Context:\n{{context}}\nEnd context.",
+ contextFiles: [{ path: "/test/AGENTS.md", content: "Test content" }],
+ skills: [],
+ });
+
+ expect(result.prompt).toContain("Context:");
+ expect(result.prompt).toContain("# Project Context");
+ expect(result.prompt).toContain("/test/AGENTS.md");
+ expect(result.prompt).toContain("Test content");
+ expect(result.prompt).toContain("End context.");
+ expect(result.prompt).not.toContain("{{context}}");
+ expect(result.contextInjected).toBe(true);
+ });
+
+ test("replaces {{skills}} with skills section", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "Skills:\n{{skills}}",
+ selectedTools: ["read"],
+ contextFiles: [],
+ skills: [
+ {
+ name: "test-skill",
+ description: "A test skill",
+ filePath: "/test/skill.md",
+ baseDir: "/test",
+ source: "project",
+ disableModelInvocation: false,
+ },
+ ],
+ });
+
+ expect(result.prompt).toContain("Skills:");
+ expect(result.prompt).toContain("test-skill");
+ expect(result.prompt).not.toContain("{{skills}}");
+ expect(result.skillsInjected).toBe(true);
+ });
+
+ test("{{skills}} is empty when read tool not available", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "Skills: [{{skills}}]",
+ selectedTools: ["bash"], // no read
+ contextFiles: [],
+ skills: [
+ {
+ name: "test-skill",
+ description: "A test skill",
+ filePath: "/test/skill.md",
+ baseDir: "/test",
+ source: "project",
+ disableModelInvocation: false,
+ },
+ ],
+ });
+
+ expect(result.prompt).toContain("Skills: []");
+ expect(result.prompt).not.toContain("test-skill");
+ expect(result.skillsInjected).toBe(true); // Still true, just empty
+ });
+
+ test("multiple template variables work together", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "Tools:\n{{tools}}\n\n{{context}}\n\n{{skills}}",
+ selectedTools: ["read", "edit"],
+ contextFiles: [{ path: "/AGENTS.md", content: "Project rules" }],
+ skills: [
+ {
+ name: "my-skill",
+ description: "My skill",
+ filePath: "/skill.md",
+ baseDir: "/",
+ source: "project",
+ disableModelInvocation: false,
+ },
+ ],
+ });
+
+ expect(result.prompt).toContain("- read:");
+ expect(result.prompt).toContain("- edit:");
+ expect(result.prompt).toContain("Project rules");
+ expect(result.prompt).toContain("my-skill");
+ expect(result.prompt).not.toContain("{{tools}}");
+ expect(result.prompt).not.toContain("{{context}}");
+ expect(result.prompt).not.toContain("{{skills}}");
+ expect(result.contextInjected).toBe(true);
+ expect(result.skillsInjected).toBe(true);
+ });
+
+ test("without template vars, context and skills are NOT appended (full replacement)", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "My custom prompt",
+ contextFiles: [{ path: "/AGENTS.md", content: "Context here" }],
+ skills: [
+ {
+ name: "skill1",
+ description: "Skill 1",
+ filePath: "/skill.md",
+ baseDir: "/",
+ source: "project",
+ disableModelInvocation: false,
+ },
+ ],
+ });
+
+ // Full replacement mode: no automatic appending
+ expect(result.prompt).toContain("My custom prompt");
+ expect(result.prompt).not.toContain("Context here");
+ expect(result.prompt).not.toContain("skill1");
+ expect(result.contextInjected).toBe(false);
+ expect(result.skillsInjected).toBe(false);
+ });
+
+ test("with template vars, content only appears where requested", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "Only tools: {{tools}}",
+ contextFiles: [{ path: "/AGENTS.md", content: "Should not appear" }],
+ skills: [
+ {
+ name: "skill1",
+ description: "Should not appear",
+ filePath: "/skill.md",
+ baseDir: "/",
+ source: "project",
+ disableModelInvocation: false,
+ },
+ ],
+ });
+
+ // Has {{tools}} so template mode, but no {{context}} or {{skills}}
+ expect(result.prompt).toContain("- read:");
+ expect(result.prompt).not.toContain("Should not appear");
+ expect(result.prompt).not.toContain("skill1");
+ expect(result.contextInjected).toBe(false);
+ expect(result.skillsInjected).toBe(false);
+ });
+
+ test("always includes datetime and cwd", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "{{tools}}",
+ cwd: "/test/dir",
+ contextFiles: [],
+ skills: [],
+ });
+
+ expect(result.prompt).toContain("Current date and time:");
+ expect(result.prompt).toContain("Current working directory: /test/dir");
+ });
+
+ test("appendSystemPrompt still works with template vars", () => {
+ const result = buildSystemPrompt({
+ customPrompt: "Main: {{tools}}",
+ appendSystemPrompt: "Extra instructions",
+ contextFiles: [],
+ skills: [],
+ });
+
+ expect(result.prompt).toContain("Main:");
+ expect(result.prompt).toContain("Extra instructions");
+ });
+
+ test("default prompt returns contextInjected and skillsInjected true", () => {
+ const result = buildSystemPrompt({
+ contextFiles: [{ path: "/AGENTS.md", content: "Context" }],
+ skills: [],
+ });
+
+ expect(result.contextInjected).toBe(true);
+ expect(result.skillsInjected).toBe(true);
});
});
});
diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts
index 6e4e60b43..46c88d29d 100644
--- a/packages/coding-agent/test/utilities.ts
+++ b/packages/coding-agent/test/utilities.ts
@@ -182,6 +182,7 @@ export function createTestResourceLoader(): ResourceLoader {
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
+ getSystemPromptTemplates: () => undefined,
getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
reload: async () => {},
diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts
index 2733dbd57..ff6980979 100644
--- a/packages/mom/src/agent.ts
+++ b/packages/mom/src/agent.ts
@@ -457,6 +457,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => systemPrompt,
+ getSystemPromptTemplates: () => undefined,
getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
reload: async () => {},>gh pr diff 868 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 9c2a9bbd8..2900d4c9e 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -4,6 +4,7 @@
### Breaking Changes
+- Session selector (`/resume`) now supports name filter toggle (default: `Ctrl+N`, configurable) to show only named sessions
- Header values in `models.json` now resolve environment variables (if a header value matches an env var name, the env var value is used). This may change behavior if a literal header value accidentally matches an env var name. ([#909](https://github.com/badlogic/pi-mono/issues/909))
- Resource loading now uses `ResourceLoader` only and settings.json uses arrays for extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
- Removed `discoverAuthStorage` and `discoverModels` from the SDK. `AuthStorage` and `ModelRegistry` now default to `~/.pi/agent` paths unless you pass an `agentDir` ([#645](https://github.com/badlogic/pi-mono/issues/645))
@@ -2170,4 +2171,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
\ No newline at end of file
diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 8e5433d2d..2f8f04217 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -421,6 +421,7 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Eac
| `selectModel` | `ctrl+l` | Open model selector |
| `expandTools` | `ctrl+o` | Expand tool output |
| `toggleThinking` | `ctrl+t` | Toggle thinking |
+| `toggleSessionNamedFilter` | `ctrl+n` | Toggle named-only filter in session picker (`/resume`) |
| `externalEditor` | `ctrl+g` | Open external editor |
| `followUp` | `alt+enter` | Queue follow-up message |
| `dequeue` | `alt+up` | Restore queued messages to editor |
@@ -546,6 +547,7 @@ pi --session a8ec1c2a # Resume by session ID (partial UUID)
In the `/resume` picker:
- `Ctrl+P` toggles display of the session `.jsonl` file path
+- `Ctrl+N` toggles the named-only filter (configurable via `toggleSessionNamedFilter` in `keybindings.json`)
- `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session)
**Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions/<project>/` (e.g., `2025-12-13T17-47-46-817Z_a8ec1c2a-5a5f-4699-88cb-03e7d3cb9292.jsonl`). The UUID is the part after the underscore. You can also search by session ID in the `pi -r` picker.
diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts
index 3ca22355c..62cd6e446 100644
--- a/packages/coding-agent/src/cli/session-picker.ts
+++ b/packages/coding-agent/src/cli/session-picker.ts
@@ -3,6 +3,7 @@
*/
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
+import { KeybindingsManager } from "../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
@@ -15,6 +16,7 @@ export async function selectSession(
): Promise<string | null> {
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
+ const keybindings = KeybindingsManager.create();
let resolved = false;
const selector = new SessionSelectorComponent(
@@ -39,6 +41,7 @@ export async function selectSession(
process.exit(0);
},
() => ui.requestRender(),
+ keybindings,
);
ui.addChild(selector);
diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts
index 12661f791..d994f1535 100644
--- a/packages/coding-agent/src/core/keybindings.ts
+++ b/packages/coding-agent/src/core/keybindings.ts
@@ -25,6 +25,9 @@ export type AppAction =
| "selectModel"
| "expandTools"
| "toggleThinking"
+ // Session selector-only action. Intentionally not treated as a globally reserved shortcut for
+ // extension shortcut conflict checks, since it only applies inside the /resume picker
+ | "toggleSessionNamedFilter"
| "externalEditor"
| "followUp"
| "dequeue"
@@ -56,6 +59,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
selectModel: "ctrl+l",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
+ toggleSessionNamedFilter: "ctrl+n",
externalEditor: "ctrl+g",
followUp: "alt+enter",
dequeue: "alt+up",
@@ -82,6 +86,7 @@ const APP_ACTIONS: AppAction[] = [
"selectModel",
"expandTools",
"toggleThinking",
+ "toggleSessionNamedFilter",
"externalEditor",
"followUp",
"dequeue",
diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts
index b77d2f882..a2975d340 100644
--- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts
+++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts
@@ -3,6 +3,8 @@ import type { SessionInfo } from "../../../core/session-manager.js";
export type SortMode = "recent" | "relevance";
+export type NameFilter = "all" | "named";
+
export interface ParsedSearchQuery {
mode: "tokens" | "regex";
tokens: { kind: "fuzzy" | "phrase"; value: string }[];
@@ -25,6 +27,15 @@ function getSessionSearchText(session: SessionInfo): string {
return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`;
}
+function hasSessionName(session: SessionInfo): boolean {
+ return !!session.name?.trim();
+}
+
+function matchesNameFilter(session: SessionInfo, filter: NameFilter): boolean {
+ if (filter === "all") return true;
+ return hasSessionName(session);
+}
+
export function parseSearchQuery(query: string): ParsedSearchQuery {
const trimmed = query.trim();
if (!trimmed) {
@@ -142,9 +153,17 @@ export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): M
return { matches: true, score: totalScore };
}
-export function filterAndSortSessions(sessions: SessionInfo[], query: string, sortMode: SortMode): SessionInfo[] {
+export function filterAndSortSessions(
+ sessions: SessionInfo[],
+ query: string,
+ sortMode: SortMode,
+ nameFilter: NameFilter = "all",
+): SessionInfo[] {
+ // Apply name filter first.
+ const nameFiltered = nameFilter === "all" ? sessions : sessions.filter((s) => matchesNameFilter(s, nameFilter));
+
const trimmed = query.trim();
- if (!trimmed) return sessions;
+ if (!trimmed) return nameFiltered;
const parsed = parseSearchQuery(query);
if (parsed.error) return [];
@@ -152,7 +171,7 @@ export function filterAndSortSessions(sessions: SessionInfo[], query: string, so
// Recent mode: filter only, keep incoming order.
if (sortMode === "recent") {
const filtered: SessionInfo[] = [];
- for (const s of sessions) {
+ for (const s of nameFiltered) {
const res = matchSession(s, parsed);
if (res.matches) filtered.push(s);
}
@@ -161,7 +180,7 @@ export function filterAndSortSessions(sessions: SessionInfo[], query: string, so
// Relevance mode: sort by score, tie-break by modified desc.
const scored: { session: SessionInfo; score: number }[] = [];
- for (const s of sessions) {
+ for (const s of nameFiltered) {
const res = matchSession(s, parsed);
if (!res.matches) continue;
scored.push({ session: s, score: res.score });
diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
index c6d8d13b9..34e3310dc 100644
--- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
@@ -13,11 +13,12 @@ import {
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
+import { KeybindingsManager } from "../../../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
-import { keyHint, rawKeyHint } from "./keybinding-hints.js";
-import { filterAndSortSessions, type SortMode } from "./session-selector-search.js";
+import { appKey, appKeyHint, keyHint, rawKeyHint } from "./keybinding-hints.js";
+import { filterAndSortSessions, type NameFilter, type SortMode } from "./session-selector-search.js";
type SessionScope = "current" | "all";
@@ -49,6 +50,8 @@ function formatSessionDate(date: Date): string {
class SessionSelectorHeader implements Component {
private scope: SessionScope;
private sortMode: SortMode;
+ private nameFilter: NameFilter;
+ private keybindings: KeybindingsManager;
private requestRender: () => void;
private loading = false;
private loadProgress: { loaded: number; total: number } | null = null;
@@ -57,9 +60,17 @@ class SessionSelectorHeader implements Component {
private statusMessage: { type: "info" | "error"; message: string } | null = null;
private statusTimeout: ReturnType<typeof setTimeout> | null = null;
- constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) {
+ constructor(
+ scope: SessionScope,
+ sortMode: SortMode,
+ nameFilter: NameFilter,
+ keybindings: KeybindingsManager,
+ requestRender: () => void,
+ ) {
this.scope = scope;
this.sortMode = sortMode;
+ this.nameFilter = nameFilter;
+ this.keybindings = keybindings;
this.requestRender = requestRender;
}
@@ -71,6 +82,10 @@ class SessionSelectorHeader implements Component {
this.sortMode = sortMode;
}
+ setNameFilter(nameFilter: NameFilter): void {
+ this.nameFilter = nameFilter;
+ }
+
setLoading(loading: boolean): void {
this.loading = loading;
// Progress is scoped to the current load; clear whenever the loading state is set
@@ -116,6 +131,9 @@ class SessionSelectorHeader implements Component {
const sortLabel = this.sortMode === "recent" ? "Recent" : "Fuzzy";
const sortText = theme.fg("muted", "Sort: ") + theme.fg("accent", sortLabel);
+ const nameLabel = this.nameFilter === "all" ? "All" : "Named";
+ const nameText = theme.fg("muted", "Name: ") + theme.fg("accent", nameLabel);
+
let scopeText: string;
if (this.loading) {
const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "...";
@@ -126,7 +144,7 @@ class SessionSelectorHeader implements Component {
scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`;
}
- const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, "");
+ const rightText = truncateToWidth(`${scopeText} ${nameText} ${sortText}`, width, "");
const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1);
const left = truncateToWidth(leftText, availableLeft, "");
const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText));
@@ -149,6 +167,8 @@ class SessionSelectorHeader implements Component {
const hint2 =
rawKeyHint("ctrl+r", "sort") +
sep +
+ appKeyHint(this.keybindings, "toggleSessionNamedFilter", "named") +
+ sep +
rawKeyHint("ctrl+d", "delete") +
sep +
rawKeyHint("ctrl+p", `path ${pathState}`);
@@ -170,6 +190,8 @@ class SessionList implements Component, Focusable {
private searchInput: Input;
private showCwd = false;
private sortMode: SortMode = "relevance";
+ private nameFilter: NameFilter = "all";
+ private keybindings: KeybindingsManager;
private showPath = false;
private confirmingDeletePath: string | null = null;
private currentSessionFilePath?: string;
@@ -178,6 +200,7 @@ class SessionList implements Component, Focusable {
public onExit: () => void = () => {};
public onToggleScope?: () => void;
public onToggleSort?: () => void;
+ public onToggleNameFilter?: () => void;
public onTogglePath?: (showPath: boolean) => void;
public onDeleteConfirmationChange?: (path: string | null) => void;
public onDeleteSession?: (sessionPath: string) => Promise<void>;
@@ -194,12 +217,21 @@ class SessionList implements Component, Focusable {
this.searchInput.focused = value;
}
- constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode, currentSessionFilePath?: string) {
+ constructor(
+ sessions: SessionInfo[],
+ showCwd: boolean,
+ sortMode: SortMode,
+ nameFilter: NameFilter,
+ keybindings: KeybindingsManager,
+ currentSessionFilePath?: string,
+ ) {
this.allSessions = sessions;
this.filteredSessions = sessions;
this.searchInput = new Input();
this.showCwd = showCwd;
this.sortMode = sortMode;
+ this.nameFilter = nameFilter;
+ this.keybindings = keybindings;
this.currentSessionFilePath = currentSessionFilePath;
// Handle Enter in search input - select current item
@@ -218,6 +250,11 @@ class SessionList implements Component, Focusable {
this.filterSessions(this.searchInput.getValue());
}
+ setNameFilter(nameFilter: NameFilter): void {
+ this.nameFilter = nameFilter;
+ this.filterSessions(this.searchInput.getValue());
+ }
+
setSessions(sessions: SessionInfo[], showCwd: boolean): void {
this.allSessions = sessions;
this.showCwd = showCwd;
@@ -225,7 +262,7 @@ class SessionList implements Component, Focusable {
}
private filterSessions(query: string): void {
- this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode);
+ this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode, this.nameFilter);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}
@@ -257,18 +294,23 @@ class SessionList implements Component, Focusable {
lines.push(""); // Blank line after search
if (this.filteredSessions.length === 0) {
- if (this.showCwd) {
+ let emptyMsg: string;
+ if (this.nameFilter === "named") {
+ const toggleKey = appKey(this.keybindings, "toggleSessionNamedFilter");
+ // Name filter is active - hint to clear it
+ if (this.showCwd) {
+ emptyMsg = ` No named sessions found. Press ${toggleKey} to show all.`;
+ } else {
+ emptyMsg = ` No named sessions in current folder. Press ${toggleKey} to show all, or Tab to view all.`;
+ }
+ } else if (this.showCwd) {
// "All" scope - no sessions anywhere that match filter
- lines.push(theme.fg("muted", truncateToWidth(" No sessions found", width, "…")));
+ emptyMsg = " No sessions found";
} else {
// "Current folder" scope - hint to try "all"
- lines.push(
- theme.fg(
- "muted",
- truncateToWidth(" No sessions in current folder. Press Tab to view all.", width, "…"),
- ),
- );
+ emptyMsg = " No sessions in current folder. Press Tab to view all.";
}
+ lines.push(theme.fg("muted", truncateToWidth(emptyMsg, width, "…")));
return lines;
}
@@ -374,6 +416,12 @@ class SessionList implements Component, Focusable {
return;
}
+ // Toggle named-only filter (configurable)
+ if (this.keybindings.matches(keyData, "toggleSessionNamedFilter")) {
+ this.onToggleNameFilter?.();
+ return;
+ }
+
// Ctrl+P: toggle path display
if (matchesKey(keyData, "ctrl+p")) {
this.showPath = !this.showPath;
@@ -485,8 +533,10 @@ async function deleteSessionFile(
export class SessionSelectorComponent extends Container implements Focusable {
private sessionList: SessionList;
private header: SessionSelectorHeader;
+ private keybindings: KeybindingsManager;
private scope: SessionScope = "current";
private sortMode: SortMode = "relevance";
+ private nameFilter: NameFilter = "all";
private currentSessions: SessionInfo[] | null = null;
private allSessions: SessionInfo[] | null = null;
private currentSessionsLoader: SessionsLoader;
@@ -515,13 +565,44 @@ export class SessionSelectorComponent extends Container implements Focusable {
onExit: () => void,
requestRender: () => void,
currentSessionFilePath?: string,
+ );
+ constructor(
+ currentSessionsLoader: SessionsLoader,
+ allSessionsLoader: SessionsLoader,
+ onSelect: (sessionPath: string) => void,
+ onCancel: () => void,
+ onExit: () => void,
+ requestRender: () => void,
+ keybindings: KeybindingsManager,
+ currentSessionFilePath?: string,
+ );
+ constructor(
+ currentSessionsLoader: SessionsLoader,
+ allSessionsLoader: SessionsLoader,
+ onSelect: (sessionPath: string) => void,
+ onCancel: () => void,
+ onExit: () => void,
+ requestRender: () => void,
+ arg7?: KeybindingsManager | string,
+ arg8?: string,
) {
super();
+
+ const keybindings = typeof arg7 === "string" || arg7 === undefined ? KeybindingsManager.create() : arg7;
+ const currentSessionFilePath = typeof arg7 === "string" ? arg7 : arg8;
+
this.currentSessionsLoader = currentSessionsLoader;
this.allSessionsLoader = allSessionsLoader;
this.onCancel = onCancel;
this.requestRender = requestRender;
- this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
+ this.keybindings = keybindings;
+ this.header = new SessionSelectorHeader(
+ this.scope,
+ this.sortMode,
+ this.nameFilter,
+ this.keybindings,
+ this.requestRender,
+ );
// Add header
this.addChild(new Spacer(1));
@@ -531,7 +612,14 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
// Create session list (starts empty, will be populated after load)
- this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
+ this.sessionList = new SessionList(
+ [],
+ false,
+ this.sortMode,
+ this.nameFilter,
+ this.keybindings,
+ currentSessionFilePath,
+ );
// Ensure header status timeouts are cleared when leaving the selector
const clearStatusMessage = () => this.header.setStatusMessage(null);
@@ -549,6 +637,7 @@ export class SessionSelectorComponent extends Container implements Focusable {
};
this.sessionList.onToggleScope = () => this.toggleScope();
this.sessionList.onToggleSort = () => this.toggleSortMode();
+ this.sessionList.onToggleNameFilter = () => this.toggleNameFilter();
// Sync list events to header
this.sessionList.onTogglePath = (showPath) => {
@@ -641,6 +730,13 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.requestRender();
}
+ private toggleNameFilter(): void {
+ this.nameFilter = this.nameFilter === "all" ? "named" : "all";
+ this.header.setNameFilter(this.nameFilter);
+ this.sessionList.setNameFilter(this.nameFilter);
+ this.requestRender();
+ }
+
private toggleScope(): void {
if (this.scope === "current") {
this.scope = "all";
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index ab4455d8c..05a6494f9 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -3091,6 +3091,7 @@ export class InteractiveMode {
void this.shutdown();
},
() => this.ui.requestRender(),
+ this.keybindings,
this.sessionManager.getSessionFile(),
);
return { component: selector, focus: selector.getSessionList() };
diff --git a/packages/coding-agent/test/session-selector-path-delete.test.ts b/packages/coding-agent/test/session-selector-path-delete.test.ts
index f1b2e96ac..c65e41ff8 100644
--- a/packages/coding-agent/test/session-selector-path-delete.test.ts
+++ b/packages/coding-agent/test/session-selector-path-delete.test.ts
@@ -1,4 +1,5 @@
import { beforeAll, describe, expect, it } from "vitest";
+import { KeybindingsManager } from "../src/core/keybindings.js";
import type { SessionInfo } from "../src/core/session-manager.js";
import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
import { initTheme } from "../src/modes/interactive/theme/theme.js";
@@ -43,6 +44,7 @@ const CTRL_D = "\x04";
const CTRL_BACKSPACE = "\x1b[127;5u";
describe("session selector path/delete interactions", () => {
+ const keybindings = KeybindingsManager.inMemory();
beforeAll(() => {
// session selector uses the global theme instance
initTheme("dark");
@@ -57,6 +59,7 @@ describe("session selector path/delete interactions", () => {
() => {},
() => {},
() => {},
+ keybindings,
);
await flushPromises();
@@ -80,6 +83,7 @@ describe("session selector path/delete interactions", () => {
() => {},
() => {},
() => {},
+ keybindings,
);
await flushPromises();
@@ -103,6 +107,7 @@ describe("session selector path/delete interactions", () => {
() => {},
() => {},
() => {},
+ keybindings,
);
await flushPromises();
@@ -138,6 +143,7 @@ describe("session selector path/delete interactions", () => {
() => {},
() => {},
() => {},
+ keybindings,
);
await flushPromises();
@@ -169,6 +175,7 @@ describe("session selector path/delete interactions", () => {
() => {},
() => {},
() => {},
+ keybindings,
);
await flushPromises();
diff --git a/packages/coding-agent/test/session-selector-search.test.ts b/packages/coding-agent/test/session-selector-search.test.ts
index 00d666066..7e7272797 100644
--- a/packages/coding-agent/test/session-selector-search.test.ts
+++ b/packages/coding-agent/test/session-selector-search.test.ts
@@ -124,4 +124,72 @@ describe("session selector search", () => {
const result = filterAndSortSessions(sessions, "re:(", "recent");
expect(result).toEqual([]);
});
+
+ describe("name filter", () => {
+ const sessions: SessionInfo[] = [
+ makeSession({
+ id: "named1",
+ name: "My Project",
+ modified: new Date("2026-01-03T00:00:00.000Z"),
+ allMessagesText: "blueberry",
+ }),
+ makeSession({
+ id: "named2",
+ name: "Another Named",
+ modified: new Date("2026-01-02T00:00:00.000Z"),
+ allMessagesText: "blueberry",
+ }),
+ makeSession({
+ id: "other1",
+ modified: new Date("2026-01-04T00:00:00.000Z"),
+ allMessagesText: "blueberry",
+ }),
+ makeSession({
+ id: "other2",
+ modified: new Date("2026-01-01T00:00:00.000Z"),
+ allMessagesText: "blueberry",
+ }),
+ ];
+
+ it("returns all sessions when nameFilter is 'all'", () => {
+ const result = filterAndSortSessions(sessions, "", "recent", "all");
+ expect(result.map((s) => s.id)).toEqual(["named1", "named2", "other1", "other2"]);
+ });
+
+ it("returns only named sessions when nameFilter is 'named'", () => {
+ const result = filterAndSortSessions(sessions, "", "recent", "named");
+ expect(result.map((s) => s.id)).toEqual(["named1", "named2"]);
+ });
+
+ it("applies name filter before search query", () => {
+ const result = filterAndSortSessions(sessions, "blueberry", "recent", "named");
+ expect(result.map((s) => s.id)).toEqual(["named1", "named2"]);
+ });
+
+ it("excludes whitespace-only names from named filter", () => {
+ const sessionsWithWhitespace: SessionInfo[] = [
+ makeSession({
+ id: "whitespace",
+ name: " ",
+ modified: new Date("2026-01-01T00:00:00.000Z"),
+ allMessagesText: "test",
+ }),
+ makeSession({
+ id: "empty",
+ name: "",
+ modified: new Date("2026-01-02T00:00:00.000Z"),
+ allMessagesText: "test",
+ }),
+ makeSession({
+ id: "named",
+ name: "Real Name",
+ modified: new Date("2026-01-03T00:00:00.000Z"),
+ allMessagesText: "test",
+ }),
+ ];
+
+ const result = filterAndSortSessions(sessionsWithWhitespace, "", "recent", "named");
+ expect(result.map((s) => s.id)).toEqual(["named"]);
+ });
+ });
});>gh pr diff 863 --repo badlogic/pi-mono 2>/dev/nulldiff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 8633df591..2ddd594d1 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -546,6 +546,7 @@ pi --session a8ec1c2a # Resume by session ID (partial UUID)
In the `/resume` picker:
- `Ctrl+P` toggles display of the session `.jsonl` file path
- `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session)
+- `Ctrl+R` opens `Rename Session` component, `Esc` cancels and returns to session list, `Enter` applies the new name and reloads the list.
**Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions/<project>/` (e.g., `2025-12-13T17-47-46-817Z_a8ec1c2a-5a5f-4699-88cb-03e7d3cb9292.jsonl`). The UUID is the part after the underscore. You can also search by session ID in the `pi -r` picker.
diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts
index 3ca22355c..e2e8d0f15 100644
--- a/packages/coding-agent/src/cli/session-picker.ts
+++ b/packages/coding-agent/src/cli/session-picker.ts
@@ -39,6 +39,7 @@ export async function selectSession(
process.exit(0);
},
() => ui.requestRender(),
+ { showRenameHint: false },
);
ui.addChild(selector);
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index 95e2d9db4..17ff363d1 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -498,6 +498,44 @@ function extractTextContent(message: Message): string {
.join(" ");
}
+function getLastActivityTime(entries: FileEntry[]): number | undefined {
+ let lastActivityTime: number | undefined;
+
+ for (const entry of entries) {
+ if (entry.type !== "message") continue;
+
+ const message = (entry as SessionMessageEntry).message;
+ if (!isMessageWithContent(message)) continue;
+ if (message.role !== "user" && message.role !== "assistant") continue;
+
+ const msgTimestamp = (message as { timestamp?: number }).timestamp;
+ if (typeof msgTimestamp === "number") {
+ lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
+ continue;
+ }
+
+ const entryTimestamp = (entry as SessionEntryBase).timestamp;
+ if (typeof entryTimestamp === "string") {
+ const t = new Date(entryTimestamp).getTime();
+ if (!Number.isNaN(t)) {
+ lastActivityTime = Math.max(lastActivityTime ?? 0, t);
+ }
+ }
+ }
+
+ return lastActivityTime;
+}
+
+function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {
+ const lastActivityTime = getLastActivityTime(entries);
+ if (typeof lastActivityTime === "number" && lastActivityTime > 0) {
+ return new Date(lastActivityTime);
+ }
+
+ const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
+ return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;
+}
+
async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
try {
const content = await readFile(filePath, "utf8");
@@ -550,13 +588,15 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : "";
+ const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);
+
return {
path: filePath,
id: (header as SessionHeader).id,
cwd,
name,
created: new Date((header as SessionHeader).timestamp),
- modified: stats.mtime,
+ modified,
messageCount,
firstMessage: firstMessage || "(no messages)",
allMessagesText: allMessages.join(" "),
diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
index c6d8d13b9..41ee1ba78 100644
--- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
@@ -10,6 +10,7 @@ import {
Input,
matchesKey,
Spacer,
+ Text,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
@@ -56,6 +57,7 @@ class SessionSelectorHeader implements Component {
private confirmingDeletePath: string | null = null;
private statusMessage: { type: "info" | "error"; message: string } | null = null;
private statusTimeout: ReturnType<typeof setTimeout> | null = null;
+ private showRenameHint = false;
constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) {
this.scope = scope;
@@ -85,6 +87,10 @@ class SessionSelectorHeader implements Component {
this.showPath = showPath;
}
+ setShowRenameHint(show: boolean): void {
+ this.showRenameHint = show;
+ }
+
setConfirmingDeletePath(path: string | null): void {
this.confirmingDeletePath = path;
}
@@ -146,12 +152,15 @@ class SessionSelectorHeader implements Component {
const pathState = this.showPath ? "(on)" : "(off)";
const sep = theme.fg("muted", " · ");
const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
- const hint2 =
- rawKeyHint("ctrl+r", "sort") +
- sep +
- rawKeyHint("ctrl+d", "delete") +
- sep +
- rawKeyHint("ctrl+p", `path ${pathState}`);
+ const hint2Parts = [
+ rawKeyHint("ctrl+n", "sort"),
+ rawKeyHint("ctrl+d", "delete"),
+ rawKeyHint("ctrl+p", `path ${pathState}`),
+ ];
+ if (this.showRenameHint) {
+ hint2Parts.push(rawKeyHint("ctrl+r", "rename"));
+ }
+ const hint2 = hint2Parts.join(sep);
hintLine1 = truncateToWidth(hint1, width, "…");
hintLine2 = truncateToWidth(hint2, width, "…");
}
@@ -164,6 +173,10 @@ class SessionSelectorHeader implements Component {
* Custom session list component with multi-line items and search
*/
class SessionList implements Component, Focusable {
+ public getSelectedSessionPath(): string | undefined {
+ const selected = this.filteredSessions[this.selectedIndex];
+ return selected?.path;
+ }
private allSessions: SessionInfo[] = [];
private filteredSessions: SessionInfo[] = [];
private selectedIndex: number = 0;
@@ -181,6 +194,7 @@ class SessionList implements Component, Focusable {
public onTogglePath?: (showPath: boolean) => void;
public onDeleteConfirmationChange?: (path: string | null) => void;
public onDeleteSession?: (sessionPath: string) => Promise<void>;
+ public onRenameSession?: (sessionPath: string) => void;
public onError?: (message: string) => void;
private maxVisible: number = 5; // Max sessions visible (each session: message + metadata + optional path + blank)
@@ -369,7 +383,7 @@ class SessionList implements Component, Focusable {
return;
}
- if (matchesKey(keyData, "ctrl+r")) {
+ if (matchesKey(keyData, "ctrl+n")) {
this.onToggleSort?.();
return;
}
@@ -387,6 +401,15 @@ class SessionList implements Component, Focusable {
return;
}
+ // Ctrl+R: rename selected session
+ if (matchesKey(keyData, "ctrl+r")) {
+ const selected = this.filteredSessions[this.selectedIndex];
+ if (selected) {
+ this.onRenameSession?.(selected.path);
+ }
+ return;
+ }
+
// Ctrl+Backspace: non-invasive convenience alias for delete
// Only triggers deletion when the query is empty; otherwise it is forwarded to the input
if (matchesKey(keyData, "ctrl+backspace")) {
@@ -483,6 +506,21 @@ async function deleteSessionFile(
* Component that renders a session selector
*/
export class SessionSelectorComponent extends Container implements Focusable {
+ handleInput(data: string): void {
+ if (this.mode === "rename") {
+ const kb = getEditorKeybindings();
+ if (kb.matches(data, "selectCancel") || matchesKey(data, "ctrl+c")) {
+ this.exitRenameMode();
+ return;
+ }
+ this.renameInput.handleInput(data);
+ return;
+ }
+
+ this.sessionList.handleInput(data);
+ }
+
+ private canRename = true;
private sessionList: SessionList;
private header: SessionSelectorHeader;
private scope: SessionScope = "current";
@@ -493,10 +531,15 @@ export class SessionSelectorComponent extends Container implements Focusable {
private allSessionsLoader: SessionsLoader;
private onCancel: () => void;
private requestRender: () => void;
+ private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
private currentLoading = false;
private allLoading = false;
private allLoadSeq = 0;
+ private mode: "list" | "rename" = "list";
+ private renameInput = new Input();
+ private renameTargetPath: string | null = null;
+
// Focusable implementation - propagate to sessionList for IME cursor positioning
private _focused = false;
get focused(): boolean {
@@ -505,6 +548,24 @@ export class SessionSelectorComponent extends Container implements Focusable {
set focused(value: boolean) {
this._focused = value;
this.sessionList.focused = value;
+ this.renameInput.focused = value;
+ if (value && this.mode === "rename") {
+ this.renameInput.focused = true;
+ }
+ }
+
+ private buildBaseLayout(content: Component, options?: { showHeader?: boolean }): void {
+ this.clear();
+ this.addChild(new Spacer(1));
+ this.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
+ this.addChild(new Spacer(1));
+ if (options?.showHeader ?? true) {
+ this.addChild(this.header);
+ this.addChild(new Spacer(1));
+ }
+ this.addChild(content);
+ this.addChild(new Spacer(1));
+ this.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
}
constructor(
@@ -514,6 +575,10 @@ export class SessionSelectorComponent extends Container implements Focusable {
onCancel: () => void,
onExit: () => void,
requestRender: () => void,
+ options?: {
+ renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
+ showRenameHint?: boolean;
+ },
currentSessionFilePath?: string,
) {
super();
@@ -522,17 +587,20 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.onCancel = onCancel;
this.requestRender = requestRender;
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
-
- // Add header
- this.addChild(new Spacer(1));
- this.addChild(new DynamicBorder());
- this.addChild(new Spacer(1));
- this.addChild(this.header);
- this.addChild(new Spacer(1));
+ const renameSession = options?.renameSession;
+ this.renameSession = renameSession;
+ this.canRename = !!renameSession;
+ this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename);
// Create session list (starts empty, will be populated after load)
this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
+ this.buildBaseLayout(this.sessionList);
+
+ this.renameInput.onSubmit = (value) => {
+ void this.confirmRename(value);
+ };
+
// Ensure header status timeouts are cleared when leaving the selector
const clearStatusMessage = () => this.header.setStatusMessage(null);
this.sessionList.onSelect = (sessionPath) => {
@@ -549,6 +617,15 @@ export class SessionSelectorComponent extends Container implements Focusable {
};
this.sessionList.onToggleScope = () => this.toggleScope();
this.sessionList.onToggleSort = () => this.toggleSortMode();
+ this.sessionList.onRenameSession = (sessionPath) => {
+ if (!renameSession) return;
+ if (this.scope === "current" && this.currentLoading) return;
+ if (this.scope === "all" && this.allLoading) return;
+
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
+ const session = sessions.find((s) => s.path === sessionPath);
+ this.enterRenameMode(sessionPath, session?.name);
+ };
// Sync list events to header
this.sessionList.onTogglePath = (showPath) => {
@@ -582,6 +659,7 @@ export class SessionSelectorComponent extends Container implements Focusable {
const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted";
this.header.setStatusMessage({ type: "info", message: msg }, 2000);
+ await this.refreshSessionsAfterMutation();
} else {
const errorMessage = result.error ?? "Unknown error";
this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000);
@@ -590,48 +668,128 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.requestRender();
};
- this.addChild(this.sessionList);
-
- // Add bottom border
- this.addChild(new Spacer(1));
- this.addChild(new DynamicBorder());
-
// Start loading current sessions immediately
this.loadCurrentSessions();
}
private loadCurrentSessions(): void {
- this.currentLoading = true;
- this.header.setScope("current");
+ void this.loadScope("current", "initial");
+ }
+
+ private enterRenameMode(sessionPath: string, currentName: string | undefined): void {
+ this.mode = "rename";
+ this.renameTargetPath = sessionPath;
+ this.renameInput.setValue(currentName ?? "");
+ this.renameInput.focused = true;
+
+ const panel = new Container();
+ panel.addChild(new Text(theme.bold("Rename Session"), 1, 0));
+ panel.addChild(new Spacer(1));
+ panel.addChild(this.renameInput);
+ panel.addChild(new Spacer(1));
+ panel.addChild(new Text(theme.fg("muted", "Enter to save · Esc/Ctrl+C to cancel"), 1, 0));
+
+ this.buildBaseLayout(panel, { showHeader: false });
+ this.requestRender();
+ }
+
+ private exitRenameMode(): void {
+ this.mode = "list";
+ this.renameTargetPath = null;
+
+ this.buildBaseLayout(this.sessionList);
+
+ this.requestRender();
+ }
+
+ private async confirmRename(value: string): Promise<void> {
+ const next = value.trim();
+ if (!next) return;
+ const target = this.renameTargetPath;
+ if (!target) {
+ this.exitRenameMode();
+ return;
+ }
+
+ // Find current name for callback
+ const renameSession = this.renameSession;
+ if (!renameSession) {
+ this.exitRenameMode();
+ return;
+ }
+
+ try {
+ await renameSession(target, next);
+ await this.refreshSessionsAfterMutation();
+ } finally {
+ this.exitRenameMode();
+ }
+ }
+
+ private async loadScope(scope: SessionScope, reason: "initial" | "refresh" | "toggle"): Promise<void> {
+ const showCwd = scope === "all";
+
+ // Mark loading
+ if (scope === "current") {
+ this.currentLoading = true;
+ } else {
+ this.allLoading = true;
+ }
+
+ const seq = scope === "all" ? ++this.allLoadSeq : undefined;
+ this.header.setScope(scope);
this.header.setLoading(true);
this.requestRender();
- this.currentSessionsLoader((loaded, total) => {
- if (this.scope !== "current") return;
+ const onProgress = (loaded: number, total: number) => {
+ if (scope !== this.scope) return;
+ if (seq !== undefined && seq !== this.allLoadSeq) return;
this.header.setProgress(loaded, total);
this.requestRender();
- })
- .then((sessions) => {
+ };
+
+ try {
+ const sessions = await (scope === "current"
+ ? this.currentSessionsLoader(onProgress)
+ : this.allSessionsLoader(onProgress));
+
+ if (scope === "current") {
this.currentSessions = sessions;
this.currentLoading = false;
+ } else {
+ this.allSessions = sessions;
+ this.allLoading = false;
+ }
- if (this.scope !== "current") return;
+ if (scope !== this.scope) return;
+ if (seq !== undefined && seq !== this.allLoadSeq) return;
- this.header.setLoading(false);
- this.sessionList.setSessions(sessions, false);
- this.requestRender();
- })
- .catch((error: unknown) => {
+ this.header.setLoading(false);
+ this.sessionList.setSessions(sessions, showCwd);
+ this.requestRender();
+
+ if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
+ this.onCancel();
+ }
+ } catch (err) {
+ if (scope === "current") {
this.currentLoading = false;
- const message = error instanceof Error ? error.message : String(error);
+ } else {
+ this.allLoading = false;
+ }
- if (this.scope !== "current") return;
+ if (scope !== this.scope) return;
+ if (seq !== undefined && seq !== this.allLoadSeq) return;
- this.header.setLoading(false);
- this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
- this.sessionList.setSessions([], false);
- this.requestRender();
- });
+ const message = err instanceof Error ? err.message : String(err);
+ this.header.setLoading(false);
+ this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
+
+ if (reason === "initial") {
+ this.sessionList.setSessions([], showCwd);
+ }
+ this.requestRender();
+ }
}
private toggleSortMode(): void {
@@ -641,6 +799,10 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.requestRender();
}
+ private async refreshSessionsAfterMutation(): Promise<void> {
+ await this.loadScope(this.scope, "refresh");
+ }
+
private toggleScope(): void {
if (this.scope === "current") {
this.scope = "all";
@@ -653,55 +815,17 @@ export class SessionSelectorComponent extends Container implements Focusable {
return;
}
- this.header.setLoading(true);
- this.sessionList.setSessions([], true);
- this.requestRender();
-
- if (this.allLoading) return;
-
- this.allLoading = true;
- const seq = ++this.allLoadSeq;
-
- this.allSessionsLoader((loaded, total) => {
- if (seq !== this.allLoadSeq) return;
- if (this.scope !== "all") return;
- this.header.setProgress(loaded, total);
- this.requestRender();
- })
- .then((sessions) => {
- this.allSessions = sessions;
- this.allLoading = false;
-
- if (seq !== this.allLoadSeq) return;
- if (this.scope !== "all") return;
-
- this.header.setLoading(false);
- this.sessionList.setSessions(sessions, true);
- this.requestRender();
-
- if (sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
- this.onCancel();
- }
- })
- .catch((error: unknown) => {
- this.allLoading = false;
- const message = error instanceof Error ? error.message : String(error);
-
- if (seq !== this.allLoadSeq) return;
- if (this.scope !== "all") return;
-
- this.header.setLoading(false);
- this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
- this.sessionList.setSessions([], true);
- this.requestRender();
- });
- } else {
- this.scope = "current";
- this.header.setScope(this.scope);
- this.header.setLoading(this.currentLoading);
- this.sessionList.setSessions(this.currentSessions ?? [], false);
- this.requestRender();
+ if (!this.allLoading) {
+ void this.loadScope("all", "toggle");
+ }
+ return;
}
+
+ this.scope = "current";
+ this.header.setScope(this.scope);
+ this.header.setLoading(this.currentLoading);
+ this.sessionList.setSessions(this.currentSessions ?? [], false);
+ this.requestRender();
}
getSessionList(): SessionList {
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index b71defdbc..53df332d1 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -3088,9 +3088,19 @@ export class InteractiveMode {
void this.shutdown();
},
() => this.ui.requestRender(),
+ {
+ renameSession: async (sessionFilePath: string, nextName: string | undefined) => {
+ const next = (nextName ?? "").trim();
+ if (!next) return;
+ const mgr = SessionManager.open(sessionFilePath);
+ mgr.appendSessionInfo(next);
+ },
+ showRenameHint: true,
+ },
+
this.sessionManager.getSessionFile(),
);
- return { component: selector, focus: selector.getSessionList() };
+ return { component: selector, focus: selector };
});
}
diff --git a/packages/coding-agent/test/session-info-modified-timestamp.test.ts b/packages/coding-agent/test/session-info-modified-timestamp.test.ts
new file mode 100644
index 000000000..7089cdb2b
--- /dev/null
+++ b/packages/coding-agent/test/session-info-modified-timestamp.test.ts
@@ -0,0 +1,83 @@
+import { writeFileSync } from "node:fs";
+import { stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import type { SessionHeader } from "../src/core/session-manager.js";
+import { SessionManager } from "../src/core/session-manager.js";
+import { initTheme } from "../src/modes/interactive/theme/theme.js";
+
+function createSessionFile(path: string): void {
+ const header: SessionHeader = {
+ type: "session",
+ id: "test-session",
+ version: 3,
+ timestamp: new Date(0).toISOString(),
+ cwd: "/tmp",
+ };
+ writeFileSync(path, `${JSON.stringify(header)}\n`, "utf8");
+
+ // SessionManager only persists once it has seen at least one assistant message.
+ // Add a minimal assistant entry so subsequent appends are persisted.
+ const mgr = SessionManager.open(path);
+ mgr.appendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "hi" }],
+ api: "openai-completions",
+ provider: "openai",
+ model: "test",
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop",
+ timestamp: Date.now(),
+ });
+}
+
+describe("SessionInfo.modified", () => {
+ beforeAll(() => initTheme("dark"));
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("uses last user/assistant message timestamp instead of file mtime", async () => {
+ const filePath = join(tmpdir(), `pi-session-${Date.now()}-modified.jsonl`);
+ createSessionFile(filePath);
+
+ const before = await stat(filePath);
+ // Ensure the file mtime can differ from our message timestamp even on coarse filesystems.
+ await new Promise((r) => setTimeout(r, 10));
+
+ const mgr = SessionManager.open(filePath);
+ const msgTime = Date.now();
+ mgr.appendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "later" }],
+ api: "openai-completions",
+ provider: "openai",
+ model: "test",
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop",
+ timestamp: msgTime,
+ });
+
+ const sessions = await SessionManager.list("/tmp", filePath.replace(/\/[^/]+$/, ""));
+ const s = sessions.find((x) => x.path === filePath);
+ expect(s).toBeDefined();
+ expect(s!.modified.getTime()).toBe(msgTime);
+ expect(s!.modified.getTime()).not.toBe(before.mtime.getTime());
+ });
+});
diff --git a/packages/coding-agent/test/session-selector-rename.test.ts b/packages/coding-agent/test/session-selector-rename.test.ts
new file mode 100644
index 000000000..801814d5a
--- /dev/null
+++ b/packages/coding-agent/test/session-selector-rename.test.ts
@@ -0,0 +1,101 @@
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import type { SessionInfo } from "../src/core/session-manager.js";
+import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
+import { initTheme } from "../src/modes/interactive/theme/theme.js";
+
+async function flushPromises(): Promise<void> {
+ await new Promise<void>((resolve) => {
+ setImmediate(resolve);
+ });
+}
+
+function makeSession(overrides: Partial<SessionInfo> & { id: string }): SessionInfo {
+ return {
+ path: overrides.path ?? `/tmp/${overrides.id}.jsonl`,
+ id: overrides.id,
+ cwd: overrides.cwd ?? "",
+ name: overrides.name,
+ created: overrides.created ?? new Date(0),
+ modified: overrides.modified ?? new Date(0),
+ messageCount: overrides.messageCount ?? 1,
+ firstMessage: overrides.firstMessage ?? "hello",
+ allMessagesText: overrides.allMessagesText ?? "hello",
+ };
+}
+
+// Kitty keyboard protocol encoding for Ctrl+R
+const CTRL_R = "\x1b[114;5u";
+
+describe("session selector rename", () => {
+ beforeAll(() => {
+ initTheme("dark");
+ });
+
+ it("shows rename hint in interactive /resume picker configuration", async () => {
+ const sessions = [makeSession({ id: "a" })];
+ const selector = new SessionSelectorComponent(
+ async () => sessions,
+ async () => [],
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ { showRenameHint: true },
+ );
+ await flushPromises();
+
+ const output = selector.render(120).join("\n");
+ expect(output).toContain("ctrl+r");
+ expect(output).toContain("rename");
+ });
+
+ it("does not show rename hint in --resume picker configuration", async () => {
+ const sessions = [makeSession({ id: "a" })];
+ const selector = new SessionSelectorComponent(
+ async () => sessions,
+ async () => [],
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ { showRenameHint: false },
+ );
+ await flushPromises();
+
+ const output = selector.render(120).join("\n");
+ expect(output).not.toContain("ctrl+r");
+ expect(output).not.toContain("rename");
+ });
+
+ it("enters rename mode on Ctrl+R and submits with Enter", async () => {
+ const sessions = [makeSession({ id: "a", name: "Old" })];
+ const renameSession = vi.fn(async () => {});
+
+ const selector = new SessionSelectorComponent(
+ async () => sessions,
+ async () => [],
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ { renameSession, showRenameHint: true },
+ );
+ await flushPromises();
+
+ selector.getSessionList().handleInput(CTRL_R);
+ await flushPromises();
+
+ // Rename mode layout
+ const output = selector.render(120).join("\n");
+ expect(output).toContain("Rename Session");
+ expect(output).not.toContain("Resume Session");
+
+ // Type and submit
+ selector.handleInput("X");
+ selector.handleInput("\r");
+ await flushPromises();
+
+ expect(renameSession).toHaveBeenCalledTimes(1);
+ expect(renameSession).toHaveBeenCalledWith(sessions[0]!.path, "XOld");
+ });
+});>gh pr diff 345 --repo badlogic/pi-mono 2>/dev/null+
+ if (!hasHttpRequestHandlers && !hasHttpResponseHandlers) {
+ return undefined;
+ }
+
+ return (model: Model<any>): typeof globalThis.fetch => {
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
+ const method =
+ init?.method || (typeof input !== "string" && !(input instanceof URL) ? input.method : undefined) || "GET";
+
+ const normalizeHeaderValue = (value: unknown): string => {
+ if (Array.isArray(value)) {
+ return value.map((v) => String(v)).join(", ");
+ }
+ if (value === undefined || value === null) return "";
+ return String(value);
+ };
+
+ // Build headers record from input Request + init
+ let headers: Record<string, string> = {};
+
+ if (typeof input !== "string" && !(input instanceof URL)) {
+ headers = headersToRecord(input.headers);
+ }
+
+ if (init?.headers) {
+ if (init.headers instanceof Headers) {
+ headers = { ...headers, ...headersToRecord(init.headers) };
+ } else if (Array.isArray(init.headers)) {
+ for (const [key, value] of init.headers) {
+ headers[key] = normalizeHeaderValue(value);
+ }
+ } else {
+ for (const [key, value] of Object.entries(init.headers)) {
+ headers[key] = normalizeHeaderValue(value);
+ }
+ }
+ }
+
+ // Emit http_request event with redacted headers
+ let extraHeaders: Record<string, string> | undefined;
+ if (hasHttpRequestHandlers) {
+ const result = await runner.emitHttpRequest({
+ provider: model.provider,
+ modelId: model.id,
+ url,
+ method,
+ headers: redactHeaders(headers),
+ body: typeof init?.body === "string" ? init.body : undefined,
+ });
+
+ if (result?.cancel) {
+ throw new Error("HTTP request cancelled by extension");
+ }
+ extraHeaders = result?.headers;
+ }
+
+ const finalInit: RequestInit = { ...init };
+ if (extraHeaders) {
+ finalInit.headers = { ...headers, ...extraHeaders };
+ }
+
+ const startTime = Date.now();
+ const response = await globalThis.fetch(input, finalInit);
+ const durationMs = Date.now() - startTime;
+
+ if (hasHttpResponseHandlers) {
+ await runner.emitHttpResponse({
+ provider: model.provider,
+ modelId: model.id,
+ status: response.status,
+ headers: headersToRecord(response.headers),
+ durationMs,
+ });
+ }
+
+ return response;
+ };
+ };
+}
diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts
index 4b7c901b8..aa25ca0f5 100644
--- a/packages/coding-agent/src/core/extensions/index.ts
+++ b/packages/coding-agent/src/core/extensions/index.ts
@@ -40,6 +40,9 @@ export type {
GetActiveToolsHandler,
GetAllToolsHandler,
GrepToolResultEvent,
+ HttpRequestEvent,
+ HttpRequestEventResult,
+ HttpResponseEvent,
LoadExtensionsResult,
// Loaded Extension
LoadedExtension,
diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts
index 41ad7883d..80536ec3f 100644
--- a/packages/coding-agent/src/core/extensions/runner.ts
+++ b/packages/coding-agent/src/core/extensions/runner.ts
@@ -23,6 +23,9 @@ import type {
ExtensionUIContext,
GetActiveToolsHandler,
GetAllToolsHandler,
+ HttpRequestEvent,
+ HttpRequestEventResult,
+ HttpResponseEvent,
LoadedExtension,
MessageRenderer,
RegisteredCommand,
@@ -465,4 +468,76 @@ export class ExtensionRunner {
return undefined;
}
+
+ /**
+ * Emit http_request event to all extensions.
+ * Returns the merged result from all handlers (headers merged, cancel if any).
+ */
+ async emitHttpRequest(event: Omit<HttpRequestEvent, "type">): Promise<HttpRequestEventResult | undefined> {
+ const ctx = this.createContext();
+ let mergedHeaders: Record<string, string> | undefined;
+ let cancel = false;
+
+ const fullEvent: HttpRequestEvent = { type: "http_request", ...event };
+
+ for (const ext of this.extensions) {
+ const handlers = ext.handlers.get("http_request");
+ if (!handlers || handlers.length === 0) continue;
+
+ for (const handler of handlers) {
+ try {
+ const handlerResult = await handler(fullEvent, ctx);
+ if (handlerResult) {
+ const result = handlerResult as HttpRequestEventResult;
+ if (result.cancel) cancel = true;
+ if (result.headers) {
+ mergedHeaders = { ...mergedHeaders, ...result.headers };
+ }
+ }
+ } 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: "http_request",
+ error: message,
+ stack,
+ });
+ }
+ }
+ }
+
+ if (cancel || mergedHeaders) {
+ return { cancel, headers: mergedHeaders };
+ }
+ return undefined;
+ }
+
+ /**
+ * Emit http_response event to all extensions.
+ */
+ async emitHttpResponse(event: Omit<HttpResponseEvent, "type">): Promise<void> {
+ const ctx = this.createContext();
+ const fullEvent: HttpResponseEvent = { type: "http_response", ...event };
+
+ for (const ext of this.extensions) {
+ const handlers = ext.handlers.get("http_response");
+ if (!handlers || handlers.length === 0) continue;
+
+ for (const handler of handlers) {
+ try {
+ await handler(fullEvent, ctx);
+ } 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: "http_response",
+ error: message,
+ stack,
+ });
+ }
+ }
+ }
+ }
}
diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts
index 44bd7b6f4..f2d56e5be 100644
--- a/packages/coding-agent/src/core/extensions/types.ts
+++ b/packages/coding-agent/src/core/extensions/types.ts
@@ -413,6 +413,39 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
return e.toolName === "ls";
}
+// ============================================================================
+// HTTP Events
+// ============================================================================
+
+/**
+ * Fired before an HTTP request is made to an LLM provider.
+ * Extensions can add headers or cancel the request.
+ */
+export interface HttpRequestEvent {
+ type: "http_request";
+ provider: string;
+ modelId: string;
+ url: string;
+ method: string;
+ /** Request headers with sensitive values redacted */
+ headers: Record<string, string>;
+ /** Request body (if present and a string) */
+ body?: string;
+}
+
+/**
+ * Fired after an HTTP response is received from an LLM provider.
+ * Useful for logging and monitoring.
+ */
+export interface HttpResponseEvent {
+ type: "http_response";
+ provider: string;
+ modelId: string;
+ status: number;
+ headers: Record<string, string>;
+ durationMs: number;
+}
+
/** Union of all event types */
export type ExtensionEvent =
| SessionEvent
@@ -423,7 +456,9 @@ export type ExtensionEvent =
| TurnStartEvent
| TurnEndEvent
| ToolCallEvent
- | ToolResultEvent;
+ | ToolResultEvent
+ | HttpRequestEvent
+ | HttpResponseEvent;
// ============================================================================
// Event Results
@@ -471,6 +506,13 @@ export interface SessionBeforeTreeResult {
};
}
+export interface HttpRequestEventResult {
+ /** Additional headers to merge into the request */
+ headers?: Record<string, string>;
+ /** If true, cancel the request (throws an error) */
+ cancel?: boolean;
+}
+
// ============================================================================
// Message Rendering
// ============================================================================
@@ -538,6 +580,8 @@ export interface ExtensionAPI {
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;
+ on(event: "http_request", handler: ExtensionHandler<HttpRequestEvent, HttpRequestEventResult>): void;
+ on(event: "http_response", handler: ExtensionHandler<HttpResponseEvent>): void;
// =========================================================================
// Tool Registration
diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts
index e15926e81..41b9318f1 100644
--- a/packages/coding-agent/src/core/sdk.ts
+++ b/packages/coding-agent/src/core/sdk.ts
@@ -27,6 +27,7 @@ import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { createEventBus, type EventBus } from "./event-bus.js";
+import { createExtensionFetchFactory } from "./extensions/http.js";
import {
discoverAndLoadExtensions,
type ExtensionFactory,
@@ -303,7 +304,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() },
};
}
-
// Factory
/**
@@ -672,6 +672,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
return key;
},
+ createFetch: createExtensionFetchFactory(extensionRunner),
});
time("createAgent");
diff --git a/packages/coding-agent/test/hooks/http-hooks-integration.test.ts b/packages/coding-agent/test/hooks/http-hooks-integration.test.ts
new file mode 100644
index 000000000..a53b31b4a
--- /dev/null
+++ b/packages/coding-agent/test/hooks/http-hooks-integration.test.ts
@@ -0,0 +1,852 @@
+/**
+ * Integration tests for HTTP extensions with the fetch wrapper.
+ *
+ * Tests the full flow from extension registration through the fetch wrapper
+ * that integrates with pi-ai.
+ */
+
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import type { Api, Model } from "@mariozechner/pi-ai";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createExtensionFetchFactory, headersToRecord, redactHeaders } from "../../src/core/extensions/http.js";
+import { ExtensionRunner } from "../../src/core/extensions/runner.js";
+import type { HttpRequestEvent, HttpResponseEvent, LoadedExtension } from "../../src/core/extensions/types.js";
+import type { ModelRegistry } from "../../src/core/model-registry.js";
+import type { SessionManager } from "../../src/core/session-manager.js";
+
+// Mock SessionManager
+function createMockSessionManager(): SessionManager {
+ return {
+ getCwd: () => "/test",
+ getSessionDir: () => "/test/.pi/sessions",
+ getSessionId: () => "test-session",
+ getSessionFile: () => "/test/.pi/sessions/test.jsonl",
+ getLeafUuid: () => "test-uuid",
+ getLeafEntry: () => undefined,
+ getEntry: () => undefined,
+ getLabel: () => undefined,
+ getPath: () => [],
+ getHeader: () => undefined,
+ getEntries: () => [],
+ getTree: () => ({ roots: [], currentPath: [] }),
+ } as unknown as SessionManager;
+}
+
+// Mock ModelRegistry
+function createMockModelRegistry(): ModelRegistry {
+ return {
+ getApiKey: async () => "test-key",
+ find: () => undefined,
+ getAll: () => [],
+ } as unknown as ModelRegistry;
+}
+
+// Mock Model
+function createMockModel(): Model<Api> {
+ return {
+ id: "claude-sonnet-4-5",
+ name: "Claude Sonnet 4.5",
+ api: "anthropic-messages",
+ provider: "anthropic",
+ baseUrl: "https://api.anthropic.com",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+ contextWindow: 200000,
+ maxTokens: 8192,
+ } as Model<Api>;
+}
+
+// Create a LoadedExtension
+function createExtension(
+ onHttpRequest?: (event: HttpRequestEvent) => { headers?: Record<string, string>; cancel?: boolean } | undefined,
+ onHttpResponse?: (event: HttpResponseEvent) => undefined,
+): LoadedExtension {
+ const handlers = new Map<string, ((event: unknown, ctx: unknown) => Promise<unknown>)[]>();
+
+ if (onHttpRequest) {
+ handlers.set("http_request", [async (event: unknown) => onHttpRequest(event as HttpRequestEvent)]);
+ }
+
+ if (onHttpResponse) {
+ handlers.set("http_response", [async (event: unknown) => onHttpResponse(event as HttpResponseEvent)]);
+ }
+
+ return {
+ path: "test-extension",
+ resolvedPath: "/test/test-extension.ts",
+ handlers,
+ tools: new Map(),
+ messageRenderers: new Map(),
+ commands: new Map(),
+ flags: new Map(),
+ flagValues: new Map(),
+ shortcuts: new Map(),
+ setSendMessageHandler: () => {},
+ setSendUserMessageHandler: () => {},
+ setAppendEntryHandler: () => {},
+ setGetActiveToolsHandler: () => {},
+ setGetAllToolsHandler: () => {},
+ setSetActiveToolsHandler: () => {},
+ setFlagValue: () => {},
+ };
+}
+
+/**
+ * Helper to create the wrapped fetch using the real createExtensionFetchFactory.
+ */
+function createFetchWithExtensions(extensionRunner: ExtensionRunner, model: Model<Api>): typeof globalThis.fetch {
+ const factory = createExtensionFetchFactory(extensionRunner);
+ if (!factory) {
+ throw new Error("Expected createExtensionFetchFactory to return a factory when extensions are registered");
+ }
+ return factory(model);
+}
+
+describe("HTTP extensions integration", () => {
+ let server: Server;
+ let serverPort: number;
+ let serverUrl: string;
+ let receivedHeaders: Record<string, string | string[] | undefined>;
+ let _receivedBody: string;
+
+ beforeEach(async () => {
+ receivedHeaders = {};
+ _receivedBody = "";
+
+ // Create a simple test server
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
+ // Capture received headers
+ receivedHeaders = { ...req.headers };
+
+ // Capture body
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
+ req.on("end", () => {
+ _receivedBody = Buffer.concat(chunks).toString();
+ res.writeHead(200, { "content-type": "application/json", "x-server-id": "test-server" });
+ res.end(JSON.stringify({ success: true }));
+ });
+ });
+
+ // Listen on a random available port
+ await new Promise<void>((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const addr = server.address();
+ if (addr && typeof addr === "object") {
+ serverPort = addr.port;
+ serverUrl = `http://127.0.0.1:${serverPort}`;
+ }
+ resolve();
+ });
+ });
+ });
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ await new Promise<void>((resolve) => {
+ server.close(() => resolve());
+ });
+ });
+
+ it("should emit http_request and http_response events through fetch wrapper", async () => {
+ const capturedRequestEvents: HttpRequestEvent[] = [];
+ const capturedResponseEvents: HttpResponseEvent[] = [];
+
+ const ext = createExtension(
+ (event) => {
+ capturedRequestEvents.push(event);
+ return undefined;
+ },
+ (event) => {
+ capturedResponseEvents.push(event);
+ return undefined;
+ },
+ );
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ const response = await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ test: true }),
+ });
+
+ expect(response.status).toBe(200);
+
+ // Verify request event
+ expect(capturedRequestEvents).toHaveLength(1);
+ expect(capturedRequestEvents[0].provider).toBe("anthropic");
+ expect(capturedRequestEvents[0].modelId).toBe("claude-sonnet-4-5");
+ expect(capturedRequestEvents[0].url).toBe(serverUrl);
+ expect(capturedRequestEvents[0].method).toBe("POST");
+
+ // Verify response event
+ expect(capturedResponseEvents).toHaveLength(1);
+ expect(capturedResponseEvents[0].provider).toBe("anthropic");
+ expect(capturedResponseEvents[0].modelId).toBe("claude-sonnet-4-5");
+ expect(capturedResponseEvents[0].status).toBe(200);
+ expect(capturedResponseEvents[0].durationMs).toBeGreaterThanOrEqual(0);
+ expect(capturedResponseEvents[0].headers["content-type"]).toBe("application/json");
+ expect(capturedResponseEvents[0].headers["x-server-id"]).toBe("test-server");
+ });
+
+ it("should allow extensions to inject custom headers", async () => {
+ const ext = createExtension(() => ({
+ headers: {
+ "x-custom-header": "injected-value",
+ "x-request-trace": "trace-123",
+ },
+ }));
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+
+ // Server should receive the injected headers
+ expect(receivedHeaders["x-custom-header"]).toBe("injected-value");
+ expect(receivedHeaders["x-request-trace"]).toBe("trace-123");
+ });
+
+ it("should allow extensions to overwrite existing headers", async () => {
+ const ext = createExtension(() => ({
+ headers: {
+ "x-existing-header": "hook-overwritten-value",
+ },
+ }));
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-existing-header": "original-value",
+ },
+ body: "{}",
+ });
+
+ // Server should receive the hook-overwritten value, not the original
+ expect(receivedHeaders["x-existing-header"]).toBe("hook-overwritten-value");
+ });
+
+ it("should throw error when extension cancels request", async () => {
+ const ext = createExtension(() => ({
+ cancel: true,
+ }));
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await expect(
+ wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ }),
+ ).rejects.toThrow("HTTP request cancelled by extension");
+ });
+
+ it("should redact authorization header in http_request event", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: "Bearer sk-secret-key-12345",
+ },
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ expect(capturedEvent!.headers.authorization).toBe("[REDACTED]");
+
+ // But actual request should have the real header
+ expect(receivedHeaders.authorization).toBe("Bearer sk-secret-key-12345");
+ });
+
+ it("should redact x-api-key header in http_request event", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-api-key": "anthropic-key-abc123",
+ },
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ expect(capturedEvent!.headers["x-api-key"]).toBe("[REDACTED]");
+
+ // But actual request should have the real header
+ expect(receivedHeaders["x-api-key"]).toBe("anthropic-key-abc123");
+ });
+
+ it("should redact headers matching heuristic patterns", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-auth-token": "token-value",
+ "custom-secret-header": "secret-value",
+ "api-key-custom": "key-value",
+ "session-cookie": "cookie-value",
+ },
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ // All should be redacted due to pattern matching
+ expect(capturedEvent!.headers["x-auth-token"]).toBe("[REDACTED]");
+ expect(capturedEvent!.headers["custom-secret-header"]).toBe("[REDACTED]");
+ expect(capturedEvent!.headers["api-key-custom"]).toBe("[REDACTED]");
+ expect(capturedEvent!.headers["session-cookie"]).toBe("[REDACTED]");
+
+ // But actual request should have real values
+ expect(receivedHeaders["x-auth-token"]).toBe("token-value");
+ expect(receivedHeaders["custom-secret-header"]).toBe("secret-value");
+ expect(receivedHeaders["api-key-custom"]).toBe("key-value");
+ expect(receivedHeaders["session-cookie"]).toBe("cookie-value");
+ });
+
+ it("should not redact non-sensitive headers", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ accept: "application/json",
+ "user-agent": "test-agent/1.0",
+ "x-request-id": "12345",
+ },
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ expect(capturedEvent!.headers["content-type"]).toBe("application/json");
+ expect(capturedEvent!.headers.accept).toBe("application/json");
+ expect(capturedEvent!.headers["user-agent"]).toBe("test-agent/1.0");
+ expect(capturedEvent!.headers["x-request-id"]).toBe("12345");
+ });
+
+ it("should normalize array header values to comma-separated string", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Use Headers object which supports append for multiple values
+ const headers = new Headers();
+ headers.append("accept", "application/json");
+ headers.append("accept", "text/plain");
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers,
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ // Headers class joins multiple values with ", "
+ expect(capturedEvent!.headers.accept).toBe("application/json, text/plain");
+ });
+
+ it("should handle string array header values via type assertion", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Pass a string array value directly through init.headers using type assertion
+ const headersWithArray = {
+ "content-type": "application/json",
+ "x-multi-value": ["value1", "value2", "value3"],
+ } as unknown as RequestInit["headers"];
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: headersWithArray,
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ // Array values should be joined into a comma-separated string (with spaces, like Headers class)
+ expect(capturedEvent!.headers["x-multi-value"]).toBe("value1, value2, value3");
+ });
+
+ it("should convert non-string header values to strings", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Use type assertion to force a non-string value (number) through to test normalization
+ // In practice, some libraries might pass numbers which should be converted to strings
+ const headersWithNumber = {
+ "content-type": "application/json",
+ "x-count": 42 as unknown as string, // Force a number through type assertion
+ };
+
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: headersWithNumber,
+ body: "{}",
+ });
+
+ expect(capturedEvent).toBeDefined();
+ expect(typeof capturedEvent!.headers["x-count"]).toBe("string");
+ expect(capturedEvent!.headers["x-count"]).toBe("42");
+
+ // Verify the server also received the header with the string value
+ expect(receivedHeaders["x-count"]).toBe("42");
+ });
+
+ it("should measure response duration accurately", async () => {
+ let capturedDurationMs = 0;
+
+ const ext = createExtension(undefined, (event) => {
+ capturedDurationMs = event.durationMs;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ const startTime = Date.now();
+ await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ const totalTime = Date.now() - startTime;
+
+ // Duration should be captured and reasonably close to actual time
+ expect(capturedDurationMs).toBeGreaterThanOrEqual(0);
+ expect(capturedDurationMs).toBeLessThanOrEqual(totalTime + 100); // Allow some margin
+ });
+
+ it("should handle Request object input and capture its headers", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Create a Request object with headers
+ const request = new Request(serverUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-custom-from-request": "request-value",
+ },
+ body: "{}",
+ });
+
+ const response = await wrappedFetch(request);
+
+ expect(response.status).toBe(200);
+ expect(capturedEvent).toBeDefined();
+ // Verify method and url are correctly extracted from the Request object
+ expect(capturedEvent!.method).toBe("POST");
+ // Request object may normalize the URL (e.g., adding trailing slash)
+ expect(capturedEvent!.url).toContain(serverUrl);
+ expect(capturedEvent!.headers["content-type"]).toBe("application/json");
+ expect(capturedEvent!.headers["x-custom-from-request"]).toBe("request-value");
+
+ // Verify server received the headers
+ expect(receivedHeaders["x-custom-from-request"]).toBe("request-value");
+ });
+
+ it("should have undefined body for non-string bodies but request still succeeds", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ return undefined;
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Use Uint8Array as body
+ const binaryBody = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]); // "hello" in bytes
+
+ const response = await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/octet-stream" },
+ body: binaryBody,
+ });
+
+ expect(response.status).toBe(200);
+ expect(capturedEvent).toBeDefined();
+ // Body should be undefined since it's not a string
+ expect(capturedEvent!.body).toBeUndefined();
+
+ // Server should still receive the body
+ expect(_receivedBody).toBe("hello");
+ });
+
+ it("should fire http_request but not http_response on network errors, and propagate the error", async () => {
+ const capturedRequestEvents: HttpRequestEvent[] = [];
+ const capturedResponseEvents: HttpResponseEvent[] = [];
+
+ const ext = createExtension(
+ (event) => {
+ capturedRequestEvents.push(event);
+ return undefined;
+ },
+ (event) => {
+ capturedResponseEvents.push(event);
+ return undefined;
+ },
+ );
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Mock globalThis.fetch to reject - vitest will restore in afterEach
+ const networkError = new Error("Network error: connection refused");
+ vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(networkError);
+
+ await expect(
+ wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ }),
+ ).rejects.toThrow("Network error: connection refused");
+
+ // http_request should have been emitted before the fetch call
+ expect(capturedRequestEvents).toHaveLength(1);
+ expect(capturedRequestEvents[0].url).toBe(serverUrl);
+
+ // http_response should NOT have been emitted due to the error
+ expect(capturedResponseEvents).toHaveLength(0);
+ });
+
+ it("should not call globalThis.fetch and not emit http_response when hook cancels", async () => {
+ const capturedRequestEvents: HttpRequestEvent[] = [];
+ const capturedResponseEvents: HttpResponseEvent[] = [];
+
+ const ext = createExtension(
+ (event) => {
+ capturedRequestEvents.push(event);
+ return { cancel: true };
+ },
+ (event) => {
+ capturedResponseEvents.push(event);
+ return undefined;
+ },
+ );
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ // Mock globalThis.fetch to track if it's called - vitest will restore in afterEach
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
+
+ await expect(
+ wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ }),
+ ).rejects.toThrow("HTTP request cancelled by extension");
+
+ // http_request was emitted
+ expect(capturedRequestEvents).toHaveLength(1);
+
+ // globalThis.fetch was NEVER called
+ expect(fetchSpy).not.toHaveBeenCalled();
+
+ // http_response was NEVER emitted
+ expect(capturedResponseEvents).toHaveLength(0);
+ });
+
+ it("should handle hook returning empty object (no headers, no cancel)", async () => {
+ let capturedEvent: HttpRequestEvent | undefined;
+
+ const ext = createExtension((event) => {
+ capturedEvent = event;
+ // Return empty object - should be treated as no modifications
+ return {};
+ });
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const model = createMockModel();
+ const wrappedFetch = createFetchWithExtensions(extensionRunner, model);
+
+ const response = await wrappedFetch(serverUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+
+ // Request should succeed
+ expect(response.status).toBe(200);
+ expect(capturedEvent).toBeDefined();
+ });
+});
+
+describe("exported helper functions", () => {
+ describe("redactHeaders", () => {
+ it("should redact sensitive headers", () => {
+ const headers = {
+ "content-type": "application/json",
+ authorization: "Bearer secret123",
+ "x-api-key": "key123",
+ };
+
+ const result = redactHeaders(headers);
+
+ expect(result["content-type"]).toBe("application/json");
+ expect(result.authorization).toBe("[REDACTED]");
+ expect(result["x-api-key"]).toBe("[REDACTED]");
+ });
+
+ it("should redact headers matching patterns", () => {
+ const headers = {
+ "x-auth-token": "token-value",
+ "custom-secret-value": "secret",
+ };
+
+ const result = redactHeaders(headers);
+
+ expect(result["x-auth-token"]).toBe("[REDACTED]");
+ expect(result["custom-secret-value"]).toBe("[REDACTED]");
+ });
+ });
+
+ describe("headersToRecord", () => {
+ it("should convert Headers to Record", () => {
+ const headers = new Headers();
+ headers.set("content-type", "application/json");
+ headers.set("x-custom", "value");
+
+ const result = headersToRecord(headers);
+
+ expect(result["content-type"]).toBe("application/json");
+ expect(result["x-custom"]).toBe("value");
+ });
+ });
+
+ describe("createExtensionFetchFactory", () => {
+ it("should return undefined when runner is undefined", () => {
+ const result = createExtensionFetchFactory(undefined);
+ expect(result).toBeUndefined();
+ });
+
+ it("should return undefined when no HTTP handlers are registered", () => {
+ const ext: LoadedExtension = {
+ path: "test-extension",
+ resolvedPath: "/test/test-extension.ts",
+ handlers: new Map(), // No handlers
+ tools: new Map(),
+ messageRenderers: new Map(),
+ commands: new Map(),
+ flags: new Map(),
+ flagValues: new Map(),
+ shortcuts: new Map(),
+ setSendMessageHandler: () => {},
+ setSendUserMessageHandler: () => {},
+ setAppendEntryHandler: () => {},
+ setGetActiveToolsHandler: () => {},
+ setGetAllToolsHandler: () => {},
+ setSetActiveToolsHandler: () => {},
+ setFlagValue: () => {},
+ };
+
+ const extensionRunner = new ExtensionRunner(
+ [ext],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+ const result = createExtensionFetchFactory(extensionRunner);
+
+ expect(result).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/coding-agent/test/hooks/http-hooks.test.ts b/packages/coding-agent/test/hooks/http-hooks.test.ts
new file mode 100644
index 000000000..990b92399
--- /dev/null
+++ b/packages/coding-agent/test/hooks/http-hooks.test.ts
@@ -0,0 +1,490 @@
+/**
+ * Tests for HTTP extension events (http_request / http_response).
+ *
+ * These tests verify the extension infrastructure for HTTP events without making real API calls.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ExtensionRunner } from "../../src/core/extensions/runner.js";
+import type {
+ HttpRequestEvent,
+ HttpRequestEventResult,
+ HttpResponseEvent,
+ LoadedExtension,
+} from "../../src/core/extensions/types.js";
+import type { ModelRegistry } from "../../src/core/model-registry.js";
+import type { SessionManager } from "../../src/core/session-manager.js";
+
+// Mock SessionManager (minimal interface for ExtensionRunner)
+function createMockSessionManager(): SessionManager {
+ return {
+ getCwd: () => "/test",
+ getSessionDir: () => "/test/.pi/sessions",
+ getSessionId: () => "test-session",
+ getSessionFile: () => "/test/.pi/sessions/test.jsonl",
+ getLeafUuid: () => "test-uuid",
+ getLeafEntry: () => undefined,
+ getEntry: () => undefined,
+ getLabel: () => undefined,
+ getPath: () => [],
+ getHeader: () => undefined,
+ getEntries: () => [],
+ getTree: () => ({ roots: [], currentPath: [] }),
+ } as unknown as SessionManager;
+}
+
+// Mock ModelRegistry
+function createMockModelRegistry(): ModelRegistry {
+ return {
+ getApiKey: async () => "test-key",
+ find: () => undefined,
+ getAll: () => [],
+ } as unknown as ModelRegistry;
+}
+
+// Create a LoadedExtension with HTTP handlers
+function createExtension(
+ onHttpRequest?: (
+ event: HttpRequestEvent,
+ ) => HttpRequestEventResult | undefined | Promise<HttpRequestEventResult | undefined>,
+ onHttpResponse?: (event: HttpResponseEvent) => undefined | Promise<undefined>,
+): LoadedExtension {
+ const handlers = new Map<string, ((event: unknown, ctx: unknown) => Promise<unknown>)[]>();
+
+ if (onHttpRequest) {
+ handlers.set("http_request", [async (event: unknown) => onHttpRequest(event as HttpRequestEvent)]);
+ }
+
+ if (onHttpResponse) {
+ handlers.set("http_response", [async (event: unknown) => onHttpResponse(event as HttpResponseEvent)]);
+ }
+
+ return {
+ path: "test-extension",
+ resolvedPath: "/test/test-extension.ts",
+ handlers,
+ tools: new Map(),
+ messageRenderers: new Map(),
+ commands: new Map(),
+ flags: new Map(),
+ flagValues: new Map(),
+ shortcuts: new Map(),
+ setSendMessageHandler: () => {},
+ setSendUserMessageHandler: () => {},
+ setAppendEntryHandler: () => {},
+ setGetActiveToolsHandler: () => {},
+ setGetAllToolsHandler: () => {},
+ setSetActiveToolsHandler: () => {},
+ setFlagValue: () => {},
+ };
+}
+
+describe("HTTP extensions", () => {
+ let extensionRunner: ExtensionRunner;
+ let capturedRequestEvents: HttpRequestEvent[];
+ let capturedResponseEvents: HttpResponseEvent[];
+
+ beforeEach(() => {
+ capturedRequestEvents = [];
+ capturedResponseEvents = [];
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("http_request event", () => {
+ it("should emit http_request event with correct fields", async () => {
+ const ext = createExtension((event) => {
+ capturedRequestEvents.push(event);
+ return undefined;
+ });
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: "[REDACTED]",
+ },
+ body: '{"model":"claude-sonnet-4-5"}',
+ });
+
+ expect(capturedRequestEvents).toHaveLength(1);
+ const event = capturedRequestEvents[0];
+ expect(event.type).toBe("http_request");
+ expect(event.provider).toBe("anthropic");
+ expect(event.modelId).toBe("claude-sonnet-4-5");
+ expect(event.url).toBe("https://api.anthropic.com/v1/messages");
+ expect(event.method).toBe("POST");
+ expect(event.headers["content-type"]).toBe("application/json");
+ expect(event.headers.authorization).toBe("[REDACTED]");
+ expect(event.body).toBe('{"model":"claude-sonnet-4-5"}');
+ });
+
+ it("should allow extensions to add headers via result.headers", async () => {
+ const ext = createExtension(() => {
+ return {
+ headers: {
+ "x-custom-header": "custom-value",
+ "x-request-id": "12345",
+ },
+ };
+ });
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "openai",
+ modelId: "gpt-4o",
+ url: "https://api.openai.com/v1/chat/completions",
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ });
+
+ expect(result).toBeDefined();
+ expect(result!.headers).toBeDefined();
+ expect(result!.headers!["x-custom-header"]).toBe("custom-value");
+ expect(result!.headers!["x-request-id"]).toBe("12345");
+ expect(result!.cancel).toBe(false);
+ });
+
+ it("should allow extensions to cancel requests via result.cancel", async () => {
+ const ext = createExtension(() => {
+ return { cancel: true };
+ });
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {},
+ });
+
+ expect(result).toBeDefined();
+ expect(result!.cancel).toBe(true);
+ });
+
+ it("should merge headers from multiple extensions", async () => {
+ const ext1 = createExtension(() => ({
+ headers: { "x-hook1": "value1" },
+ }));
+ const ext2 = createExtension(() => ({
+ headers: { "x-hook2": "value2" },
+ }));
+
+ extensionRunner = new ExtensionRunner(
+ [ext1, ext2],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {},
+ });
+
+ expect(result).toBeDefined();
+ expect(result!.headers!["x-hook1"]).toBe("value1");
+ expect(result!.headers!["x-hook2"]).toBe("value2");
+ });
+
+ it("should return cancel=true if any extension cancels", async () => {
+ const ext1 = createExtension(() => ({
+ headers: { "x-hook1": "value1" },
+ }));
+ const ext2 = createExtension(() => ({
+ cancel: true,
+ }));
+ const ext3 = createExtension(() => ({
+ headers: { "x-hook3": "value3" },
+ }));
+
+ extensionRunner = new ExtensionRunner(
+ [ext1, ext2, ext3],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {},
+ });
+
+ expect(result).toBeDefined();
+ expect(result!.cancel).toBe(true);
+ // Headers from all hooks should still be merged
+ expect(result!.headers!["x-hook1"]).toBe("value1");
+ expect(result!.headers!["x-hook3"]).toBe("value3");
+ });
+
+ it("should return undefined if no extensions are registered", async () => {
+ extensionRunner = new ExtensionRunner([], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {},
+ });
+
+ expect(result).toBeUndefined();
+ });
+
+ it("should handle extension errors gracefully", async () => {
+ const errorExt = createExtension(() => {
+ throw new Error("Extension error");
+ });
+
+ extensionRunner = new ExtensionRunner(
+ [errorExt],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const errors: { extensionPath: string; event: string; error: string }[] = [];
+ extensionRunner.onError((err) => errors.push(err));
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {},
+ });
+
+ expect(result).toBeUndefined();
+ expect(errors).toHaveLength(1);
+ expect(errors[0].extensionPath).toBe("test-extension");
+ expect(errors[0].event).toBe("http_request");
+ expect(errors[0].error).toContain("Extension error");
+ });
+
+ it("should handle async extension handlers", async () => {
+ const ext = createExtension(async (event) => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ capturedRequestEvents.push(event);
+ return { headers: { "x-async": "true" } };
+ });
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ const result = await extensionRunner.emitHttpRequest({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ url: "https://api.anthropic.com/v1/messages",
+ method: "POST",
+ headers: {},
+ });
+
+ expect(capturedRequestEvents).toHaveLength(1);
+ expect(result!.headers!["x-async"]).toBe("true");
+ });
+ });
+
+ describe("http_response event", () => {
+ it("should emit http_response event with correct fields", async () => {
+ const ext = createExtension(undefined, (event) => {
+ capturedResponseEvents.push(event);
+ return undefined;
+ });
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ await extensionRunner.emitHttpResponse({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ "x-request-id": "req-12345",
+ },
+ durationMs: 1500,
+ });
+
+ expect(capturedResponseEvents).toHaveLength(1);
+ const event = capturedResponseEvents[0];
+ expect(event.type).toBe("http_response");
+ expect(event.provider).toBe("anthropic");
+ expect(event.modelId).toBe("claude-sonnet-4-5");
+ expect(event.status).toBe(200);
+ expect(event.headers["content-type"]).toBe("application/json");
+ expect(event.headers["x-request-id"]).toBe("req-12345");
+ expect(event.durationMs).toBe(1500);
+ });
+
+ it("should call multiple response handlers in order", async () => {
+ const callOrder: string[] = [];
+
+ const ext1: LoadedExtension = {
+ path: "ext1",
+ resolvedPath: "/test/ext1.ts",
+ handlers: new Map([
+ [
+ "http_response",
+ [
+ async () => {
+ callOrder.push("hook1");
+ return undefined;
+ },
+ ],
+ ],
+ ]),
+ tools: new Map(),
+ messageRenderers: new Map(),
+ commands: new Map(),
+ flags: new Map(),
+ flagValues: new Map(),
+ shortcuts: new Map(),
+ setSendMessageHandler: () => {},
+ setSendUserMessageHandler: () => {},
+ setAppendEntryHandler: () => {},
+ setGetActiveToolsHandler: () => {},
+ setGetAllToolsHandler: () => {},
+ setSetActiveToolsHandler: () => {},
+ setFlagValue: () => {},
+ };
+
+ const ext2: LoadedExtension = {
+ path: "ext2",
+ resolvedPath: "/test/ext2.ts",
+ handlers: new Map([
+ [
+ "http_response",
+ [
+ async () => {
+ callOrder.push("hook2");
+ return undefined;
+ },
+ ],
+ ],
+ ]),
+ tools: new Map(),
+ messageRenderers: new Map(),
+ commands: new Map(),
+ flags: new Map(),
+ flagValues: new Map(),
+ shortcuts: new Map(),
+ setSendMessageHandler: () => {},
+ setSendUserMessageHandler: () => {},
+ setAppendEntryHandler: () => {},
+ setGetActiveToolsHandler: () => {},
+ setGetAllToolsHandler: () => {},
+ setSetActiveToolsHandler: () => {},
+ setFlagValue: () => {},
+ };
+
+ extensionRunner = new ExtensionRunner(
+ [ext1, ext2],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ await extensionRunner.emitHttpResponse({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ status: 200,
+ headers: {},
+ durationMs: 100,
+ });
+
+ expect(callOrder).toEqual(["hook1", "hook2"]);
+ });
+
+ it("should handle response extension errors gracefully", async () => {
+ const errorExt = createExtension(undefined, () => {
+ throw new Error("Response extension error");
+ });
+
+ extensionRunner = new ExtensionRunner(
+ [errorExt],
+ "/test",
+ createMockSessionManager(),
+ createMockModelRegistry(),
+ );
+
+ const errors: { extensionPath: string; event: string; error: string }[] = [];
+ extensionRunner.onError((err) => errors.push(err));
+
+ // Should not throw
+ await extensionRunner.emitHttpResponse({
+ provider: "anthropic",
+ modelId: "claude-sonnet-4-5",
+ status: 200,
+ headers: {},
+ durationMs: 100,
+ });
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].extensionPath).toBe("test-extension");
+ expect(errors[0].event).toBe("http_response");
+ expect(errors[0].error).toContain("Response extension error");
+ });
+
+ it("should capture non-200 status codes", async () => {
+ const ext = createExtension(undefined, (event) => {
+ capturedResponseEvents.push(event);
+ return undefined;
+ });
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ await extensionRunner.emitHttpResponse({
+ provider: "openai",
+ modelId: "gpt-4o",
+ status: 429,
+ headers: { "retry-after": "60" },
+ durationMs: 50,
+ });
+
+ expect(capturedResponseEvents).toHaveLength(1);
+ expect(capturedResponseEvents[0].status).toBe(429);
+ expect(capturedResponseEvents[0].headers["retry-after"]).toBe("60");
+ });
+ });
+
+ describe("hasHandlers", () => {
+ it("should return true when http_request handlers exist", () => {
+ const ext = createExtension(() => undefined);
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ expect(extensionRunner.hasHandlers("http_request")).toBe(true);
+ expect(extensionRunner.hasHandlers("http_response")).toBe(false);
+ });
+
+ it("should return true when http_response handlers exist", () => {
+ const ext = createExtension(undefined, () => undefined);
+
+ extensionRunner = new ExtensionRunner([ext], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ expect(extensionRunner.hasHandlers("http_request")).toBe(false);
+ expect(extensionRunner.hasHandlers("http_response")).toBe(true);
+ });
+
+ it("should return false when no HTTP handlers exist", () => {
+ extensionRunner = new ExtensionRunner([], "/test", createMockSessionManager(), createMockModelRegistry());
+
+ expect(extensionRunner.hasHandlers("http_request")).toBe(false);
+ expect(extensionRunner.hasHandlers("http_response")).toBe(false);
+ });
+ });
+});
[Showing lines 362-2008 of 2008 (50.0KB limit). Full output: /var/folders/49/l4171l3n219_xwq30fmyzr8w0000gn/T/pi-bash-a4a5107026487cbb.log]Now let me check the linked issues and read relevant source files:
>gh issue view 862 --repo badlogic/pi-mono --json title,body,comments,labels,state 2>/dev/null{"body":"### What do you want to change?\n\nAdd a `Ctrl+N` toggle to `/resume` that filters sessions by named-only vs all. The header would show something like `Name: All` or `Name: Named`.\n\n### Why?\n\nI often use `/name` to mark the sessions I'm certain are worth keeping and that I'm likely to need to revisit later. When there are less important sessions cluttering results, finding the named ones in `/resume` requires extra searching guesswork and scrolling. A quick toggle would save time.\n\n### How? (optional)\n\nI have a working prototype (screenshots below). Changes are in `session-selector-search.ts` (filtering logic) and `session-selector.ts` (state, toggle, header, keybinding).\n\nTwo questions:\n1. Is `Ctrl+N` acceptable, or different binding?\n2. Should cycle include \"Unnamed\" (`All → Named → Unnamed`), or just `All ↔ Named`? I lean simpler.\n\n<p align=\"center\">\n<img width=\"360\" alt=\"all\" src=\"https://github.com/user-attachments/assets/ea7a89e3-7e91-4fc9-b486-fdbdc97edfe8\" />\n</p>\n<p align=\"center\">\n<img width=\"360\" alt=\"named\" src=\"https://github.com/user-attachments/assets/810c322d-c2aa-479d-888a-390cd732d96a\" />\n</p>","comments":[{"id":"IC_kwDOPbFNk87gvInb","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"lgtm please send a PR!","createdAt":"2026-01-19T23:40:17Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/issues/862#issuecomment-3770452443","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87gvIro","author":{"login":"github-actions"},"authorAssociation":"CONTRIBUTOR","body":"@w-winter is already in the approved contributors list.","createdAt":"2026-01-19T23:40:26Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/issues/862#issuecomment-3770452712","viewerDidAuthor":false}],"labels":[],"state":"CLOSED","title":"`/resume`: add named filter toggle (Named vs. All)"}Now let me read more of the PR #903 about bash interception:
Now let me check what issue #316 is (referenced in PR #345):
>gh issue view 316 --repo badlogic/pi-mono --json title,body,comments,labels,state 2>/dev/null{"body":"Switch session storage from linear JSONL to tree structure.\n\nEach entry gets `uuid` and `parentUuid` fields. Session header includes `version` for migrations. Last entry is current leaf. Context = walk from leaf to root via parentUuid.\n\n```jsonl\n{\"type\":\"session\",\"version\":2,\"uuid\":\"ses1\",\"parentUuid\":null,\"id\":\"...\",\"cwd\":\"...\"}\n{\"type\":\"message\",\"uuid\":\"m1\",\"parentUuid\":\"ses1\",\"message\":{\"role\":\"user\",...}}\n{\"type\":\"message\",\"uuid\":\"m2\",\"parentUuid\":\"m1\",\"message\":{\"role\":\"assistant\",...}}\n{\"type\":\"branch_summary\",\"uuid\":\"bs1\",\"parentUuid\":\"m1\",\"summary\":\"Abandoned path summary\"}\n{\"type\":\"message\",\"uuid\":\"m3\",\"parentUuid\":\"bs1\",\"message\":{\"role\":\"user\",...}}\n```\n\n**Versions:**\n- v1 (implicit): Linear format, no uuid/parentUuid\n- v2: Tree format with uuid/parentUuid\n\n**Using UUIDs instead of indices:**\n- No remapping needed when branching to new file\n- Robust to entry deletion/reordering\n\n**Tree is for branching. Compaction is just a marker in the linear chain.**\n- `firstKeptEntryIndex` → `firstKeptEntryUuid`\n\n**Migration:** On load, detect version, migrate in memory, rewrite file. Transparent, one-time per session.\n\n**New capabilities:**\n- `/branch-here` - branch in current file with optional summary\n- `/branches` - list/switch between branches\n- Built-in stacking without hooks\n\nFull analysis: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session-tree.md","comments":[{"id":"IC_kwDOPbFNk87cDNYF","author":{"login":"nicobailon"},"authorAssociation":"CONTRIBUTOR","body":"This is solid. #293 will need updates once this lands. I'll hold off on that PR and update it after this ships.","createdAt":"2025-12-25T23:55:02Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/issues/316#issuecomment-3691828741","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87cDhaN","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Branch: [`session-tree`](https://github.com/badlogic/pi-mono/tree/session-tree)\n\n**Done:**\n- Session v2 format with `id`/`parentId` tree structure\n- Auto-migration from v1 on load\n- SessionManager API: `appendXXX()` methods, `getTree()`, `getPath()`, `branch()`, `branchWithSummary()`, `createBranchedSession()`\n- Compaction works with entry IDs instead of indices\n- 58 tests covering migration, tree traversal, context building, file operations\n\n**Remaining (see `docs/session-tree-plan.md`):**\n- Compaction hook types cleanup\n- Branch summary design (`abandonedLeafId`, summarizer)\n- Entry labels\n- HTML export with tree sidebar\n- UI commands (`/branch-here`, `/branches`)","createdAt":"2025-12-26T01:50:21Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[{"content":"HOORAY","users":{"totalCount":2}},{"content":"EYES","users":{"totalCount":1}}],"url":"https://github.com/badlogic/pi-mono/issues/316#issuecomment-3691910797","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87cDs6Q","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"@nicobailon aye, sorry for the extra work. But I think it's worth it.\n\nAlso haven't forgotten about mom. Just want to get core into a stable shape. Almost there. I think :)","createdAt":"2025-12-26T02:39:42Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[{"content":"HEART","users":{"totalCount":1}}],"url":"https://github.com/badlogic/pi-mono/issues/316#issuecomment-3691957904","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87cqDDP","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"See [packages/coding-agent/docs/session.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/session.md)","createdAt":"2025-12-31T11:19:47Z","includesCreatedEdit":true,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/issues/316#issuecomment-3702010063","viewerDidAuthor":true}],"labels":[{"id":"LA_kwDOPbFNk88AAAACQ8TbnQ","name":"pkg:coding-agent","description":"Coding agent CLI","color":"0e8a16"}],"state":"CLOSED","title":"Session tree format"}Now let me read the README to check if documentation updates are needed:
>rg -n "before_bash" packages/coding-agent/README.md packages/coding-agent/docs/*.md 2>/dev/null | head -30(no output)>rg -n "http_request|http_response" packages/coding-agent/README.md packages/coding-agent/docs/*.md 2>/dev/null | head -30(no output)Now let me review all PRs completely. First, let me verify the status of various changelogs:
>rg -n "940|908|903|899|892|868|863|345" packages/*/CHANGELOG.md 2>/dev/null | head -50(no output)>rg -n "qwen|tmux|intercept.*bash|zai-coding-plan|template.*variable|named.*filter|rename.*session|http.*hook" packages/*/CHANGELOG.md 2>/dev/nullpackages/tui/CHANGELOG.md:70:- Expanded keymap coverage for terminal compatibility: added support for Home/End keys in tmux, additional modifier combinations, and improved key sequence parsing ([#752](https://github.com/badlogic/pi-mono/pull/752) by [@richardgill](https://github.com/richardgill))
packages/coding-agent/CHANGELOG.md:1364:- **Ghostty inline images in tmux**: Fixed terminal detection for Ghostty when running inside tmux by checking `GHOSTTY_RESOURCES_DIR` env var. ([#299](https://github.com/badlogic/pi-mono/pull/299) by [@nicobailon](https://github.com/nicobailon))Now let me compile the full review for all PRs:
Title: feat(coding-agent): add Qwen CLI OAuth provider Author: @4h9fbZ
Changelog:
packages/coding-agent/CHANGELOG.md or packages/ai/CHANGELOG.md. If merged, a changelog entry is required. The entry should go under ### Added for the Qwen CLI extension example and the thinkingFormat: "qwen" addition.Good:
modifyModels hook for dynamic baseUrl based on OAuth credentialsthinkingFormat: "qwen" to core types for enable_thinking: boolean parameter supportBad:
oauth.modifyModels support to ModelRegistry.registerCustomProvider() (line 533-538 in model-registry.ts), but this code path is only called during initial provider registration. If credentials change later (e.g., after re-login), models won't be updated with the new baseUrl.thinkingFormat: "qwen" code path in openai-completions.tsvision-model model has supportsDeveloperRole: false but no explanation why this is neededUgly:
Questions or Assumptions:
vision-model the correct model ID for Qwen3 VL Plus? The naming is inconsistent with other Qwen models (e.g., qwen3-coder-plus vs vision-model)modifyModels hook be documented in docs/custom-provider.md as the PR description mentions?Change summary:
thinkingFormat: "qwen" to OpenAICompletionsCompat for enable_thinking parametermodifyModels in OAuth providers to update model baseUrl from credentialsTests:
Title: feat(tui): add tmux support for kitty graphics protocol Author: @ogulcancelik
Changelog:
packages/tui/CHANGELOG.md under ### Added with proper attribution.Good:
allow-passthrough settingisInsideTmux, isTmuxPassthroughEnabled, resetTmuxPassthroughCache, generatePlaceholderRows, getNextImageIdBad:
isTmuxPassthroughEnabled() uses synchronous execSync which could block the event loop on slow systemsKITTY_DIACRITICS array is 297 entries but the constant MAX_DIACRITIC_VALUE is KITTY_DIACRITICS.length - 1 (296), and images are clamped to this. Large images would be silently truncated to 297x297 cells without any warning.Ugly:
imageId option and deleteKittyImage/deleteAllKittyImages functions from the public API. This is a breaking change that should be noted.Questions or Assumptions:
set -g allow-passthrough all)?Change summary:
Tests:
Title: Add support for intercepting bash Author: @mitsuhiko
Changelog:
packages/coding-agent/CHANGELOG.md. Entry should be in ### Added documenting the before_bash_exec extension event.Good:
before_bash_exec event for intercepting both tool and user bash executionBashExecEvent, BashExecOverrides, BeforeBashExecEvent)uv.ts example extension demonstrating practical use case (redirecting Python to uv)extensions.md documentation with the new eventexecutedCommand in session for transparency when command was rewrittenundefined valuesBad:
tool_result hook returning isError: true on a successful tool to "force" it to be treated as an error is a bit confusing API-wise. The PR description notes this feels "somewhat consistent but ugly."tools.test.ts changes from mocking getShellConfig to resolveShellExecutionOptions, which is fine but shows tight coupling to implementation details.Ugly:
Questions or Assumptions:
before_bash_exec in the extensions overview section?Change summary:
before_bash_exec event fired before bash execution (tool calls and user !/!!)uv.ts example extensionTests:
Title: Add zai-coding-plan provider and make zai point to regular API Author: @kaofelix
Changelog:
packages/ai/CHANGELOG.md under ### Breaking Changes but packages/coding-agent/CHANGELOG.md has a ### Changed entry in the wrong section (inside [0.49.2] which is already released). The coding-agent entry should be under ## [Unreleased] ### Breaking Changes.Good:
zai to zai-coding-plan for clarity about which endpoint is usedBad:
packages/coding-agent/CHANGELOG.md entry is in the wrong section (inside [0.49.2] which is already released)packages/ai/CHANGELOG.md correctly has it under [Unreleased] but packages/coding-agent/CHANGELOG.md doesn't have a breaking changes entry in unreleasedUgly:
Questions or Assumptions:
Change summary:
zai provider to zai-coding-planzai-coding-plan provider nameTests:
Title: feat(coding-agent): add template variables to custom system prompts Author: @ogulcancelik
Changelog:
packages/coding-agent/CHANGELOG.md under ### Added, though it could be more descriptive about the behavioral change (no template vars = full replacement mode).Good:
{{tools}}, {{context}}, {{skills}} template variables for SYSTEM.mdSystemPromptTemplates interfacegetSystemPromptTemplates() methodBad:
ResourceLoader interface now requires implementing getSystemPromptTemplates(), which could break custom implementationsUgly:
Questions or Assumptions:
Change summary:
{{tools}}, {{context}}, {{skills}}) for custom SYSTEM.mdTests:
Title: feat(coding-agent): add named-only filter toggle to /resume picker Author: @w-winter Linked Issue: #862 (closed)
Changelog:
packages/coding-agent/CHANGELOG.md under ### Breaking Changes, but this is NOT a breaking change. It's a new feature (added keybinding). Should be under ### Added.Good:
Ctrl+N toggle to filter sessions by named-only vs all in /resumetoggleSessionNamedFilter in keybindings.jsonBad:
### Breaking Changes but this is a feature addition, not a breaking changeUgly:
Questions or Assumptions:
Change summary:
Ctrl+N toggle in /resume picker to filter named sessions onlytoggleSessionNamedFilter to configurable keybindingsTests:
Title: Rename session from /resume session list Author: @svkozak
Changelog:
### Added in packages/coding-agent/CHANGELOG.md.Good:
Ctrl+R to rename sessions directly from /resume picker--resumeBad:
Ugly:
Ctrl+R to Ctrl+N to make room for rename. This conflicts with PR #868 which uses Ctrl+N for name filter toggle. These PRs need coordination.Questions or Assumptions:
Ctrl+N. Which one gets precedence? They may need to coordinate keybindings.Change summary:
Ctrl+R rename mode in /resume pickerTests:
Title: Add http_request and http_response hooks for LLM provider HTTP interception Author: @kim0
Changelog:
packages/coding-agent/CHANGELOG.md under ### Added with comprehensive description.Good:
http_request and http_response extension eventscreateFetch factory passed to agentBad:
packages/agent/ changes add createFetch to AgentOptions and AgentLoopConfig, but there's no changelog entry for packages/agent/CHANGELOG.mdUgly:
Questions or Assumptions:
Change summary:
http_request event fired before HTTP requests to LLM providershttp_response event fired after responsesTests:
For PRs that modify features, the following documentation files may need updates:
| PR | README.md | docs/*.md | examples/*.md |
|---|---|---|---|
| #940 | May need Qwen provider docs | docs/custom-provider.md should mention modifyModels | Extension example added |
| #908 | Should document tmux requirement | - | - |
| #903 | Should mention before_bash_exec | docs/extensions.md updated ✓ | uv.ts example added ✓ |
| #899 | Updated ✓ | - | - |
| #892 | Should document template variables | - | - |
| #868 | Updated ✓ | - | - |
| #863 | Updated ✓ | - | - |
| #345 | - | Should document http events | - |