@miphamai/cli 0.85.5 → 0.85.7

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.
@@ -42,129 +42,11 @@
42
42
  "dismiss": "关闭",
43
43
  "terminalName": "Mipham Code"
44
44
  },
45
- "web": {
46
- "hero": {
47
- "title": "Mipham Code",
48
- "subtitle": "多模型开源智能编程终端。为追求性能、速度和灵活性的开发者而打造。",
49
- "company": "由 One Mipham Corporation 驱动 | 北京华安麦逄科技有限公司",
50
- "domains": "国际站: mipham.ai  |  中国大陆: onemipham.com",
51
- "cta_start": "快速开始",
52
- "cta_docs": "文档"
53
- },
54
- "features": {
55
- "title": "为什么选择 Mipham Code?",
56
- "multi_model": {
57
- "title": "多模型支持",
58
- "desc": "通过统一接口连接 Claude、GPT、DeepSeek、Qwen、Kimi 等。使用 Ctrl+P 实时切换模型。"
59
- },
60
- "slash_commands": {
61
- "title": "85 个 Slash 命令",
62
- "desc": "完全对标 Claude Code:/commit、/pr、/simplify、/lint、/loop init、/browse-plugins — 全部 85 个命令,零学习成本。"
63
- },
64
- "plugins": {
65
- "title": "插件市场",
66
- "desc": "从 npm 或本地路径安装插件。探索社区插件,包括 NotebookLM MCP 集成。"
67
- },
68
- "loopkit": {
69
- "title": "LoopKit Vault",
70
- "desc": "/loop init 初始化完整的项目结构:.mipham/ 配置、钩子、代理、9 领域技能、.mcp.json 等。"
71
- },
72
- "mcp": {
73
- "title": "MCP 协议",
74
- "desc": "完整的 Model Context Protocol 支持,基于 stdio 传输。连接 NotebookLM、文件系统、GitHub 和自定义 MCP 服务器。"
75
- },
76
- "secure": {
77
- "title": "开源核心 · 安全可靠",
78
- "desc": "Apache 2.0 许可证。TLS 1.3、AES-256-GCM 加密,全面的权限系统。开箱即用的企业级安全。"
79
- }
80
- },
81
- "models": {
82
- "title": "支持的模型",
83
- "status_active": "已上线",
84
- "status_upcoming": "即将推出"
85
- },
86
- "install_section": {
87
- "title": "秒级安装",
88
- "subtitle": "四种安装方式 — 选择适合你的平台。",
89
- "npm_recommended": "npm(推荐)",
90
- "curl_label": "curl(macOS / Linux)",
91
- "powershell_label": "PowerShell(Windows)",
92
- "label_international": "International · 国际站",
93
- "label_china": "中国大陆 · China mainland",
94
- "requirements": "需要 Bun 1.2+ 或 Node.js 22+。支持 macOS、Linux、Windows。",
95
- "full_guide": "完整安装指南"
96
- },
97
- "footer": {
98
- "copyright": "© {year} One Mipham Corporation。保留所有权利。",
99
- "docs": "文档",
100
- "dashboard": "控制台",
101
- "github": "GitHub"
102
- },
103
- "install_page": {
104
- "title": "安装指南",
105
- "current_version": "当前版本:",
106
- "choose_platform": "选择适合你的平台和安装方式。",
107
- "all_methods_same": "所有方式安装的是同一个 Mipham Code。",
108
- "prerequisites": "前置要求",
109
- "prereq_bun": "Bun 1.2+(推荐)或 Node.js 22+",
110
- "prereq_os": "macOS、Linux 或 Windows(PowerShell)",
111
- "prereq_api_key": "至少一个 AI 提供商的 API 密钥(如 Anthropic、OpenAI)",
112
- "npm_title": "1. npm 安装(推荐)",
113
- "npm_desc": "适用于所有平台 — macOS、Linux、Windows。",
114
- "curl_title": "2. curl 安装(macOS / Linux)",
115
- "powershell_title": "3. PowerShell 安装(Windows)",
116
- "direct_download": "4. 直接下载",
117
- "table_platform": "平台",
118
- "table_download": "下载",
119
- "platform_mac_arm": "macOS(Apple Silicon)",
120
- "platform_mac_intel": "macOS(Intel)",
121
- "platform_linux": "Linux(x64)",
122
- "platform_windows": "Windows(x64)",
123
- "verify_title": "验证安装",
124
- "start_title": "启动 Mipham Code",
125
- "first_launch": "首次启动时,交互式设置向导(/setup)将引导你完成提供商配置、模型选择、技能安装和权限设置。",
126
- "api_keys_title": "设置 API 密钥",
127
- "api_keys_desc": "将提供商的 API 密钥导出为环境变量:",
128
- "international": "International · 国际站",
129
- "china_mainland": "中国大陆 · China mainland"
130
- },
131
- "docs": {
132
- "title": "文档",
133
- "quick_start": "快速开始",
134
- "configuration": "配置",
135
- "commands": "命令",
136
- "help": "显示帮助",
137
- "model": "显示当前模型",
138
- "switch": "切换模型",
139
- "clear": "清除对话",
140
- "exit": "退出 Mipham Code"
141
- },
142
- "dashboard": {
143
- "title": "控制台",
144
- "coming_soon": "即将推出",
145
- "description": "Mipham Code 控制台将展示你的使用分析 — 会话历史、Token 消耗、最常用的模型和工具以及技能活动。我们正在开发中。",
146
- "sessions": "会话",
147
- "sessions_desc": "会话历史与统计",
148
- "tokens": "Token",
149
- "tokens_desc": "各提供商用量",
150
- "skills": "技能",
151
- "skills_desc": "活跃技能分析"
152
- },
153
- "not_found": {
154
- "code": "404",
155
- "title": "页面未找到",
156
- "description": "你要找的页面不存在。",
157
- "back": "返回 Mipham Code"
158
- },
159
- "error": {
160
- "title": "出了点问题",
161
- "fallback_message": "发生了意外错误。",
162
- "try_again": "重试"
163
- }
164
- },
165
45
  "greeting": "你好 {name}",
166
46
  "commands": {
167
- "clear": { "confirmed": "✓ 对话已清除,上下文已重置。" },
47
+ "clear": {
48
+ "confirmed": "✓ 对话已清除,上下文已重置。"
49
+ },
168
50
  "compact": {
169
51
  "confirmed": "✓ 上下文已压缩。",
170
52
  "tokens": "Tokens: {before} → {after} (节省 {saved}%)"
@@ -304,7 +186,6 @@
304
186
  },
305
187
  "task_list": {
306
188
  "title": "── 后台任务 ──",
307
- "detected": "在此会话中检测到 {count} 次任务操作。\n\n使用 Task 工具(action \"create\" / \"update\" / \"list\")管理结构化任务跟踪。",
308
189
  "no_tasks": "尚未跟踪任何任务。使用 Task 工具的 action \"create\"、\"update\" 或 \"list\" 管理结构化任务。",
309
190
  "reference": "快速参考:",
310
191
  "legacy_hint": "输入 /todos 使用旧版任务界面。"
@@ -333,7 +214,9 @@
333
214
  "not_found_content": "未找到名为 \"{name}\" 的会话。\n\n不带参数运行 /resume 列出所有已保存的会话。",
334
215
  "found_content": "名称: {name}\n消息数: {messages}\n提供商: {provider} / {model}\n更新时间: {updated}\n\n要接着此会话继续,请另起进程运行:\n mipham --resume \"{target}\""
335
216
  },
336
- "help": { "title": "── Mipham Code 帮助 ──" },
217
+ "help": {
218
+ "title": "── Mipham Code 帮助 ──"
219
+ },
337
220
  "lang": {
338
221
  "current": "当前语言: {locale}",
339
222
  "set": "语言已设置为 {locale}。重启 Mipham Code 生效。"
@@ -433,6 +316,8 @@
433
316
  "update_failed": "✗ 更新失败: {reason}{rolledBack}\n 手动尝试: {command}\n 配置备份位于: {path}",
434
317
  "rolled_back": "\n 已恢复原安装 —— mipham 仍可使用。",
435
318
  "no_rollback": "\n ⚠ 原安装未能恢复 —— 请按下方命令手动重装。",
319
+ "install_untouched": "\n 原安装未被改动 —— mipham 仍可使用。",
320
+ "install_unknown": "\n ⚠ 无法定位全局安装路径 —— 无法判断原安装是否完好。",
436
321
  "unverified": "⚠ 已安装,但无法验证新版本: {reason}"
437
322
  },
438
323
  "no_plan": {
@@ -849,9 +734,15 @@
849
734
  "finished": "已完成",
850
735
  "status_label": "代理"
851
736
  },
852
- "system": { "role_label": "系统" },
853
- "assistant": { "role_label": "Mipham Code" },
854
- "user": { "role_label": "用户" },
737
+ "system": {
738
+ "role_label": "系统"
739
+ },
740
+ "assistant": {
741
+ "role_label": "Mipham Code"
742
+ },
743
+ "user": {
744
+ "role_label": "用户"
745
+ },
855
746
  "input": {
856
747
  "placeholder": "输入消息(Esc 清空)..."
857
748
  },
@@ -976,22 +867,70 @@
976
867
  }
977
868
  },
978
869
  "tools": {
979
- "bash": { "name": "Bash", "description": "执行 Shell 命令" },
980
- "read": { "name": "Read", "description": "从文件系统读取文件" },
981
- "write": { "name": "Write", "description": "写入内容到文件" },
982
- "edit": { "name": "Edit", "description": "通过字符串替换编辑文件" },
983
- "glob": { "name": "Glob", "description": "查找匹配模式的文件" },
984
- "grep": { "name": "Grep", "description": "使用正则表达式搜索文件内容" },
985
- "web_fetch": { "name": "WebFetch", "description": "获取 URL 并解析内容" },
986
- "web_search": { "name": "WebSearch", "description": "搜索网页" },
987
- "agent": { "name": "Agent", "description": "启动子代理执行复杂任务" },
988
- "task": { "name": "Task", "description": "创建和跟踪任务" },
989
- "skill": { "name": "Skill", "description": "调用命名技能" },
990
- "memory": { "name": "Memory", "description": "读写持久记忆" },
991
- "workflow": { "name": "Workflow", "description": "执行多代理工作流" },
992
- "plan": { "name": "Plan", "description": "进入计划模式进行结构化思考" },
993
- "config": { "name": "Config", "description": "查看和修改配置" },
994
- "mcp": { "name": "MCP", "description": "管理 MCP 服务器连接" }
870
+ "bash": {
871
+ "name": "Bash",
872
+ "description": "执行 Shell 命令"
873
+ },
874
+ "read": {
875
+ "name": "Read",
876
+ "description": "从文件系统读取文件"
877
+ },
878
+ "write": {
879
+ "name": "Write",
880
+ "description": "写入内容到文件"
881
+ },
882
+ "edit": {
883
+ "name": "Edit",
884
+ "description": "通过字符串替换编辑文件"
885
+ },
886
+ "glob": {
887
+ "name": "Glob",
888
+ "description": "查找匹配模式的文件"
889
+ },
890
+ "grep": {
891
+ "name": "Grep",
892
+ "description": "使用正则表达式搜索文件内容"
893
+ },
894
+ "web_fetch": {
895
+ "name": "WebFetch",
896
+ "description": "获取 URL 并解析内容"
897
+ },
898
+ "web_search": {
899
+ "name": "WebSearch",
900
+ "description": "搜索网页"
901
+ },
902
+ "agent": {
903
+ "name": "Agent",
904
+ "description": "启动子代理执行复杂任务"
905
+ },
906
+ "task": {
907
+ "name": "Task",
908
+ "description": "创建和跟踪任务"
909
+ },
910
+ "skill": {
911
+ "name": "Skill",
912
+ "description": "调用命名技能"
913
+ },
914
+ "memory": {
915
+ "name": "Memory",
916
+ "description": "读写持久记忆"
917
+ },
918
+ "workflow": {
919
+ "name": "Workflow",
920
+ "description": "执行多代理工作流"
921
+ },
922
+ "plan": {
923
+ "name": "Plan",
924
+ "description": "进入计划模式进行结构化思考"
925
+ },
926
+ "config": {
927
+ "name": "Config",
928
+ "description": "查看和修改配置"
929
+ },
930
+ "mcp": {
931
+ "name": "MCP",
932
+ "description": "管理 MCP 服务器连接"
933
+ }
995
934
  },
996
935
  "errors": {
997
936
  "tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式,或移除该规则:/permissions remove \"{pattern}\"",
@@ -132,6 +132,9 @@ export class AnthropicProvider implements ProviderInstance {
132
132
  'anthropic-beta': 'prompt-caching-2024-07-31',
133
133
  },
134
134
  body: JSON.stringify(body),
135
+ // Same as `openai-compat`: without this the caller's signal never reaches
136
+ // the transport, and every per-call cancellation budget is decorative.
137
+ signal: req.signal,
135
138
  })
136
139
 
137
140
  if (!response.ok) {
@@ -153,162 +156,173 @@ export class AnthropicProvider implements ProviderInstance {
153
156
  // passes aren't mistaken for a stalled connection.
154
157
  const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort)
155
158
 
156
- while (true) {
157
- let readResult: Awaited<ReturnType<typeof reader.read>>
158
- let idleTimer: ReturnType<typeof setTimeout> | undefined
159
- try {
160
- readResult = await Promise.race([
161
- reader.read(),
162
- new Promise<never>((_, reject) => {
163
- idleTimer = setTimeout(
164
- () =>
165
- reject(
166
- new Error(
167
- `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
168
- ),
169
- ),
170
- STREAM_READ_TIMEOUT_MS,
171
- )
172
- }),
173
- ])
174
- } catch (err) {
175
- yield { type: 'error', error: `Stream stalled: ${String(err)}` }
176
- return
177
- } finally {
178
- if (idleTimer) clearTimeout(idleTimer)
179
- }
180
- const { done, value } = readResult
181
- if (done) break
182
-
183
- buffer += decoder.decode(value, { stream: true })
184
- const lines = buffer.split('\n')
185
- buffer = lines.pop() || ''
186
-
187
- for (const line of lines) {
188
- const trimmed = line.trim()
189
- if (!trimmed || !trimmed.startsWith('data: ')) continue
190
- const data = trimmed.slice(6)
191
-
159
+ // The read loop and the trailing stop share one reader, and that reader owns
160
+ // the connection. `engine.ts` breaks out of this generator on the ordinary
161
+ // `stop` chunk, and a sub-agent throws mid-stream on abort — both call
162
+ // `.return()`, which unwinds through here. Without this, a turn that ends
163
+ // normally (or is abandoned) leaves the body unread and uncancelled, so the
164
+ // socket can't be reused. `cancel()` on an already-errored stream rejects, and
165
+ // on a closed one is a no-op — the catch covers the first.
166
+ try {
167
+ while (true) {
168
+ let readResult: Awaited<ReturnType<typeof reader.read>>
169
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
192
170
  try {
193
- const event = JSON.parse(data) as AnthropicSSEEvent
194
-
195
- switch (event.type) {
196
- case 'content_block_start': {
197
- const cb = event.content_block
198
- if (!cb) continue
199
-
200
- if (cb.type === 'tool_use') {
201
- currentToolName = cb.name || ''
202
- currentToolId = cb.id || ''
203
- accumulatedToolInput = ''
171
+ readResult = await Promise.race([
172
+ reader.read(),
173
+ new Promise<never>((_, reject) => {
174
+ idleTimer = setTimeout(
175
+ () =>
176
+ reject(
177
+ new Error(
178
+ `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
179
+ ),
180
+ ),
181
+ STREAM_READ_TIMEOUT_MS,
182
+ )
183
+ }),
184
+ ])
185
+ } catch (err) {
186
+ yield { type: 'error', error: `Stream stalled: ${String(err)}` }
187
+ return
188
+ } finally {
189
+ if (idleTimer) clearTimeout(idleTimer)
190
+ }
191
+ const { done, value } = readResult
192
+ if (done) break
193
+
194
+ buffer += decoder.decode(value, { stream: true })
195
+ const lines = buffer.split('\n')
196
+ buffer = lines.pop() || ''
197
+
198
+ for (const line of lines) {
199
+ const trimmed = line.trim()
200
+ if (!trimmed || !trimmed.startsWith('data: ')) continue
201
+ const data = trimmed.slice(6)
202
+
203
+ try {
204
+ const event = JSON.parse(data) as AnthropicSSEEvent
205
+
206
+ switch (event.type) {
207
+ case 'content_block_start': {
208
+ const cb = event.content_block
209
+ if (!cb) continue
210
+
211
+ if (cb.type === 'tool_use') {
212
+ currentToolName = cb.name || ''
213
+ currentToolId = cb.id || ''
214
+ accumulatedToolInput = ''
215
+ }
216
+ break
204
217
  }
205
- break
206
- }
207
218
 
208
- case 'content_block_delta': {
209
- const delta = event.delta
210
- if (!delta) continue
219
+ case 'content_block_delta': {
220
+ const delta = event.delta
221
+ if (!delta) continue
211
222
 
212
- if (delta.type === 'text_delta' && delta.text) {
213
- yield { type: 'text', content: delta.text }
214
- }
223
+ if (delta.type === 'text_delta' && delta.text) {
224
+ yield { type: 'text', content: delta.text }
225
+ }
215
226
 
216
- if (delta.type === 'thinking_delta' && delta.text) {
217
- yield { type: 'thinking', thinking: delta.text }
218
- }
227
+ if (delta.type === 'thinking_delta' && delta.text) {
228
+ yield { type: 'thinking', thinking: delta.text }
229
+ }
219
230
 
220
- if (delta.type === 'input_json_delta' && delta.partial_json) {
221
- accumulatedToolInput += delta.partial_json
231
+ if (delta.type === 'input_json_delta' && delta.partial_json) {
232
+ accumulatedToolInput += delta.partial_json
233
+ }
234
+ break
222
235
  }
223
- break
224
- }
225
236
 
226
- case 'content_block_stop': {
227
- // 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
228
- // `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
229
- // 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
230
- // tool_call」的处置,这里结构上做不到(它的 finish_reason 与
231
- // tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
232
- // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
233
- // 那是一次行为变更,不属本次范围。
234
- if (currentToolId && currentToolName && accumulatedToolInput) {
235
- // A replayed block carries the id it was first sent with, so the
236
- // id is what tells a second call apart from the same call twice.
237
- if (!emittedToolIds.has(currentToolId)) {
238
- emittedToolIds.add(currentToolId)
239
-
240
- let parsedInput: Record<string, unknown> = {}
241
- try {
242
- parsedInput = JSON.parse(accumulatedToolInput)
243
- } catch {
244
- parsedInput = { _raw: accumulatedToolInput }
245
- }
246
-
247
- yield {
248
- type: 'tool_use',
249
- toolUse: {
237
+ case 'content_block_stop': {
238
+ // 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
239
+ // `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
240
+ // 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
241
+ // tool_call」的处置,这里结构上做不到(它的 finish_reason 与
242
+ // tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
243
+ // 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
244
+ // 那是一次行为变更,不属本次范围。
245
+ if (currentToolId && currentToolName && accumulatedToolInput) {
246
+ // A replayed block carries the id it was first sent with, so the
247
+ // id is what tells a second call apart from the same call twice.
248
+ if (!emittedToolIds.has(currentToolId)) {
249
+ emittedToolIds.add(currentToolId)
250
+
251
+ let parsedInput: Record<string, unknown> = {}
252
+ try {
253
+ parsedInput = JSON.parse(accumulatedToolInput)
254
+ } catch {
255
+ parsedInput = { _raw: accumulatedToolInput }
256
+ }
257
+
258
+ yield {
250
259
  type: 'tool_use',
251
- id: currentToolId,
252
- name: currentToolName,
253
- input: parsedInput,
254
- },
260
+ toolUse: {
261
+ type: 'tool_use',
262
+ id: currentToolId,
263
+ name: currentToolName,
264
+ input: parsedInput,
265
+ },
266
+ }
255
267
  }
256
- }
257
268
 
258
- // Reset accumulator
259
- currentToolName = ''
260
- currentToolId = ''
261
- accumulatedToolInput = ''
269
+ // Reset accumulator
270
+ currentToolName = ''
271
+ currentToolId = ''
272
+ accumulatedToolInput = ''
273
+ }
274
+ break
262
275
  }
263
- break
264
- }
265
276
 
266
- case 'message_delta': {
267
- // Capture token usage for accurate cost tracking
268
- if (event.usage) {
269
- yield {
270
- type: 'usage',
271
- inputTokens: event.usage.input_tokens,
272
- outputTokens: event.usage.output_tokens,
277
+ case 'message_delta': {
278
+ // Capture token usage for accurate cost tracking
279
+ if (event.usage) {
280
+ yield {
281
+ type: 'usage',
282
+ inputTokens: event.usage.input_tokens,
283
+ outputTokens: event.usage.output_tokens,
284
+ }
273
285
  }
286
+ // Contains stop_reason; also handles late input_json_delta
287
+ if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
288
+ accumulatedToolInput += event.delta.partial_json
289
+ }
290
+ // `max_tokens` means the turn hit the output ceiling. Without this the
291
+ // truncation is indistinguishable from `end_turn`: both arrive here and
292
+ // the terminal stop below looks the same either way.
293
+ const stopReason = event.delta?.stop_reason
294
+ if (stopReason === 'max_tokens') {
295
+ truncated = true
296
+ }
297
+ break
274
298
  }
275
- // Contains stop_reason; also handles late input_json_delta
276
- if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
277
- accumulatedToolInput += event.delta.partial_json
278
- }
279
- // `max_tokens` means the turn hit the output ceiling. Without this the
280
- // truncation is indistinguishable from `end_turn`: both arrive here and
281
- // the terminal stop below looks the same either way.
282
- const stopReason = event.delta?.stop_reason
283
- if (stopReason === 'max_tokens') {
284
- truncated = true
285
- }
286
- break
287
- }
288
299
 
289
- case 'message_stop': {
290
- sawTerminalEvent = true
291
- yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
292
- return
293
- }
300
+ case 'message_stop': {
301
+ sawTerminalEvent = true
302
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
303
+ return
304
+ }
294
305
 
295
- case 'error': {
296
- yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
297
- return
306
+ case 'error': {
307
+ yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
308
+ return
309
+ }
298
310
  }
311
+ } catch {
312
+ // Skip unparseable SSE events
299
313
  }
300
- } catch {
301
- // Skip unparseable SSE events
302
314
  }
303
315
  }
304
- }
305
316
 
306
- // The stream ran out without `message_stop`. Whatever stopped it, the turn is
307
- // incomplete — and this is the only place that knows, because a cleanly
308
- // closed connection and a finished response are otherwise the same stream.
309
- if (!sawTerminalEvent) truncated = true
317
+ // The stream ran out without `message_stop`. Whatever stopped it, the turn is
318
+ // incomplete — and this is the only place that knows, because a cleanly
319
+ // closed connection and a finished response are otherwise the same stream.
320
+ if (!sawTerminalEvent) truncated = true
310
321
 
311
- yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
322
+ yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
323
+ } finally {
324
+ await reader.cancel().catch(() => {})
325
+ }
312
326
  }
313
327
 
314
328
  async listModels(): Promise<ModelInfo[]> {
@@ -96,7 +96,13 @@ export async function fetchWithRetry(
96
96
  timedOut = true
97
97
  controller.abort()
98
98
  }, timeout)
99
- const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
99
+ // `AbortSignal.any` (node ≥22 / bun ≥1.2, both in `engines`) instead of a
100
+ // hand-rolled combiner: it keeps a *weak* reference to the source signals, so
101
+ // the combination stays live for the reader without pinning the caller's
102
+ // signal — which is what the hand-rolled version had to trade away.
103
+ const signal = init.signal
104
+ ? AbortSignal.any([init.signal, controller.signal])
105
+ : controller.signal
100
106
 
101
107
  try {
102
108
  const response = await fetch(url, { ...init, signal })
@@ -123,14 +129,11 @@ export async function fetchWithRetry(
123
129
  await sleep(baseDelay * Math.pow(2, attempt))
124
130
  } finally {
125
131
  clearTimeout(timer)
126
- // Clean up combined signal if we created one
127
- if (init.signal) {
128
- try {
129
- controller.abort()
130
- } catch {
131
- /* best effort */
132
- }
133
- }
132
+ // Nothing to release: the combination is natively managed. Do NOT abort
133
+ // anything here — `fetch` holds that signal and the caller reads the body
134
+ // *after* we return, so aborting it at this point errored every response at
135
+ // the headers (measured on Node: the next read throws AbortError; Bun
136
+ // happens to tolerate it, which is why a Bun-only run never showed this).
134
137
  }
135
138
  }
136
139
 
@@ -163,23 +166,3 @@ export function streamIdleTimeoutMs(effort?: string): number {
163
166
  const multiplier = effort ? (EFFORT_TIMEOUT_MULTIPLIER[effort] ?? 1) : 1
164
167
  return STREAM_IDLE_TIMEOUT_BASE_MS * multiplier
165
168
  }
166
-
167
- /**
168
- * Combine multiple AbortSignals into one — any signal aborting
169
- * triggers the combined signal.
170
- */
171
- function anySignal(signals: AbortSignal[]): AbortSignal {
172
- const controller = new AbortController()
173
- const onAbort = () => {
174
- controller.abort()
175
- for (const s of signals) s.removeEventListener('abort', onAbort)
176
- }
177
- for (const s of signals) {
178
- if (s.aborted) {
179
- controller.abort()
180
- return controller.signal
181
- }
182
- s.addEventListener('abort', onAbort)
183
- }
184
- return controller.signal
185
- }