@yanzc/claude-pro 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,7 @@ claude-pro config set CLAUDE_PRO_PROVIDER claude-code
64
64
 
65
65
  调用方式是:本项目 → `https://api.anthropic.com/v1/messages?beta=true`。请求复用 `Authorization: Bearer <accessToken>`,带上 Claude Code 的 OAuth beta、`x-app: cli`、User-Agent、系统标识,以及来自 `.claude.json` 的设备和账号 metadata。流式响应、原生 `tool_use` / `tool_result`、工具执行、hooks、检查点、计划模式及会话功能都由本项目原有逻辑处理。
66
66
 
67
- 此模式默认 `sonnet` → `claude-sonnet-5-5`,`opus` → `claude-opus-5-5`,`haiku` → `claude-haiku-4-5-20251001`;子 agent 默认用 `sonnet`(`CLAUDE_PRO_CLAUDE_CODE_SUBAGENT_MODEL` 可改)。使用 `--model`、`/model` 或 `CLAUDE_PRO_CLAUDE_CODE_MODEL` 指定模型。**`/model`、`/effort` 的选择会一直保留**:按后端记在 `~/.claude-pro/settings.json` 的 `remembered` 里,之后每次启动(交互式和 `-p`)都沿用,优先于 `.env`/settings/环境变量里的配置;命令行 `--model`/`--effort` 只覆盖当次启动;`/model default`、`/effort default` 删掉记住的选择,回到配置的值。`claude-pro config list` 会显示当前记住的选择。已有的 DeepSeek API URL、API key 和 API 模型别名不会参与订阅请求。OAuth 令牌始终只发往 Anthropic,原 API 配置可以通过 `--provider api` 继续使用。
67
+ 此模式默认 `sonnet` → `claude-sonnet-5-5`,`opus` → `claude-opus-5-5`,`haiku` → `claude-haiku-5-5`(与 Claude Code 2.1.293 起一样;话题标题、WebFetch 摘要等小请求用它);子 agent 默认用 `sonnet`(`CLAUDE_PRO_CLAUDE_CODE_SUBAGENT_MODEL` 可改)。使用 `--model`、`/model` 或 `CLAUDE_PRO_CLAUDE_CODE_MODEL` 指定模型。**`/model`、`/effort` 的选择会一直保留**:按后端记在 `~/.claude-pro/settings.json` 的 `remembered` 里,之后每次启动(交互式和 `-p`)都沿用,优先于 `.env`/settings/环境变量里的配置;命令行 `--model`/`--effort` 只覆盖当次启动;`/model default`、`/effort default` 删掉记住的选择,回到配置的值。`claude-pro config list` 会显示当前记住的选择。已有的 DeepSeek API URL、API key 和 API 模型别名不会参与订阅请求。OAuth 令牌始终只发往 Anthropic,原 API 配置可以通过 `--provider api` 继续使用。
68
68
 
69
69
  每次请求重新读取现有凭据;如果本机 Claude Code 更新了令牌,下次请求自动使用新值。
70
70
 
@@ -116,13 +116,13 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
116
116
  每次请求都会把整个对话重新发给模型,账单的大头是"重复读历史"。本项目按 [Claude Code 的做法](https://code.claude.com/docs/en/prompt-caching)使用 Anthropic 的提示词缓存:
117
117
 
118
118
  - **断点**:在工具列表末尾、系统提示词、最新一条消息的末尾各放一个 `cache_control`(共 3 个,上限 4 个)。系统提示词在一个会话里只构建一次,前缀稳定才能命中。
119
- - **开箱即用,不需要配置**:默认值和 Claude Code 一致。订阅登录(`--provider claude-code`)下**主对话用 1 小时**缓存(Claude Code 在订阅额度内就是这样请求的),API key / 其他端点用 5 分钟(API 默认值,不发送 `ttl` 字段);子 agent、压缩等其他请求永远默认 5 分钟。effort 不设就用模型自己的默认值(Sonnet 5.5、Opus 5.5 是 medium),自动压缩窗口不设就是模型的整个窗口。
119
+ - **开箱即用,不需要配置**:默认值和 Claude Code 一致。订阅登录(`--provider claude-code`)和网关(`gateway`)下**主对话用 1 小时**缓存(Claude Code 在订阅额度内就是这样请求的;网关按 2 倍价计费 1 小时写入,但按网关的用量记录算,533 次 Opus 请求里停顿 5–60 分钟让整段对话重写了 9 次、多花 $14.77,全部改用 1 小时只多花 $2.80),API key / 其他端点用 5 分钟(API 默认值,不发送 `ttl` 字段);子 agent、压缩等其他请求永远默认 5 分钟。effort 不设就用模型自己的默认值(Sonnet 5.5、Opus 5.5 是 medium),自动压缩窗口不设就是模型的整个窗口。
120
120
  - **`.env` 只属于 API 提供方**:`--provider claude-code` 时,`.env` 里的 `CLAUDE_CODE_EFFORT_LEVEL`、`CLAUDE_CODE_AUTO_COMPACT_WINDOW`、`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`、`CLAUDE_CODE_MAX_OUTPUT_TOKENS`、`MAX_THINKING_TOKENS` 不会被读取(它们是为 DeepSeek 之类的端点调的),订阅模式用上面的默认值;`/config` 会列出被忽略的键。想在订阅模式下调,用 `/config set`、`/effort`、设置文件、环境变量或 `--effort`,这些照常生效。
121
121
  - **TTL 分两个桶**(可以改),每次命中会重新计时:
122
122
  - **主对话**(你的每一轮):`CLAUDE_CODE_PROMPT_CACHE_TTL=5m|1h`,或设置项 `promptCacheTtl`
123
123
  - **其他请求**(子 agent、压缩、小模型的辅助请求):`CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=5m|1h`,或设置项 `subagentPromptCacheTtl`
124
124
  - 变量优先于设置项(无论来自哪个文件);无效的值被忽略,按下一条规则算。`ENABLE_PROMPT_CACHING_1H=1` 让两个桶都用 1 小时(上面两个变量和设置项优先);`FORCE_PROMPT_CACHING_5M=1` 强制两个桶都用 5 分钟,优先级最高;其他取值被忽略。
125
- - 1 小时档的缓存写入按 2 倍价格计费(5 分钟档是 1.25 倍),而且每一轮新增的内容都按这个价写:用 API key 按 token 付费时,只有上下文很大、而且常常停顿超过 5 分钟才划算,所以那里默认是 5 分钟。订阅额度内 Claude Code 默认用 1 小时;想在订阅模式下用 5 分钟,设 `FORCE_PROMPT_CACHING_5M=1`。
125
+ - 1 小时档的缓存写入按 2 倍价格计费(5 分钟档是 1.25 倍),而且每一轮新增的内容都按这个价写:用 API key 按 token 付费时,只有上下文很大、而且常常停顿超过 5 分钟才划算,所以那里默认是 5 分钟。订阅额度内 Claude Code 默认用 1 小时;想在订阅模式或网关下用 5 分钟,设 `FORCE_PROMPT_CACHING_5M=1`。
126
126
  - **关闭缓存**:`DISABLE_PROMPT_CACHING=1` 完全不发 `cache_control`;`DISABLE_PROMPT_CACHING_HAIKU` / `_SONNET` / `_OPUS` 只关 haiku/sonnet/opus 别名所指的模型,`_FABLE` 只关 Fable 模型。
127
127
  - **端点不认**:网关对 `cache_control`(或 `ttl`)返回 400 时,不会让请求失败:在被点名的位置去掉标记(报 `ttl` 则退回 5 分钟)后重发;没点名位置的报错一次去掉一处(先消息、再工具、再系统提示词),尽量多保留缓存。请求成功之后才记住这个端点拒绝了什么(之后不再发送),`/status` 会显示;报错本身只是提到 `cache_control` 而去掉标记也没用时,什么都不会记住。
128
128
  - **一次性的请求不加缓存标记**:WebFetch 摘要这类整页内容只发一次的小模型请求,缓存写入只会多收钱,所以不标记。
@@ -151,6 +151,7 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
151
151
  - **压缩对话时**撞上限流同样会暂停,界面显示同样的倒计时(不会一直停在"Compacting conversation"上让人以为卡死了)。
152
152
  - **子 agent 和后台 agent** 同样会等待,暂停与恢复都以 `[agent 名]` 为前缀提示一次(倒计时那一行属于主 agent,因为后台 agent 暂停时主 agent 可能还在干活)。轮询(服务器没给重置时间)每隔几分钟重试一次,但只提示一次,不会刷屏。
153
153
  - `/doctor` 这类诊断请求不等,直接报告限流。
154
+ - **API 过载(529 / `overloaded_error`)** 也不会让任务停下:先按 `CLAUDE_CODE_OVERLOADED_RETRY_BASE_DELAY_MS`(默认 1 秒)起、每次翻倍、最长 `CLAUDE_CODE_OVERLOADED_RETRY_MAX_DELAY_MS`(默认 32 秒)快速重试 `CLAUDE_PRO_MAX_RETRIES` 次;用完后转为暂停(界面显示 `API overloaded · checking again in …`,Esc 取消),每隔最长间隔再试一次,直到 API 恢复(提示 `The API is answering again; continuing.`),最多等 `CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS`(默认同 `CLAUDE_PRO_RATE_LIMIT_MAX_WAIT_MS`,6 小时;`0` = 重试用完就报错)。
154
155
 
155
156
  ## 终端标题、页脚与窗口缩放
156
157
 
@@ -186,7 +187,7 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
186
187
  | Bash 默认 / 最长超时 | 2 分钟 / 10 分钟 | 10 分钟 / 1 小时 |
187
188
  | Bash 输出返回给模型的上限 | 30000 字符 | 60000 字符,超出部分的全文保存到文件 |
188
189
  | 单个工具结果上限(超出落盘) | 50000 字符 | 100000 字符 |
189
- | `Read` 一次最多 | 2000 行、每行 2000 字符、总共 12 万字符 | 10000 行、每行 20000 字符、总共 40 万字符 |
190
+ | `Read` 一次最多 | 2000 行、每行 2000 字符、总共 12 万字符 | 10000 行、每行 20000 字符、总共 40 万字符;加 `allow_large: true`(与 Claude Code 2.1.296 相同)不限行数和长度,只要上下文在自动压缩之前还放得下(同一批并行读取共用这份余量),放不下就拒绝并写明差多少 |
190
191
  | `Glob` / `Grep` 默认返回条数 | 100 / 250 | 1000 / 1000 |
191
192
  | `WebFetch` | 只返回小模型的摘要(最多 4096 token),自动把 http 升成 https | `raw: true` 直接拿转换后的全文;摘要上限 16000 token、页面上限 40 万字符;按原样请求 URL,localhost 和内网也可以 |
192
193
  | 子 agent | 不能再派子 agent | 嵌套到 2 层(`CLAUDE_PRO_MAX_SUBAGENT_DEPTH`,0 = 不嵌套) |
@@ -197,12 +198,13 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
197
198
 
198
199
  ## 功能
199
200
 
200
- **工具**(与 Claude Code 同名同语义):`Bash`(持久 cwd、超时、后台运行、可喂 stdin)、`BashOutput`、`KillShell`、`Read`(行号、图片、ipynb)、`Write`、`Edit`(必须先读后改、检测外部修改、保留 CRLF/BOM)、`NotebookEdit`、`Glob`、`Grep`(有 ripgrep 用 rg,否则用内置 JS 搜索:异步不卡界面,遵循 `.gitignore`/`.ignore`、`!` 排除 glob、`^`/`$` 行锚点、多行匹配、上下文行;找不到 rg 时可用 `CLAUDE_PRO_RIPGREP_PATH` 指定;与 rg 不同的是它始终跳过 `node_modules` 等目录,除非 glob 里点名)、`WebFetch`、`WebSearch`(DuckDuckGo/Bing)、`TodoWrite`、`Task`(子 agent)、`TaskOutput`、`TaskStop`、`Skill`、`ExitPlanMode`、`AskUserQuestion`,以及 MCP 工具。
201
+ **工具**(与 Claude Code 同名同语义):`Bash`(持久 cwd、超时、后台运行、可喂 stdin)、`BashOutput`、`KillShell`、`Read`(行号、图片、ipynb)、`Write`、`Edit`(必须先读后改、检测外部修改、保留 CRLF/BOM)、`NotebookEdit`、`Glob`、`Grep`(有 ripgrep 用 rg,否则用内置 JS 搜索:异步不卡界面,遵循 `.gitignore`/`.ignore`、`!` 排除 glob、`^`/`$` 行锚点、多行匹配、上下文行;找不到 rg 时可用 `CLAUDE_PRO_RIPGREP_PATH` 指定;与 rg 不同的是它始终跳过 `node_modules` 等目录,除非 glob 里点名;模型误传的 `file_path` 当作 `path`,grep 的 `-l`/`-c` 当作输出模式)、`WebFetch`、`WebSearch`(DuckDuckGo/Bing)、`TodoWrite`、`Task`(子 agent)、`TaskOutput`、`TaskStop`、`Skill`、`ExitPlanMode`、`AskUserQuestion`,以及 MCP 工具。
201
202
 
202
203
  **交互**
203
204
  - 流式 Markdown 渲染(代码高亮、表格、中文宽度对齐),思考过程折叠显示(`ctrl+o` 展开)
204
205
  - `Esc` 中断;忙碌时输入的消息会排队并插入当前任务
205
206
  - `!` Bash 模式、`#` 写入记忆(CLAUDE.md)、`\`+回车 或 `ctrl+j` 换行、粘贴大段文本折叠为 `[Pasted text #1 +42 lines]`
207
+ - `ctrl+c` 或连按两次 `Esc` 清掉的草稿不会丢:在空输入框按 `↑` 找回(连同粘贴的文本和图片),中间发过别的消息也可以
206
208
  - `@文件` 补全并附加内容:走的就是 `Read` 工具,等于模型自己读了这个文件。最多带前 2000 行(被截断时会明确告诉模型,并把该文件记为"只读了一部分",之后 `Write` 不会去覆盖没给它看过的行);图片作为图片附加;二进制文件和超过 512KB 的文本会被跳过并提示原因;`@目录` 列出目录内容
207
209
  - `alt+v` 粘贴剪贴板里的图片(截图用 `Win+Shift+S`),输入框里显示为 `[Image #1]`;超过 1568px 的图会自动缩小。把图片文件拖进终端(或粘贴图片路径)也会作为图片附加
208
210
  - 上下文用量接近阈值时自动压缩(`CLAUDE_CODE_AUTO_COMPACT_WINDOW`),也可手动 `/compact`
@@ -210,13 +212,13 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
210
212
 
211
213
  **斜杠命令**:`/help` `/clear` `/compact` `/config` `/model` `/effort` `/status` `/cost` `/context` `/init` `/review` `/memory` `/verify` `/tasks` `/plan` `/rewind` `/skills` `/resume` `/todos` `/agents` `/mcp` `/bashes` `/verbose` `/export` `/doctor` `/exit`,以及 `.claude/commands/*.md`、`.claude-pro/commands/*.md`、`~/.claude-pro/commands/*.md` 中的自定义命令(支持 `$ARGUMENTS`、`$1`、`` !`cmd` ``)。
212
214
 
213
- **记忆**:自动读取 `~/.claude-pro/CLAUDE.md`、项目各级目录的 `CLAUDE.md` / `.claude/CLAUDE.md` / `AGENTS.md` / `CLAUDE.local.md`。
215
+ **记忆**:自动读取 `~/.claude-pro/CLAUDE.md`、项目各级目录的 `CLAUDE.md` / `.claude/CLAUDE.md` / `CLAUDE.local.md`;某一级目录没有 `CLAUDE.md` 时读它的 `AGENTS.md` 代替(与 Claude Code 相同,两者都有时通常是同一份说明,不重复发送)。
214
216
 
215
217
  **自动记忆(跨会话)**:每个项目有一个由 agent 自己维护的记忆目录 `~/.claude-pro/projects/<项目>/memory/`。它会把你的偏好、纠正过的做法、项目约束等写成一条条小文件,并在 `MEMORY.md` 里建索引;每次新会话开始时索引自动载入。直接说"记住……"或"忘掉……"即可;`/memory` 查看;`CLAUDE_PRO_DISABLE_AUTO_MEMORY=1` 关闭。
216
218
 
217
219
  **Skills**:`<目录>/skills/<名字>/SKILL.md`,frontmatter 写 `name` 和 `description`,正文是指令,格式与 Claude Code 的 skills 相同。搜索 `~/.claude-pro/skills/`、`<项目>/.claude/skills/`、`<项目>/.claude-pro/skills/`(项目级覆盖用户级)。模型发现任务匹配某个 skill 的描述时会通过 `Skill` 工具自动加载它;你也可以直接用 `/<skill 名> [参数]` 调用,`/skills` 列出全部。
218
220
 
219
- **子 agent**:内置 `general-purpose`、`Explore`、`Plan`;自定义 agent 放在 `.claude/agents/*.md` 或 `~/.claude-pro/agents/*.md`(frontmatter:`name`、`description`、`tools`、`model`)。
221
+ **子 agent**:内置 `general-purpose`、`Explore`、`Plan`;自定义 agent 放在 `.claude/agents/*.md` 或 `~/.claude-pro/agents/*.md`(frontmatter:`name`、`description`、`tools`、`model`、`effort`(low/medium/high/xhigh/max)、`autoCompactWindow`(这个 agent 自己的自动压缩窗口,token 数))。模型调用 `Task` 时也可以给单个子 agent 指定 `model`(sonnet/opus/haiku 或完整 id)和 `effort`,优先于 agent 定义里的,定义里的又优先于会话的。
220
222
 
221
223
  **后台子 agent**:模型可以用 `Task` 的 `run_in_background: true` 把子 agent 放到后台,自己继续干别的。子 agent 完成后,结果会作为 `<task-notification>` 自动送回;如果当时主 agent 空闲,会自动开启新的一轮来处理结果。`TaskOutput` 用于提前等待结果,`TaskStop` 用于取消;`/tasks` 列出所有后台 agent,`/tasks kill agent_1` 停止其中一个。`-p` 模式会等所有后台 agent 结束后再退出。
222
224
 
@@ -224,7 +226,7 @@ claude-pro config set ANTHROPIC_MODEL deepseek-pro --project
224
226
 
225
227
  **大输出落盘**:工具结果超过 `CLAUDE_PRO_MAX_TOOL_RESULT_CHARS`(默认 50000 字符)时,全文会保存到 `~/.claude-pro/projects/<项目>/tool-results/`,只把预览和文件路径发给模型,模型可以再用 `Read`/`Grep` 按需查看。Bash 输出被截断时同样会保存全文。超过 7 天的旧文件在启动时自动清理。
226
228
 
227
- **Hooks**:`settings.json` 里的 `hooks` 与 Claude Code 同形:`PreToolUse`、`PostToolUse`、`UserPromptSubmit`、`Stop`。事件内容以 JSON 从 **stdin** 传给脚本(同时放在环境变量 `CLAUDE_PRO_HOOK_INPUT`,另有 `CLAUDE_PROJECT_DIR`);退出码 0 = 通过(stdout 作为附加上下文),2 = 拦截(stderr 反馈给模型;`Stop` 则让模型继续干活),其他 = 仅警告。
229
+ **Hooks**:`settings.json` 里的 `hooks` 与 Claude Code 同形:`PreToolUse`、`PostToolUse`、`UserPromptSubmit`、`Stop`。事件内容以 JSON 从 **stdin** 传给脚本(同时放在环境变量 `CLAUDE_PRO_HOOK_INPUT`,另有 `CLAUDE_PROJECT_DIR`);退出码 0 = 通过(stdout 作为附加上下文),2 = 拦截(stderr 反馈给模型;`Stop` 则让模型继续干活),其他 = 仅警告。当护栏用的 hook 加上 `"onFailure": "block"`:启动不了、超时或以其他退出码结束时按拦截处理,而不是放行(与 Claude Code 相同)。hook 输出里的 `<system-reminder>` 标签会被转义,冒充不了系统提示。
228
230
 
229
231
  ```json
230
232
  { "hooks": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "claude-pro: a self-implemented coding agent for Anthropic-compatible APIs or local Claude Code subscription login. Full access with no permission prompts; file edits are checkpointed and can be rewound.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/agent.js CHANGED
@@ -168,8 +168,10 @@ function validateInput(schema, input) {
168
168
  }
169
169
 
170
170
  export class Agent {
171
- constructor({ config, modes, tools, systemPrompt, io, model, isSubagent = false, cwd, name = 'main', spawnSubagent, hooks, tasks, checkpoints, depth = 0, refreshSystemPrompt, beforeTurn }) {
171
+ constructor({ config, modes, tools, systemPrompt, io, model, isSubagent = false, cwd, name = 'main', spawnSubagent, hooks, tasks, checkpoints, depth = 0, refreshSystemPrompt, beforeTurn, effort, compactWindow = 0 }) {
172
172
  this.config = config;
173
+ this.effort = effort || undefined; // a sub-agent's own effort level; undefined: the session's (config.effort)
174
+ this.compactWindow = compactWindow; // a sub-agent's own auto-compact window (tokens); 0: the session's
173
175
  this.depth = depth; // 0 for the main agent, +1 for every level of sub-agent
174
176
  this.hooks = hooks;
175
177
  this.tasks = tasks; // background tasks (sub-agents started with run_in_background)
@@ -191,6 +193,7 @@ export class Agent {
191
193
  this.cwd = cwd;
192
194
  this.lastContextTokens = 0;
193
195
  this.tokensSinceUsage = 0;
196
+ this.heldTokens = 0; // room taken by tool results not yet in the conversation (see takeContext)
194
197
  // What this agent's own requests used. `requests` counts the turns' requests; the summary requests of
195
198
  // compactions are counted apart (`compactions`) and their tokens are in the totals like any others.
196
199
  this.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, requests: 0, compactions: 0 };
@@ -216,9 +219,26 @@ export class Agent {
216
219
 
217
220
  get contextEstimate() { return this.lastContextTokens + this.tokensSinceUsage; }
218
221
 
222
+ // Tokens the context can still take before it reaches the auto-compaction threshold: what a Read with allow_large
223
+ // may use (a larger one would be compacted away right after it was read).
224
+ contextRoom() {
225
+ return this.config.autoCompactThreshold(this.model, this.compactWindow) - this.contextEstimate - this.heldTokens;
226
+ }
227
+
228
+ // Claim room for a tool result that is about to be added. Read-only tools run in parallel, so what each one
229
+ // took is held until their results are in the conversation. Returns {ok, tokens, room}.
230
+ takeContext(value) {
231
+ const tokens = estimateTokens(value);
232
+ const room = this.contextRoom();
233
+ if (tokens > room) return { ok: false, tokens, room };
234
+ this.heldTokens += tokens;
235
+ return { ok: true, tokens, room };
236
+ }
237
+
219
238
  pushMessage(msg) {
220
239
  this.messages.push(msg);
221
240
  this.lastActivityAt = Date.now();
241
+ if (msg.role === 'user') this.heldTokens = 0; // the results that held room are counted from here on
222
242
  if (msg.role === 'user') this.tokensSinceUsage += estimateTokens(msg.content);
223
243
  this.io.messageAdded?.(msg, this);
224
244
  }
@@ -473,7 +493,7 @@ export class Agent {
473
493
  shouldAutoCompact() {
474
494
  if (!this.config.autoCompactEnabled || this.messages.length <= 2) return false;
475
495
  if (this.compactFailures >= MAX_COMPACT_FAILURES) return false;
476
- const threshold = this.config.autoCompactThreshold(this.model);
496
+ const threshold = this.config.autoCompactThreshold(this.model, this.compactWindow);
477
497
  if (this.contextEstimate < threshold) return false;
478
498
  // Right after a compaction the base context (system prompt, tools, summary) may itself be
479
499
  // near a very small window; require real growth so we don't compact on every step.
@@ -545,6 +565,7 @@ export class Agent {
545
565
  messages: normalizeMessages(this.messages),
546
566
  tools: this.toolSchemas(),
547
567
  cacheBucket: this.isSubagent ? 'other' : 'main',
568
+ ...(this.effort ? { effort: this.effort } : {}),
548
569
  }, { signal, onEvent: (ev) => this.io.streamEvent?.(ev, this) });
549
570
  this.lastContextTokens = contextTokens(res.usage);
550
571
  this.tokensSinceUsage = 0;
@@ -618,7 +639,7 @@ export class Agent {
618
639
  const fail = (content, extra = {}) => ({ block: { type: 'tool_result', tool_use_id: tu.id, is_error: true, content }, ...extra });
619
640
  const tool = map.get(tu.name);
620
641
  if (!tool) return fail(`Error: No such tool available: ${tu.name}`);
621
- const input = tu.input || {};
642
+ const input = tool.normalizeInput && tu.input && typeof tu.input === 'object' ? tool.normalizeInput({ ...tu.input }) : (tu.input || {});
622
643
  if (input.__invalid_json !== undefined) {
623
644
  return fail('Error: the tool input was not valid JSON (it may have been truncated by the output token limit). Try again with a smaller input, e.g. write large files in several steps.');
624
645
  }
@@ -705,7 +726,7 @@ export class Agent {
705
726
  // the request is too long by the size of the instruction) the plainer request is made: a short system prompt
706
727
  // and no effort, which cannot share the cache, but is smaller and cannot be led astray by the main prompt.
707
728
  async requestSummary(messages, { signal, onEvent }) {
708
- const call = (request) => streamMessage(this.config, { model: this.model, tools: this.toolSchemas(), messages, cacheBucket: 'other', ...request }, { signal, onEvent });
729
+ const call = (request) => streamMessage(this.config, { model: this.model, tools: this.toolSchemas(), messages, cacheBucket: 'other', ...(this.effort ? { effort: this.effort } : {}), ...request }, { signal, onEvent });
709
730
  try {
710
731
  const res = await call({ system: this.currentSystem() });
711
732
  this.recordUsage(res, 'summary');
package/src/agents.js CHANGED
@@ -6,6 +6,8 @@ import path from 'node:path';
6
6
  import { CONFIG_DIR } from './config.js';
7
7
 
8
8
  const READ_ONLY_TOOLS = ['Glob', 'Grep', 'Read', 'Bash', 'WebFetch', 'WebSearch', 'BashOutput'];
9
+ // The effort levels a Task call or an agent definition may ask for (as Claude Code's Agent tool takes them).
10
+ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
9
11
 
10
12
  export const BUILTIN_AGENTS = [
11
13
  {
@@ -83,11 +85,14 @@ export function loadAgents(cwd) {
83
85
  try {
84
86
  const parsed = parseFrontmatter(fs.readFileSync(path.join(dir, f), 'utf8'));
85
87
  if (!parsed?.meta.name || !parsed.meta.description) continue;
88
+ const window = parseInt(parsed.meta.autoCompactWindow, 10);
86
89
  agents.set(parsed.meta.name, {
87
90
  name: parsed.meta.name,
88
91
  description: parsed.meta.description,
89
92
  tools: parsed.meta.tools ? toolList(parsed.meta.tools) : '*',
90
93
  model: parsed.meta.model && parsed.meta.model !== 'inherit' ? parsed.meta.model : null,
94
+ effort: EFFORTS.includes(parsed.meta.effort?.toLowerCase()) ? parsed.meta.effort.toLowerCase() : null,
95
+ autoCompactWindow: window > 0 ? window : 0,
91
96
  prompt: parsed.body,
92
97
  source: path.join(dir, f),
93
98
  });
package/src/api.js CHANGED
@@ -301,11 +301,12 @@ export function buildParams(config, { model, system, messages, tools, maxTokens,
301
301
  }
302
302
 
303
303
  export class APIError extends Error {
304
- constructor(message, { status, retryable, promptTooLong, retryAfterMs = null, rateLimit = null } = {}) {
304
+ constructor(message, { status, retryable, promptTooLong, retryAfterMs = null, rateLimit = null, overloaded = false } = {}) {
305
305
  super(message);
306
306
  this.status = status;
307
307
  this.retryable = retryable;
308
308
  this.promptTooLong = promptTooLong;
309
+ this.overloaded = overloaded; // 529 / overloaded_error: waited out with its own backoff (see planRetry)
309
310
  this.retryAfterMs = retryAfterMs; // what the server's Retry-After header asked for
310
311
  this.rateLimit = rateLimit; // what the rate limit headers say (see ratelimit.js)
311
312
  }
@@ -325,7 +326,8 @@ function describeError(err, config) {
325
326
  if (status === 402 && config?.usesGateway) msg = `The gateway account's balance is too low for this request. Top up at ${config.gatewayUrl}/user (or run /login to check the balance). ${msg}`;
326
327
  const retryable = !status || status === 408 || status === 409 || status === 429 || status >= 500;
327
328
  const header = typeof err?.headers?.get === 'function' ? err.headers.get('retry-after') : err?.headers?.['retry-after'];
328
- return new APIError(msg, { status, retryable, promptTooLong, retryAfterMs: parseRetryAfter(header), rateLimit: parseRateLimit(err?.headers, timing.now()) });
329
+ const overloaded = status === 529 || (err?.error?.error?.type || err?.error?.type) === 'overloaded_error';
330
+ return new APIError(msg, { status, retryable, promptTooLong, retryAfterMs: parseRetryAfter(header), rateLimit: parseRateLimit(err?.headers, timing.now()), overloaded });
329
331
  }
330
332
 
331
333
  // Our own aborts are named AbortError and the SDK's is an APIUserAbortError. The message test only
@@ -365,16 +367,17 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
365
367
  const params = buildParams(config, request, login);
366
368
  let authReloaded = false;
367
369
  const refused = new Set(); // what the endpoint refused for this request: remembered once a request got through
368
- const retryState = { attempt: 0, rateLimitHits: 0, waitedMs: 0 };
369
- let paused = false;
370
+ const retryState = { attempt: 0, rateLimitHits: 0, waitedMs: 0, overloaded: 0, overloadedWaitMs: 0 };
371
+ let paused = null; // the plan of the pause in progress (see planRetry)
370
372
  for (;;) {
371
373
  let emitted = false;
372
374
  const startedAt = Date.now(); // the cache TTL counts from the start of the request, not from the end of the reply
373
375
  try {
374
376
  const stream = await client.messages.create(params, { signal });
375
377
  if (paused) {
376
- paused = false;
377
- onEvent({ type: 'rate_limit_resume', waitedMs: retryState.waitedMs });
378
+ const overloaded = paused.overloaded;
379
+ paused = null;
380
+ onEvent({ type: 'rate_limit_resume', waitedMs: overloaded ? retryState.overloadedWaitMs : retryState.waitedMs, ...(overloaded ? { overloaded } : {}) });
378
381
  }
379
382
  const blocks = [];
380
383
  const jsonBufs = [];
@@ -428,7 +431,7 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
428
431
  if (ev.usage) usage = { ...usage, ...Object.fromEntries(Object.entries(ev.usage).filter(([, v]) => v != null)) };
429
432
  break;
430
433
  case 'error':
431
- throw Object.assign(new Error(ev.error?.message || 'stream error'), { status: ev.error?.type === 'rate_limit_error' ? 429 : 500 });
434
+ throw Object.assign(new Error(ev.error?.message || 'stream error'), { status: { rate_limit_error: 429, overloaded_error: 529 }[ev.error?.type] || 500 });
432
435
  default:
433
436
  break;
434
437
  }
@@ -498,10 +501,13 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
498
501
  }
499
502
  if (emitted) onEvent({ type: 'reset' });
500
503
  if (plan.kind === 'rate_limit') {
501
- paused = true;
504
+ paused = plan;
502
505
  onEvent({
503
506
  type: 'rate_limit_wait', error: err, delayMs: plan.delayMs, resumeAt: plan.resumeAt, resetAt: plan.resetAt,
504
- label: plan.label, polling: plan.polling, waitedMs: retryState.waitedMs, maxWaitMs: config.rateLimitMaxWait,
507
+ label: plan.label, polling: plan.polling,
508
+ ...(plan.overloaded
509
+ ? { overloaded: true, waitedMs: retryState.overloadedWaitMs, maxWaitMs: config.overloadedMaxWait }
510
+ : { waitedMs: retryState.waitedMs, maxWaitMs: config.rateLimitMaxWait }),
505
511
  loginExpiresAt: loginExpiryToWarnAbout(config, login),
506
512
  });
507
513
  await sleepUntil(plan.resumeAt, signal);
package/src/config.js CHANGED
@@ -56,7 +56,7 @@ export const KNOWN_KEYS = {
56
56
  DISABLE_AUTOCOMPACT: 'Set to 1 to disable auto-compaction',
57
57
  CLAUDE_CODE_MAX_OUTPUT_TOKENS: 'max_tokens for each model response (default 128000; lowered automatically if the API allows less)',
58
58
  MAX_THINKING_TOKENS: 'Thinking budget: 0 disables thinking, N enables with budget N',
59
- CLAUDE_CODE_PROMPT_CACHE_TTL: 'Prompt cache TTL of the conversation itself: 5m or 1h (default: 1h with the subscription login, as in Claude Code, else 5m; 1h writes cost more, they pay off when you pause for more than 5 minutes). Also the promptCacheTtl setting',
59
+ CLAUDE_CODE_PROMPT_CACHE_TTL: 'Prompt cache TTL of the conversation itself: 5m or 1h (default: 1h with the subscription login, as in Claude Code, and with the gateway, else 5m; 1h writes cost more, they pay off when you pause for more than 5 minutes). Also the promptCacheTtl setting',
60
60
  CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL: 'Prompt cache TTL of everything else (sub-agents, compaction, side queries): 5m (default) or 1h. Also the subagentPromptCacheTtl setting',
61
61
  ENABLE_PROMPT_CACHING_1H: 'Set to 1 to request the 1-hour TTL for both (the two TTL variables above win over it)',
62
62
  FORCE_PROMPT_CACHING_5M: 'Set to 1 to force the 5-minute TTL for both, over every other TTL setting',
@@ -69,6 +69,9 @@ export const KNOWN_KEYS = {
69
69
  CLAUDE_PRO_MAX_RETRIES: 'Retries for failed API requests (0 = no retries at all, which also turns off the rate limit wait)',
70
70
  CLAUDE_PRO_RATE_LIMIT_MAX_WAIT_MS: 'Longest total time to wait for a rate limit (e.g. the 5-hour window) to reset before continuing by itself (default 21600000 = 6 hours; 0 = report the error instead of waiting)',
71
71
  CLAUDE_PRO_RATE_LIMIT_POLL_MS: 'When a rate limit does not say when it resets, try again at most this far apart (default 300000 = 5 minutes)',
72
+ CLAUDE_CODE_OVERLOADED_RETRY_BASE_DELAY_MS: 'First wait before retrying a request refused as overloaded (529); doubled at each retry (default 1000)',
73
+ CLAUDE_CODE_OVERLOADED_RETRY_MAX_DELAY_MS: 'Longest wait between retries of an overloaded (529) request (default 32000)',
74
+ CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS: 'How long to keep retrying an overloaded (529) API once the usual retries are used up, before reporting the error (default: CLAUDE_PRO_RATE_LIMIT_MAX_WAIT_MS, 6 hours; 0 = give up after the retries)',
72
75
  BASH_DEFAULT_TIMEOUT_MS: 'Default timeout for Bash commands (default 600000 = 10 minutes)',
73
76
  BASH_MAX_TIMEOUT_MS: 'Maximum timeout the model may request for Bash commands (default 3600000 = 1 hour)',
74
77
  BASH_MAX_OUTPUT_LENGTH: 'Max characters of Bash output returned to the model; the full text is saved to a file (default 60000)',
@@ -254,10 +257,10 @@ export class Config {
254
257
 
255
258
  aliasTarget(alias) {
256
259
  if (this.usesClaudeCode) {
257
- return { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-4-5-20251001' }[alias] || null;
260
+ return { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }[alias] || null;
258
261
  }
259
262
  // The gateway prices models by their undated names.
260
- if (this.usesGateway) return { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-4-5' }[alias] || null;
263
+ if (this.usesGateway) return { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }[alias] || null;
261
264
  if (this.usesCodex) {
262
265
  // No Claude here: the big aliases mean the Codex model, haiku the small one when the Codex install has it.
263
266
  if (alias === 'haiku') return codexModels(this.env).models.some((m) => m.slug === 'gpt-6-luna') ? 'gpt-6-luna' : this.codexModelSpec;
@@ -281,7 +284,7 @@ export class Config {
281
284
  const inner = this.resolveModel(aliased);
282
285
  return { ...inner, contextWindow: oneM ? 1_000_000 : inner.contextWindow, label: `${name}${oneM ? '[1m]' : ''} (${inner.label})` };
283
286
  }
284
- const nativeOneM = this.claudeIds && /^claude-(?:sonnet-5(?:-|$)|opus-(?:5(?:-|$)|4-[78](?:-|$)))/i.test(name);
287
+ const nativeOneM = this.claudeIds && /^claude-(?:(?:sonnet|haiku)-5(?:-|$)|opus-(?:5(?:-|$)|4-[78](?:-|$)))/i.test(name);
285
288
  // OpenAI models (the codex provider, gpt-6.1-sol on the gateway): the window the Codex install reports,
286
289
  // else the 272K of the current GPT models.
287
290
  if (/^gpt-/i.test(name)) {
@@ -337,7 +340,9 @@ export class Config {
337
340
  : normalizeTtl(this.env.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL) || normalizeTtl(this.settings?.subagentPromptCacheTtl);
338
341
  if (own) return own;
339
342
  if (isOn(this.env.ENABLE_PROMPT_CACHING_1H)) return '1h';
340
- return bucket === 'main' && this.usesClaudeCode ? '1h' : '5m';
343
+ // The gateway too: measured on its usage records (533 Opus requests), pauses of 5 to 60 minutes rewrote the whole
344
+ // conversation 9 times at 1.25x, $14.77; writing every turn at 2x instead costs $2.80 more.
345
+ return bucket === 'main' && (this.usesClaudeCode || this.usesGateway) ? '1h' : '5m';
341
346
  }
342
347
  // DISABLE_PROMPT_CACHING turns it off for everything; the per-model variables only for the model that
343
348
  // their alias resolves to (a different version pinned as the model keeps its caching), wherever it runs.
@@ -354,15 +359,20 @@ export class Config {
354
359
  get maxRetries() { return int(this.env.CLAUDE_PRO_MAX_RETRIES, 6); }
355
360
  get rateLimitMaxWait() { return Math.max(0, int(this.env.CLAUDE_PRO_RATE_LIMIT_MAX_WAIT_MS, 6 * 3_600_000)); }
356
361
  get rateLimitPoll() { return Math.max(1000, int(this.env.CLAUDE_PRO_RATE_LIMIT_POLL_MS, 300_000)); }
357
-
358
- // Effective window for compaction: the configured auto-compact window capped by the model window.
359
- compactWindow(model = this.mainModel) {
360
- const configured = int(this.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, 0);
362
+ // An overloaded API (529): the backoff, and how long to keep trying once the retries are used up (see planRetry).
363
+ get overloadedRetryBaseDelay() { return Math.max(100, int(this.env.CLAUDE_CODE_OVERLOADED_RETRY_BASE_DELAY_MS, 1000)); }
364
+ get overloadedRetryMaxDelay() { return Math.max(this.overloadedRetryBaseDelay, int(this.env.CLAUDE_CODE_OVERLOADED_RETRY_MAX_DELAY_MS, 32_000)); }
365
+ get overloadedMaxWait() { return Math.max(0, int(this.env.CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS, this.rateLimitMaxWait)); }
366
+
367
+ // Effective window for compaction: the configured auto-compact window capped by the model window. `own`: a
368
+ // sub-agent's autoCompactWindow, which takes the place of the configured one.
369
+ compactWindow(model = this.mainModel, own = 0) {
370
+ const configured = own > 0 ? own : int(this.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, 0);
361
371
  return configured > 0 ? Math.min(configured, model.contextWindow) : model.contextWindow;
362
372
  }
363
373
  get autoCompactEnabled() { return !/^(1|true|yes)$/i.test(this.env.DISABLE_AUTOCOMPACT || ''); }
364
- autoCompactThreshold(model = this.mainModel) {
365
- const window = this.compactWindow(model);
374
+ autoCompactThreshold(model = this.mainModel, own = 0) {
375
+ const window = this.compactWindow(model, own);
366
376
  const pct = int(this.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, 0);
367
377
  if (pct > 0 && pct <= 100) return Math.floor(window * pct / 100);
368
378
  // Leave room for the summary itself and the next response (replies rarely come close to the
package/src/context.js CHANGED
@@ -43,9 +43,12 @@ export function memoryFiles(cwd) {
43
43
  }
44
44
  for (const d of dirs) {
45
45
  if (d === os.homedir()) continue; // home is covered by the user memory
46
+ const before = files.length;
46
47
  add(path.join(d, 'CLAUDE.md'), 'project');
47
48
  add(path.join(d, '.claude', 'CLAUDE.md'), 'project');
48
- add(path.join(d, 'AGENTS.md'), 'project');
49
+ // As in Claude Code, AGENTS.md stands in for a missing CLAUDE.md: where both exist they are usually copies of
50
+ // each other (one for Claude, one for Codex), and loading both would send the instructions twice.
51
+ if (files.length === before) add(path.join(d, 'AGENTS.md'), 'project');
49
52
  add(path.join(d, 'CLAUDE.local.md'), 'local');
50
53
  }
51
54
  return files;
package/src/hooks.js CHANGED
@@ -4,13 +4,17 @@
4
4
  // "PostToolUse": [...], "UserPromptSubmit": [...], "Stop": [...] }
5
5
  // The event payload is passed as JSON on stdin (like Claude Code) and in $CLAUDE_PRO_HOOK_INPUT; $CLAUDE_PROJECT_DIR is set too.
6
6
  // Exit code 0: fine (stdout is kept as extra context). Exit code 2: block, stderr goes to the model.
7
- // Any other non-zero exit code is reported as a warning and ignored.
7
+ // Any other non-zero exit code, a hook that cannot start and one that times out are reported as a warning and
8
+ // let through, unless the hook says "onFailure": "block" (as in Claude Code): then they block like exit code 2.
9
+ // <system-reminder> tags in a hook's output are escaped, so that it cannot pass itself off as the harness.
8
10
 
9
11
  import { spawnShell, killTree, findShell, watchShell } from './utils/shell.js';
10
12
  import { stripAnsi } from './ui/ansi.js';
11
13
 
12
14
  const DEFAULT_TIMEOUT_MS = 60_000;
13
15
 
16
+ export const escapeReminders = (text) => String(text).replace(/<(\/?\s*system-reminder)/gi, '&lt;$1');
17
+
14
18
  function matches(matcher, name) {
15
19
  if (!matcher || matcher === '*') return true;
16
20
  try { return new RegExp(`^(?:${matcher})$`).test(name || ''); } catch { return matcher === name; }
@@ -69,11 +73,18 @@ export class HookRunner {
69
73
  const r = await runCommand(this.shell, h.command, { cwd, payload: full, timeout: h.timeout ? h.timeout * 1000 : DEFAULT_TIMEOUT_MS, signal });
70
74
  if (r.code === 2) {
71
75
  result.blocked = true;
72
- result.message = r.err || r.out || `Blocked by ${event} hook: ${h.command}`;
76
+ result.message = escapeReminders(r.err || r.out || `Blocked by ${event} hook: ${h.command}`);
73
77
  return result;
74
78
  }
75
- if (r.code !== 0) result.warnings.push(`${event} hook "${h.command}" exited with ${r.code}${r.err ? `: ${r.err.split('\n')[0]}` : ''}`);
76
- else if (r.out) result.context.push(r.out);
79
+ if (r.code !== 0) {
80
+ const failure = `${event} hook "${h.command}" exited with ${r.code}${r.err ? `: ${r.err.split('\n')[0]}` : ''}`;
81
+ if (h.onFailure === 'block' && !signal?.aborted) {
82
+ result.blocked = true;
83
+ result.message = escapeReminders(`${failure}\n(The hook failed and is set to block on failure.)`);
84
+ return result;
85
+ }
86
+ result.warnings.push(failure);
87
+ } else if (r.out) result.context.push(escapeReminders(r.out));
77
88
  }
78
89
  }
79
90
  return result;
package/src/print.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { createRuntime } from './runtime.js';
5
5
  import { SessionStore } from './session.js';
6
- import { describePause, shouldAnnounce } from './ratelimit.js';
6
+ import { describePause, resumeText, shouldAnnounce } from './ratelimit.js';
7
7
  import { c } from './ui/ansi.js';
8
8
 
9
9
  export async function runPrint({ prompt, flags, cwd, stdout = process.stdout, stderr = process.stderr }) {
@@ -21,7 +21,7 @@ export async function runPrint({ prompt, flags, cwd, stdout = process.stdout, st
21
21
  }
22
22
  if (ev.type === 'rate_limit_resume') {
23
23
  paused = null;
24
- err(c.yellow(`${who}The limit has cleared; continuing.`));
24
+ err(c.yellow(`${who}${resumeText(ev)}`));
25
25
  }
26
26
  };
27
27
  };
package/src/ratelimit.js CHANGED
@@ -161,6 +161,11 @@ function limitMessage(err, rl, state, budget, now, why) {
161
161
  // that reset, as long as it fits in the budget (rateLimitMaxWait, default 6 hours);
162
162
  // - a short announced wait: a few quick retries, counted like any other;
163
163
  // - nothing announced: back off 1s, 2s, 4s ... up to rateLimitPoll (5 minutes) between attempts, within the budget.
164
+ //
165
+ // An overloaded API (529, overloaded_error) backs off from CLAUDE_CODE_OVERLOADED_RETRY_BASE_DELAY_MS (1s) to
166
+ // CLAUDE_CODE_OVERLOADED_RETRY_MAX_DELAY_MS (32s), as in Claude Code. When the usual retries are used up it does not
167
+ // give up but pauses and tries again at the longest delay, like Claude Code's unattended retry mode, for at most
168
+ // overloadedMaxWait (CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS, by default the rate limit budget) in all.
164
169
  export function planRetry(err, state, config, { now = Date.now(), rand = Math.random, allowWait = true } = {}) {
165
170
  if (!err.retryable) return { action: 'throw' };
166
171
  const maxRetries = config.maxRetries;
@@ -171,6 +176,22 @@ export function planRetry(err, state, config, { now = Date.now(), rand = Math.ra
171
176
  return { action: 'wait', kind: 'retry', delayMs, resumeAt: now + delayMs, attempt, maxRetries: max };
172
177
  };
173
178
 
179
+ if ((err.status === 529 || err.overloaded) && maxRetries > 0) {
180
+ // Its own count: a pause starts the quick retries over (see streamMessage), and this one must not.
181
+ const n = state.overloaded = (state.overloaded || 0) + 1;
182
+ const base = config.overloadedRetryBaseDelay ?? 1000;
183
+ const cap = Math.max(base, config.overloadedRetryMaxDelay ?? 32_000);
184
+ const delayMs = Math.max(Math.min(cap, base * 2 ** (n - 1)) + Math.floor(rand() * 500), Math.min(err.retryAfterMs ?? 0, MAX_RETRY_AFTER_MS));
185
+ if (n <= maxRetries) return { action: 'wait', kind: 'retry', delayMs, resumeAt: now + delayMs, attempt: n, maxRetries };
186
+ const allowed = allowWait ? (config.overloadedMaxWait ?? budget) : 0;
187
+ const waited = state.overloadedWaitMs || 0;
188
+ if (waited + delayMs > allowed) {
189
+ return { action: 'throw', message: `${err.message} (the API was still overloaded after ${n - 1} retries${waited ? ` and ${humanDuration(waited)} of waiting` : ''})` };
190
+ }
191
+ state.overloadedWaitMs = waited + delayMs;
192
+ return { action: 'wait', kind: 'rate_limit', overloaded: true, delayMs, resumeAt: now + delayMs, resetAt: null, label: 'API overloaded', polling: true, attempt: n, maxRetries: null };
193
+ }
194
+
174
195
  if (err.status === 429 && allowWait && maxRetries > 0 && budget > 0) {
175
196
  const known = rl?.waitMs ?? null;
176
197
  if (known !== null && known < LONG_WAIT_MS) {
@@ -223,6 +244,7 @@ export function describePause(ev, now = Date.now()) {
223
244
  }
224
245
  const label = capitalize(ev.label || 'rate limit');
225
246
  const left = humanDuration(Math.max(0, ev.resumeAt - now));
247
+ if (ev.overloaded) return `The API is overloaded and kept refusing the request. Trying again at ${formatClock(ev.resumeAt, now)} (in ${left}), and every so often after that, then continuing automatically.`;
226
248
  const text = ev.polling
227
249
  ? `${label} reached and the server gave no reset time. Checking again at ${formatClock(ev.resumeAt, now)} (in ${left}), and every few minutes after that, then continuing automatically.`
228
250
  : `${label} reached. Pausing until ${formatClock(ev.resumeAt, now)} (in ${left}), then continuing automatically.`;
@@ -237,6 +259,8 @@ export function describePause(ev, now = Date.now()) {
237
259
  // announced reset moves a little between waits: those only continue the pause already announced,
238
260
  // unless the reset moved by more than two minutes. Waiting for the login to be renewed is a different
239
261
  // kind of wait from waiting for the limit, and always worth saying when it begins.
262
+ export const resumeText = (ev) => (ev?.overloaded ? 'The API is answering again; continuing.' : 'The limit has cleared; continuing.');
263
+
240
264
  export function shouldAnnounce(prev, ev) {
241
265
  if (!prev) return true;
242
266
  if (Boolean(prev.waitingForLogin) !== Boolean(ev.waitingForLogin)) return true;
package/src/runtime.js CHANGED
@@ -101,8 +101,10 @@ export async function createRuntime({ cwd, flags = {}, io }) {
101
101
  return true;
102
102
  };
103
103
 
104
- runtime.spawnSubagent = async ({ def, prompt, description, ctx }) => {
105
- const model = def.model ? config.resolveModel(def.model) : config.subagentModel;
104
+ // `model` and `effort`: what the Task call asked for, over the agent definition's own, over the session's.
105
+ runtime.spawnSubagent = async ({ def, prompt, description, ctx, model: modelSpec, effort }) => {
106
+ const spec = modelSpec || def.model;
107
+ const model = spec ? config.resolveModel(spec) : config.subagentModel;
106
108
  const subIo = io.subagentIo ? io.subagentIo({ def, description, parentCtx: ctx }) : {};
107
109
  // The main agent is depth 0; an agent below the nesting limit may start sub-agents itself.
108
110
  const depth = (ctx.depth ?? ctx.agent?.depth ?? 0) + 1;
@@ -120,6 +122,8 @@ export async function createRuntime({ cwd, flags = {}, io }) {
120
122
  cwd: ctx.cwd,
121
123
  name: def.name,
122
124
  hooks,
125
+ effort: effort || def.effort,
126
+ compactWindow: def.autoCompactWindow,
123
127
  });
124
128
  let result;
125
129
  try {
@@ -32,9 +32,9 @@ function readText(file, buf = fs.readFileSync(file)) {
32
32
  return { text, bom: text !== raw, crlf: crlfCount > 0 && crlfCount >= bareLfCount };
33
33
  }
34
34
 
35
- export function numberLines(lines, start) {
35
+ export function numberLines(lines, start, maxLineChars = MAX_LINE_CHARS) {
36
36
  return lines.map((l, i) => {
37
- const line = l.length > MAX_LINE_CHARS ? l.slice(0, MAX_LINE_CHARS) + '… [line truncated]' : l;
37
+ const line = l.length > maxLineChars ? l.slice(0, maxLineChars) + '… [line truncated]' : l;
38
38
  return `${String(start + i).padStart(6)}\t${line}`;
39
39
  }).join('\n');
40
40
  }
@@ -63,6 +63,7 @@ Usage:
63
63
  - By default, it reads up to ${MAX_LINES} lines starting from the beginning of the file
64
64
  - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
65
65
  - Any lines longer than ${MAX_LINE_CHARS} characters will be truncated
66
+ - A file whose numbered text is longer than ${MAX_OUTPUT_CHARS} characters is refused (or cut there with offset/limit). When you need a large file whole, in one call, pass allow_large: true: then there is no line, character or line length limit, only the room left in the context, and a read that does not fit is refused. Prefer offset/limit or Grep when part of the file will do.
66
67
  - Results are returned using cat -n format, with line numbers starting at 1
67
68
  - This tool can read images (PNG, JPG, GIF, WEBP) and Jupyter notebooks (.ipynb)
68
69
  - This tool can only read files, not directories. To list a directory, use ls via the Bash tool.
@@ -74,6 +75,7 @@ Usage:
74
75
  file_path: { type: 'string', description: 'The absolute path to the file to read' },
75
76
  offset: { type: 'number', description: 'The line number to start reading from. Only provide if the file is too large to read at once' },
76
77
  limit: { type: 'number', description: 'The number of lines to read. Only provide if the file is too large to read at once.' },
78
+ allow_large: { type: 'boolean', description: 'Read past the usual size limits in one call (the whole file, or the whole offset/limit range, with long lines kept whole), when you need all of it and the context has room. Refused if it would not fit in the context left.' },
77
79
  },
78
80
  required: ['file_path'],
79
81
  },
@@ -125,19 +127,27 @@ Usage:
125
127
  seen(true);
126
128
  return { content: `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has ${lines.length} lines.</system-reminder>`, display: { summary: 'Read 0 lines' } };
127
129
  }
128
- const limit = Math.max(1, Math.floor(input.limit || MAX_LINES));
130
+ // allow_large: no line, character or line length limit, only the room left in the context (see Agent.takeContext).
131
+ const large = input.allow_large === true;
132
+ const limit = Math.max(1, Math.floor(input.limit || (large ? Infinity : MAX_LINES)));
129
133
  const slice = lines.slice(start - 1, start - 1 + limit);
130
- let content = numberLines(slice, start);
131
- if (content.length > MAX_OUTPUT_CHARS && !input.limit) {
132
- return { isError: true, content: `File content (${Math.round(content.length / 1024)}KB) exceeds the maximum allowed size. Use the offset and limit parameters to read specific portions of the file, or use the Grep tool to search for specific content.` };
134
+ let content = numberLines(slice, start, large ? Infinity : MAX_LINE_CHARS);
135
+ if (large) {
136
+ const fit = ctx.agent?.takeContext?.(content);
137
+ if (fit && !fit.ok) {
138
+ const n = (t) => t.toLocaleString('en-US');
139
+ return { isError: true, content: `File content (about ${n(fit.tokens)} tokens) does not fit in the context left (about ${n(Math.max(0, fit.room))} tokens before auto-compaction). Read the parts you need with offset and limit, or use the Grep tool to search for specific content.` };
140
+ }
141
+ } else if (content.length > MAX_OUTPUT_CHARS && !input.limit) {
142
+ return { isError: true, content: `File content (${Math.round(content.length / 1024)}KB) exceeds the maximum allowed size. Use the offset and limit parameters to read specific portions of the file, or use the Grep tool to search for specific content. If you need the whole file at once, pass allow_large: true.` };
133
143
  }
134
144
  let cut = false;
135
145
  let end = start + slice.length - 1;
136
- if (content.length > HARD_MAX_CHARS) {
146
+ if (!large && content.length > HARD_MAX_CHARS) {
137
147
  const kept = content.slice(0, HARD_MAX_CHARS);
138
148
  const whole = kept.endsWith('\n'); // the cut fell between two lines
139
149
  end = start + (kept.match(/\n/g) || []).length - (whole ? 1 : 0); // the last line shown
140
- content = `${kept}${whole ? '' : '\n'}… [output cut at ${HARD_MAX_CHARS} characters: line ${end} is the last one shown${whole ? '' : ', and only in part'}; read on from there with offset/limit]`;
150
+ content = `${kept}${whole ? '' : '\n'}… [output cut at ${HARD_MAX_CHARS} characters: line ${end} is the last one shown${whole ? '' : ', and only in part'}; read on from there with offset/limit, or pass allow_large: true to read it all at once]`;
141
151
  cut = true;
142
152
  }
143
153
  seen(cut || start > 1 || end < lines.length);
@@ -385,6 +385,14 @@ Usage:
385
385
  },
386
386
  required: ['pattern'],
387
387
  },
388
+ // What a model used to grep (or to the Read tool) sometimes sends, taken as meant, as Claude Code does, instead of
389
+ // searching the working directory when it named a file_path: -l and -c pick the output mode, -r is the default.
390
+ normalizeInput(input) {
391
+ if (input.path == null && typeof input.file_path === 'string') input.path = input.file_path;
392
+ if (input.output_mode == null && input['-c']) input.output_mode = 'count';
393
+ else if (input.output_mode == null && input['-l']) input.output_mode = 'files_with_matches';
394
+ return input;
395
+ },
388
396
  summarize(input, ctx) {
389
397
  let s = `pattern: "${input.pattern}"`;
390
398
  if (input.path) s += `, path: "${displayPath(resolvePath(input.path, ctx.cwd), ctx.cwd)}"`;
package/src/tools/task.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // background tasks.
4
4
 
5
5
  import { taskResultText } from '../tasks.js';
6
+ import { EFFORTS } from '../agents.js';
6
7
 
7
8
  // Wait for `promise`, at most `ms`, or until `signal` aborts. The timer is always cleared so a
8
9
  // finished wait never keeps the process alive.
@@ -52,16 +53,24 @@ Usage notes:
52
53
  prompt: { type: 'string', description: 'The task for the agent to perform' },
53
54
  subagent_type: { type: 'string', description: 'The type of specialized agent to use for this task' },
54
55
  run_in_background: { type: 'boolean', description: 'Run the agent in the background and return immediately with a task id' },
56
+ model: { type: 'string', description: 'Optional model for this agent: sonnet, opus, haiku or a full model id. Defaults to the agent definition\'s model, else the sub-agent default. A smaller model is cheaper and faster for simple searches.' },
57
+ effort: { type: 'string', enum: EFFORTS, description: 'Optional effort level for this agent (default: the agent definition\'s, else the session\'s). Low suits quick lookups, high or above hard reasoning.' },
55
58
  },
56
59
  required: ['description', 'prompt', 'subagent_type'],
57
60
  },
58
- summarize(input) { return `${input.description || ''}${input.run_in_background ? ' (background)' : ''}`; },
61
+ summarize(input) {
62
+ const on = [input.model, input.effort].filter(Boolean).join(' · ');
63
+ return `${input.description || ''}${on ? ` (${on})` : ''}${input.run_in_background ? ' (background)' : ''}`;
64
+ },
59
65
  label(input) { return input.subagent_type && input.subagent_type !== 'general-purpose' ? input.subagent_type : 'Task'; },
60
66
  async call(input, ctx) {
61
67
  const def = agents.get(input.subagent_type) || agents.get('general-purpose');
62
68
  if (!agents.has(input.subagent_type) && input.subagent_type) {
63
69
  return { isError: true, content: `Agent type '${input.subagent_type}' not found. Available agents: ${[...agents.keys()].join(', ')}` };
64
70
  }
71
+ const effort = input.effort ? String(input.effort).toLowerCase() : undefined;
72
+ if (effort && !EFFORTS.includes(effort)) return { isError: true, content: `Unknown effort '${input.effort}'. Use one of: ${EFFORTS.join(', ')}.` };
73
+ const choice = { model: input.model?.trim() || undefined, effort };
65
74
  if (input.run_in_background) {
66
75
  if (!ctx.tasks) return { isError: true, content: 'Background agents are not available here; run the task in the foreground.' };
67
76
  // The task outlives this turn, so it gets its own signal and no live progress display.
@@ -69,7 +78,7 @@ Usage notes:
69
78
  const task = ctx.tasks.start({
70
79
  description: input.description || def.name,
71
80
  subagentType: def.name,
72
- run: (signal) => ctx.spawnSubagent({ def, prompt: input.prompt, description: input.description, ctx: { ...base, signal } }),
81
+ run: (signal) => ctx.spawnSubagent({ def, prompt: input.prompt, description: input.description, ctx: { ...base, signal }, ...choice }),
73
82
  });
74
83
  return {
75
84
  content: `Started background agent ${task.id} (${def.name}): ${input.description}. Its final report will arrive in a <task-notification> when it finishes. Continue with other work; use TaskOutput with task_id "${task.id}" only if you need to wait for it.`,
@@ -77,7 +86,7 @@ Usage notes:
77
86
  };
78
87
  }
79
88
  const started = Date.now();
80
- const { text, toolUses, tokens } = await ctx.spawnSubagent({ def, prompt: input.prompt, description: input.description, ctx });
89
+ const { text, toolUses, tokens } = await ctx.spawnSubagent({ def, prompt: input.prompt, description: input.description, ctx, ...choice });
81
90
  const secs = Math.round((Date.now() - started) / 100) / 10;
82
91
  const kTok = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
83
92
  return {
package/src/ui/editor.js CHANGED
@@ -99,12 +99,32 @@ export class Editor {
99
99
 
100
100
  clear() {
101
101
  this.set('');
102
- this.pastes.clear();
103
- this.images.clear();
102
+ this.pastes = new Map();
103
+ this.images = new Map();
104
104
  this.imageCounter = 0;
105
105
  this.historyIndex = null;
106
106
  }
107
107
 
108
+ // Clear a draft the user threw away (ctrl+c, esc esc), as Claude Code does: up on the empty prompt brings it back,
109
+ // with its pasted text and images, even after other messages were sent meanwhile.
110
+ discard() {
111
+ if (this.text.trim()) {
112
+ this.cleared = { text: this.text, cursor: this.cursor, pastes: this.pastes, images: this.images, imageCounter: this.imageCounter };
113
+ }
114
+ this.clear();
115
+ }
116
+
117
+ restoreCleared() {
118
+ const d = this.cleared;
119
+ if (!d || this.text || this.historyIndex !== null) return false;
120
+ this.cleared = null;
121
+ this.pastes = d.pastes;
122
+ this.images = d.images;
123
+ this.imageCounter = d.imageCounter;
124
+ this.set(d.text, d.cursor);
125
+ return true;
126
+ }
127
+
108
128
  insertImage(image) {
109
129
  const label = `[Image #${++this.imageCounter}]`;
110
130
  this.images.set(label, image);
@@ -213,9 +233,9 @@ export class Editor {
213
233
  }
214
234
  if (name === 'escape') {
215
235
  if (this.suggestions) { this.suggestionsDismissedFor = this.text; this.suggestions = null; return true; }
216
- if (key.meta && this.text) { this.clear(); return true; } // ESC ESC arriving in one chunk
236
+ if (key.meta && this.text) { this.discard(); return true; } // ESC ESC arriving in one chunk
217
237
  const now = Date.now();
218
- if (this.text && now - this.lastEscape < 800) { this.clear(); this.lastEscape = 0; return true; }
238
+ if (this.text && now - this.lastEscape < 800) { this.discard(); this.lastEscape = 0; return true; }
219
239
  this.lastEscape = now;
220
240
  return false;
221
241
  }
@@ -228,7 +248,7 @@ export class Editor {
228
248
  this.set(this.text, Math.min(prevStart + col, lineStart - 1));
229
249
  return true;
230
250
  }
231
- return this.historyPrev();
251
+ return this.restoreCleared() || this.historyPrev();
232
252
  }
233
253
  if (name === 'down' || (key.ctrl && name === 'n')) {
234
254
  if (this.suggestions) { this.selected = (this.selected + 1) % this.suggestions.items.length; this.onChange?.(); return true; }
package/src/ui/repl.js CHANGED
@@ -24,7 +24,7 @@ import { buildCommands } from '../commands.js';
24
24
  import { canRestart, writeRestartState, runSelfCheck, watchSource, restartNote, RESTART_EXIT_CODE, MAX_RESTARTS, RESTART_WINDOW_MS } from '../restart.js';
25
25
  import { resolvePath, displayPath } from '../utils/paths.js';
26
26
  import { ripgrepPath } from '../utils/shell.js';
27
- import { describePause, formatClock, humanDuration, shouldAnnounce } from '../ratelimit.js';
27
+ import { describePause, formatClock, humanDuration, resumeText, shouldAnnounce } from '../ratelimit.js';
28
28
  import { shutdownBackgroundShells, listBackgroundShells } from '../tools/bash.js';
29
29
  import { estimateTokens } from '../agent.js';
30
30
  import { readClipboardImage, imageFromFile, watchClipboard } from '../utils/clipboard.js';
@@ -625,7 +625,7 @@ export class App {
625
625
  return;
626
626
  }
627
627
  if (this.busy) { this.interrupt(); return; }
628
- if (this.editor.text) { this.editor.clear(); this.screen.schedule(); return; }
628
+ if (this.editor.text) { this.editor.discard(); this.screen.schedule(); return; }
629
629
  if (Date.now() - this.exitArmed < 1500) { this.exit(0); return; }
630
630
  this.exitArmed = Date.now();
631
631
  this.screen.schedule();
@@ -1149,12 +1149,12 @@ export class App {
1149
1149
  rateLimitEvent(ev) {
1150
1150
  if (ev.type === 'rate_limit_resume') {
1151
1151
  this.rateWait = null;
1152
- this.notice('The limit has cleared; continuing.');
1152
+ this.notice(resumeText(ev));
1153
1153
  return;
1154
1154
  }
1155
1155
  // Say it once; later waits of the same pause only move the countdown (unless the reset moved a lot).
1156
1156
  const prev = this.rateWait;
1157
- this.rateWait = { resumeAt: ev.resumeAt, label: ev.label, polling: ev.polling, waitingForLogin: Boolean(ev.waitingForLogin) };
1157
+ this.rateWait = { resumeAt: ev.resumeAt, label: ev.label, polling: ev.polling, waitingForLogin: Boolean(ev.waitingForLogin), overloaded: Boolean(ev.overloaded) };
1158
1158
  if (shouldAnnounce(prev, ev)) this.notice(`${describePause(ev)} Esc cancels.`, 'warn');
1159
1159
  this.screen.schedule();
1160
1160
  }
@@ -1238,7 +1238,7 @@ export class App {
1238
1238
  }
1239
1239
  if (ev.type === 'rate_limit_resume') {
1240
1240
  paused = null;
1241
- app.notice(`[${def.name}] The limit has cleared; continuing.`);
1241
+ app.notice(`[${def.name}] ${resumeText(ev)}`);
1242
1242
  }
1243
1243
  };
1244
1244
  return {
@@ -1321,7 +1321,7 @@ export class App {
1321
1321
  if (w.waitingForLogin) return c.yellow(`${frame} Waiting for the Claude Code login to be renewed · checking again in ${left}`) + ' ' + esc;
1322
1322
  const label = w.label.charAt(0).toUpperCase() + w.label.slice(1);
1323
1323
  const when = w.polling ? `checking again in ${left}` : `resumes at ${formatClock(w.resumeAt)} (in ${left})`;
1324
- return c.yellow(`${frame} ${label} reached · ${when}`) + ' ' + esc;
1324
+ return c.yellow(`${frame} ${w.overloaded ? label : `${label} reached`} · ${when}`) + ' ' + esc;
1325
1325
  }
1326
1326
  const secs = formatDuration(Date.now() - this.turnStart);
1327
1327
  const verb = this.status || (this.thinking ? 'Thinking' : this.verb);