codebee 0.1.48 → 0.1.50

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.
@@ -58,52 +58,91 @@ def find_git_bash():
58
58
  return None
59
59
 
60
60
 
61
- def resolve_command(command):
61
+ def resolve_command(command):
62
62
  """命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
63
63
  path = shutil.which(command) or command
64
64
  low = path.lower()
65
65
  if low.endswith(".cmd") or low.endswith(".bat"):
66
66
  return ["cmd", "/c", path]
67
- return [path]
68
-
69
-
70
- def _git_repo_issue(workdir):
71
- """返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
72
-
73
- Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
74
- 再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
75
- 错误变成可操作的任务失败信息。
76
- """
77
- if not workdir or not os.path.isdir(workdir):
78
- return ""
79
- git_dir = os.path.join(workdir, ".git")
80
- if not os.path.exists(git_dir):
81
- return ""
82
- try:
83
- probe = subprocess.run(
84
- ["git", "-C", workdir, "rev-parse", "--git-dir"],
85
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
86
- text=True, timeout=5, check=False,
87
- )
88
- except (OSError, subprocess.SubprocessError):
89
- return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
90
- if probe.returncode != 0:
91
- detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
92
- return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
93
- try:
94
- probe = subprocess.run(
95
- ["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
96
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
97
- text=True, timeout=8, check=False,
98
- )
99
- except subprocess.TimeoutExpired:
100
- return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
101
- except OSError:
102
- return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
103
- if probe.returncode != 0:
104
- detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
105
- return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
106
- return ""
67
+ return [path]
68
+
69
+
70
+ def _orphan_pack_files(workdir):
71
+ """列出缺少同名 .idx 的 pack 文件名(无则空列表)。
72
+
73
+ Git 本体扫目录时发现 pack 没有对应 .idx,只打一行 warning 就跳过该包,
74
+ 所以 git status/fsck 全都正常;aider 走的 gitpython/gitdb 却按
75
+ pack-*.pack 逐个打开同名 .idx,缺一次就抛 FileNotFoundError,被它记成
76
+ 「is your git repo corrupted?」——2026-09-22 mo-so 实案:pack-a52e675e
77
+ .idx 缺失,aider 换将后跑满 14 分钟才失败,报错头还是 prompt toolkit 噪声。
78
+ """
79
+ try:
80
+ probe = subprocess.run(
81
+ ["git", "-C", workdir, "rev-parse", "--git-path", "objects/pack"],
82
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
83
+ text=True, timeout=5, check=False,
84
+ )
85
+ except (OSError, subprocess.SubprocessError):
86
+ return []
87
+ rel = (probe.stdout or "").strip()
88
+ if probe.returncode != 0 or not rel:
89
+ return []
90
+ pack_dir = rel if os.path.isabs(rel) else os.path.join(workdir, rel)
91
+ try:
92
+ names = set(os.listdir(pack_dir))
93
+ except OSError:
94
+ return []
95
+ return sorted(n for n in names
96
+ if n.startswith("pack-") and n.endswith(".pack")
97
+ and (n[:-len(".pack")] + ".idx") not in names)
98
+
99
+
100
+ def _git_repo_issue(workdir):
101
+ """返回工作目录 Git 仓库的可读性问题;无 Git 仓库时返回空串。
102
+
103
+ Aider 会在启动后自行读取索引。索引包缺失时它通常先写入 .gitignore,
104
+ 再在深层调用中才报错,用户会误以为任务卡住;提前做一次廉价预检能把
105
+ 错误变成可操作的任务失败信息。
106
+ """
107
+ if not workdir or not os.path.isdir(workdir):
108
+ return ""
109
+ git_dir = os.path.join(workdir, ".git")
110
+ if not os.path.exists(git_dir):
111
+ return ""
112
+ try:
113
+ probe = subprocess.run(
114
+ ["git", "-C", workdir, "rev-parse", "--git-dir"],
115
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
116
+ text=True, timeout=5, check=False,
117
+ )
118
+ except (OSError, subprocess.SubprocessError):
119
+ return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
120
+ if probe.returncode != 0:
121
+ detail = (probe.stderr or probe.stdout or "仓库元数据不可读").strip()
122
+ return "Git 仓库不可读:%s;请先执行 git fsck/恢复 .git 对象后重试" % detail[-300:]
123
+ try:
124
+ probe = subprocess.run(
125
+ ["git", "-C", workdir, "status", "--porcelain", "--untracked-files=no"],
126
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
127
+ text=True, timeout=8, check=False,
128
+ )
129
+ except subprocess.TimeoutExpired:
130
+ return "Git 仓库检查超时;请先执行 git status/git fsck 修复后重试"
131
+ except OSError:
132
+ return "Git 命令不可用,无法读取仓库;请安装 Git 或改用非 Aider 实现器"
133
+ if probe.returncode != 0:
134
+ detail = (probe.stderr or probe.stdout or "仓库对象或索引不可读").strip()
135
+ return "Git 仓库损坏或对象缺失:%s;请先执行 git fsck 并恢复仓库后重试" % detail[-300:]
136
+ # git status 对「pack 缺 .idx」不敏感(只 warning 跳过该包),必须单独查:
137
+ # aider 的 gitdb 按 pack-*.pack 逐个开同名 .idx,缺一个就整步失败。
138
+ orphans = _orphan_pack_files(workdir)
139
+ if orphans:
140
+ return ("Git 仓库 pack 索引缺失:%s 没有同名 .idx(git 本体只警告跳过,"
141
+ "Aider 的 gitdb 会直接报「Unable to list files in git repo」)。"
142
+ "修复:在 %s 目录对每个 pack 执行 `git index-pack <pack文件>` "
143
+ "重建索引,或临时改用 Codex/Claude 实现器。"
144
+ % (", ".join(orphans[:3]), os.path.join(".git", "objects", "pack")))
145
+ return ""
107
146
 
108
147
 
109
148
  def _npm_shim_bypass(argv):
@@ -115,8 +154,8 @@ def _npm_shim_bypass(argv):
115
154
  and str(argv[2]).lower().endswith((".cmd", ".bat")):
116
155
  shim = argv[2]
117
156
  try:
118
- with open(shim, encoding="utf-8", errors="replace") as fh:
119
- text = fh.read()
157
+ with open(shim, encoding="utf-8", errors="replace") as fh:
158
+ text = fh.read()
120
159
  except Exception:
121
160
  return argv
122
161
  m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
@@ -187,20 +226,32 @@ def _pipe_reader(stream, chunks, log_fh, stamp=None):
187
226
  在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
188
227
  read1() 只要有数据就返回,日志才能真正边跑边看。
189
228
  stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
229
+ 关闭也归本线程:EOF(或杀树后的异常)后自关。主线程绝不能 close()——
230
+ close 要抢 BufferedReader 的锁,而锁被还在 read1 等 EOF 的本线程攥着,
231
+ 孙进程漏杀时它攥着管道到自然死,主线程就陪锁到天荒地老(2026-09-22
232
+ run_command 超时杀 ping 实测:taskkill /T 失手 → close 卡满命令时长)。
190
233
  """
191
- while True:
192
- b = stream.read1(65536)
193
- if not b:
194
- break
195
- chunks.append(b)
196
- if stamp is not None:
197
- stamp[0] = time.time()
198
- if log_fh:
199
- try:
200
- log_fh.write(b)
201
- log_fh.flush()
202
- except Exception:
203
- pass
234
+ try:
235
+ while True:
236
+ b = stream.read1(65536)
237
+ if not b:
238
+ break
239
+ chunks.append(b)
240
+ if stamp is not None:
241
+ stamp[0] = time.time()
242
+ if log_fh:
243
+ try:
244
+ log_fh.write(b)
245
+ log_fh.flush()
246
+ except Exception:
247
+ pass
248
+ except Exception:
249
+ pass # 杀树瞬间句柄作废:ValueError/OSError 都算正常收场
250
+ finally:
251
+ try:
252
+ stream.close()
253
+ except Exception:
254
+ pass
204
255
 
205
256
 
206
257
  def _decode_line(blob):
@@ -400,8 +451,21 @@ def pretty_cli_log(text, max_event_chars=4000):
400
451
  --json 模式下 stdout 全是机器事件(thread/turn/item…),智能体真正在说的
401
452
  话被埋在转义 JSON 里;这里逐行翻译成【消息】【思考】【命令】,让日志抽屉
402
453
  读到的是「蜂在干什么」。解析失败或未识别的事件类型原样保留,不吞内容。
454
+
455
+ claude stream-json 同场翻译(2026-09-22 真实案:3.2MB 步骤日志 15828 条
456
+ thinking_tokens 心跳,每 SSE 分片一条、数值只涨 1~2):思考心跳按邻近组
457
+ 折成一行;init/api_retry/assistant/result 翻译成可读行——api_retry 是
458
+ 429/断连诊断金矿,逐条保留不折叠。只影响抽屉显示,原始日志与解析不动。
403
459
  """
404
460
  out = []
461
+ hb = [0, 0] # claude 思考心跳邻近组:[条数, 最大估算 tokens]
462
+ msg_seen = [False] # 是否已出过【消息】(防 result 再重复一遍正文)
463
+
464
+ def _flush_thinking():
465
+ if hb[0]:
466
+ out.append("— 思考中(心跳 ×%d 已折叠,估算 ~%d tokens)—" % (hb[0], hb[1]))
467
+ hb[0] = hb[1] = 0
468
+
405
469
  for ln in (text or "").splitlines():
406
470
  s = ln.strip()
407
471
  ev = None
@@ -410,10 +474,68 @@ def pretty_cli_log(text, max_event_chars=4000):
410
474
  ev = json.loads(s)
411
475
  except Exception:
412
476
  ev = None
477
+ if isinstance(ev, dict) and ev.get("type") == "system" \
478
+ and (ev.get("subtype") or "") == "thinking_tokens":
479
+ try:
480
+ n = int(ev.get("estimated_tokens") or 0)
481
+ except Exception:
482
+ n = 0
483
+ hb[0] += 1
484
+ if n > hb[1]:
485
+ hb[1] = n
486
+ continue
487
+ _flush_thinking()
413
488
  if not isinstance(ev, dict):
414
489
  out.append(ln)
415
490
  continue
416
491
  typ = ev.get("type") or ""
492
+ if typ == "system": # claude 其余系统事件
493
+ sub = ev.get("subtype") or ""
494
+ if sub == "init":
495
+ out.append("— 会话启动(model=%s)—" % (ev.get("model") or "?"))
496
+ elif sub == "api_retry":
497
+ out.append("— API 重试 %s/%s(等 %sms):%s —" % (
498
+ ev.get("attempt", "?"), ev.get("max_retries", "?"),
499
+ ev.get("retry_delay_ms", "?"), ev.get("error") or "?"))
500
+ else:
501
+ out.append(ln) # 未知 subtype 原样保留,不吞内容
502
+ continue
503
+ if typ == "assistant": # claude 正文/思考/工具调用都在 content 块里
504
+ for blk in ((ev.get("message") or {}).get("content") or []):
505
+ if not isinstance(blk, dict):
506
+ continue
507
+ bt = blk.get("type") or ""
508
+ if bt == "thinking":
509
+ th = " ".join(str(blk.get("thinking") or "").split())
510
+ if th:
511
+ out.append("【思考】" + th[:200])
512
+ elif bt == "text":
513
+ tx = str(blk.get("text") or "")
514
+ if tx:
515
+ out.append("【消息】" + tx[:max_event_chars])
516
+ msg_seen[0] = True
517
+ elif bt == "tool_use":
518
+ out.append("【命令】%s %s" % (
519
+ blk.get("name") or "?",
520
+ json.dumps(blk.get("input") or "", ensure_ascii=False)[:200]))
521
+ continue
522
+ if typ in ("user", "stream_event"):
523
+ continue # claude 工具回包/流片段是过程噪音
524
+ if typ == "result": # claude 收尾:统计一行;报错带原因
525
+ u = ev.get("usage") or {}
526
+ if ev.get("is_error"):
527
+ out.append("— 完成(报错):" + str(ev.get("result") or "")[:300] + " —")
528
+ else:
529
+ cost = ev.get("total_cost_usd")
530
+ if not msg_seen[0]:
531
+ tx = str(ev.get("result") or "")
532
+ if tx: # 旧单 JSON 模式没有 assistant 事件,正文在这补上
533
+ out.append("【消息】" + tx[:max_event_chars])
534
+ msg_seen[0] = True
535
+ out.append("— 完成(tokens 入 %s / 出 %s,%s)—" % (
536
+ u.get("input_tokens", "?"), u.get("output_tokens", "?"),
537
+ ("$%.2f" % cost) if isinstance(cost, (int, float)) else "?"))
538
+ continue
417
539
  if typ in ("thread.started", "turn.started"):
418
540
  continue
419
541
  if typ == "turn.completed":
@@ -436,18 +558,18 @@ def pretty_cli_log(text, max_event_chars=4000):
436
558
  return "\n".join(out)
437
559
 
438
560
 
439
- def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
440
- timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
441
- stall_timeout=0, repeat_abort=None):
561
+ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
562
+ timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
563
+ stall_timeout=0, repeat_abort=None):
442
564
  """通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
443
565
 
444
566
  stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
445
567
  新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
446
- 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
447
- 开了会把正常长任务误杀。
448
-
449
- repeat_abort:可选 (marker, count)。同一致命错误达到次数即提前杀树,处理
450
- CLI 自身不断打印重连消息、因此永远触发不了静默看门狗的假运行。
568
+ 持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
569
+ 开了会把正常长任务误杀。
570
+
571
+ repeat_abort:可选 (marker, count)。同一致命错误达到次数即提前杀树,处理
572
+ CLI 自身不断打印重连消息、因此永远触发不了静默看门狗的假运行。
451
573
 
452
574
  返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
453
575
  """
@@ -510,7 +632,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
510
632
  pass
511
633
  threading.Thread(target=_feed, daemon=True).start()
512
634
  start = time.time()
513
- cancelled = timed_out = stalled = repeat_aborted = False
635
+ cancelled = timed_out = stalled = repeat_aborted = False
514
636
  while True:
515
637
  try:
516
638
  proc.wait(timeout=0.4)
@@ -527,44 +649,46 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
527
649
  _kill_tree(proc.pid)
528
650
  _drain_streams(proc, t_out, t_err, timeout=5)
529
651
  break
530
- if stall_timeout and time.time() - stamp[0] > stall_timeout:
652
+ if stall_timeout and time.time() - stamp[0] > stall_timeout:
531
653
  # 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
532
654
  # 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
533
655
  stalled = True
534
656
  timed_out = True
535
657
  _kill_tree(proc.pid)
536
- _drain_streams(proc, t_out, t_err, timeout=5)
537
- break
538
- if repeat_abort:
539
- try:
540
- marker, limit = repeat_abort
541
- recent = decode_output(b"".join(out_chunks)[-8000:] +
542
- b"\n" + b"".join(err_chunks)[-8000:])
543
- if marker and recent.count(str(marker)) >= int(limit):
544
- repeat_aborted = True
545
- timed_out = True
546
- _kill_tree(proc.pid)
547
- _drain_streams(proc, t_out, t_err, timeout=5)
548
- break
549
- except (TypeError, ValueError):
550
- pass
658
+ _drain_streams(proc, t_out, t_err, timeout=5)
659
+ break
660
+ if repeat_abort:
661
+ try:
662
+ marker, limit = repeat_abort
663
+ recent = decode_output(b"".join(out_chunks)[-8000:] +
664
+ b"\n" + b"".join(err_chunks)[-8000:])
665
+ if marker and recent.count(str(marker)) >= int(limit):
666
+ repeat_aborted = True
667
+ timed_out = True
668
+ _kill_tree(proc.pid)
669
+ _drain_streams(proc, t_out, t_err, timeout=5)
670
+ break
671
+ except (TypeError, ValueError):
672
+ pass
551
673
  duration = round(time.time() - start, 1)
552
674
  t_out.join(timeout=5)
553
675
  t_err.join(timeout=5)
554
676
  exit_code = proc.returncode
555
677
  stdout = decode_output(b"".join(out_chunks))
556
678
  stderr = decode_output(b"".join(err_chunks))
557
- for stream in (proc.stdin, proc.stdout, proc.stderr):
558
- try:
559
- if stream:
560
- stream.close()
561
- except Exception:
562
- pass
563
- if cancelled:
564
- stderr += "\n[已被用户取消]"
565
- elif repeat_aborted:
566
- stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % repeat_abort[1]
567
- elif stalled:
679
+ # stdout/stderr 的 close 已归 _pipe_reader 自线程(EOF 后自关):这里再
680
+ # close 会抢 read1 手里的锁,孙进程漏杀攥着管道时就死等(见其 docstring)。
681
+ # stdin 无读线程,_feed 写完自关;这里兜底一次(DEVNULL 时本就无事)。
682
+ try:
683
+ if proc.stdin:
684
+ proc.stdin.close()
685
+ except Exception:
686
+ pass
687
+ if cancelled:
688
+ stderr += "\n[已被用户取消]"
689
+ elif repeat_aborted:
690
+ stderr += "\n[同一网络错误重复 %s 次,已提前终止进程树]" % repeat_abort[1]
691
+ elif stalled:
568
692
  stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
569
693
  elif timed_out:
570
694
  stderr += "\n[超时 %ss,已终止进程树]" % timeout
@@ -739,7 +863,12 @@ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
739
863
  # 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
740
864
  # 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
741
865
  # 就可能活」的瞬态病,旧表判成终态导致整链早死
742
- "stream disconnected", "unexpected server error")
866
+ "stream disconnected", "unexpected server error",
867
+ # 2026-09-22 mo-so 实案:claude 报 "Can't reach the API server —
868
+ # check your internet or DNS (ENOTFOUND)",一家端点解析不到不代表
869
+ # 跨厂商链上别家也不通,旧表判成终态直接跳去换将别的 CLI
870
+ "enotfound", "getaddrinfo", "name or service not known",
871
+ "name resolution")
743
872
 
744
873
 
745
874
  def _transient_error(err):
@@ -747,10 +876,12 @@ def _transient_error(err):
747
876
  return any(k in err for k in _TRANSIENT)
748
877
 
749
878
 
750
- # 欠费/配额耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
879
+ # 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
751
880
  # 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
752
881
  _QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
753
- "payment required", "402", "欠费", "余额", "额度", "exceeded")
882
+ "payment required", "402", "欠费", "余额", "额度", "exceeded",
883
+ "too many requests", "rate limit", "rate_limit", "http 429",
884
+ "并发", "超过限")
754
885
 
755
886
 
756
887
  def _quota_error(err):
@@ -758,6 +889,37 @@ def _quota_error(err):
758
889
  return any(k in err for k in _QUOTA)
759
890
 
760
891
 
892
+ # 限流(429)专项:与一般瞬态不同,它是「等一个窗口就常能自愈」的病——
893
+ # 2026-09-22 四连败实测:claude ENOTFOUND 换将 codex 后仍与同一上游撞 429,
894
+ # 链上无第三条路时整步立刻判死;而限流窗口通常按分钟计,原地等一个窗口
895
+ # 再试一次,好过把整轮 run 直接烧成 failed(欠费不同源,不适用宽限)。
896
+ _RATE_LIMIT = ("429", "rate limit", "too many requests", "限流")
897
+ RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟计,等一个再试
898
+ RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
899
+
900
+
901
+ def _rate_limited(err):
902
+ err = (err or "").lower()
903
+ return any(k in err for k in _RATE_LIMIT)
904
+
905
+
906
+ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
907
+ """链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
908
+ if log_path:
909
+ try:
910
+ with open(log_path, "a", encoding="utf-8") as fh:
911
+ fh.write("\n[限流宽限] %s:等待 %ds 后原地重试一次\n"
912
+ % (why or "上游限流(429)", seconds))
913
+ except Exception:
914
+ pass
915
+ end = time.time() + seconds
916
+ while time.time() < end:
917
+ if cancel_event is not None and cancel_event.is_set():
918
+ return False
919
+ time.sleep(min(3, max(0.5, end - time.time())))
920
+ return cancel_event is None or not cancel_event.is_set()
921
+
922
+
761
923
  def _report_key(att, out):
762
924
  """把这次尝试的结果回写到 KEY 账本:欠费/失败 → 冷却,成功 → 清错误。
763
925
 
@@ -825,10 +987,10 @@ def _resolve_attempts(agent):
825
987
  """
826
988
  chain = agent.get("call_chain") or []
827
989
  if chain:
828
- return [{"model": (e.get("model") or "").strip() or None,
829
- "env": dict(e.get("env") or {}),
830
- "provider": e.get("provider") or {},
831
- "from_chain": True,
990
+ return [{"model": (e.get("model") or "").strip() or None,
991
+ "env": dict(e.get("env") or {}),
992
+ "provider": e.get("provider") or {},
993
+ "from_chain": True,
832
994
  "own_cp": "codex_provider" in e,
833
995
  "codex_provider": e.get("codex_provider"),
834
996
  "provider_id": e.get("provider_id") or "",
@@ -836,12 +998,12 @@ def _resolve_attempts(agent):
836
998
  base_model = agent.get("model")
837
999
  fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
838
1000
  models_to_try = ([base_model] if base_model else []) + fb
839
- provider = agent.get("provider") or {}
840
- return [{"model": m or None, "env": {}, "provider": provider,
841
- "from_chain": False, "own_cp": False,
842
- "codex_provider": None,
843
- "provider_id": provider.get("id") if isinstance(provider, dict) else "",
844
- "key_id": ""}
1001
+ provider = agent.get("provider") or {}
1002
+ return [{"model": m or None, "env": {}, "provider": provider,
1003
+ "from_chain": False, "own_cp": False,
1004
+ "codex_provider": None,
1005
+ "provider_id": provider.get("id") if isinstance(provider, dict) else "",
1006
+ "key_id": ""}
845
1007
  for m in (models_to_try or [None])[:3]]
846
1008
 
847
1009
 
@@ -883,9 +1045,9 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
883
1045
  stdin_text = None
884
1046
  tmp_files = []
885
1047
  imgs = [str(p) for p in (images or []) if p]
886
- if kind == "codex":
887
- cp = agent.get("codex_provider")
888
- reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
1048
+ if kind == "codex":
1049
+ cp = agent.get("codex_provider")
1050
+ reasoning = str(agent.get("reasoning_effort") or "").strip().lower()
889
1051
  if sid:
890
1052
  # resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
891
1053
  # "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
@@ -895,18 +1057,18 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
895
1057
  if model:
896
1058
  argv += ["-m", model]
897
1059
  argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
898
- if cp:
899
- argv += _codex_provider_args(cp)
1060
+ if cp:
1061
+ argv += _codex_provider_args(cp)
900
1062
  else:
901
1063
  argv = resolve_command(agent["command"]) + [
902
1064
  "exec", "--skip-git-repo-check", "--json",
903
1065
  "-s", _codex_sandbox(readonly)]
904
1066
  if model:
905
1067
  argv += ["-m", model]
906
- if cp:
907
- argv += _codex_provider_args(cp)
908
- if reasoning in ("low", "medium", "high", "xhigh"):
909
- argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
1068
+ if cp:
1069
+ argv += _codex_provider_args(cp)
1070
+ if reasoning in ("low", "medium", "high", "xhigh"):
1071
+ argv += ["-c", 'model_reasoning_effort="%s"' % reasoning]
910
1072
  # codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
911
1073
  # -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
912
1074
  for p in imgs:
@@ -932,6 +1094,21 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
932
1094
  # 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
933
1095
  # 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
934
1096
  argv += ["--dangerously-skip-permissions"]
1097
+ # 绑定链 env 落一次性 --settings:claude CLI 里用户 settings.json 的 env
1098
+ # 会覆盖进程 env(2026-09-22 双监听器实测),光靠 run_process 的 env 注入,
1099
+ # 链首供应商会被用户配置顶掉(a.test 毒配置案的放大器)。--settings 的
1100
+ # 优先级高于用户 settings.json(同日实测),临时文件进程结束随 tmp_files 删。
1101
+ ant = {k: v for k, v in (agent.get("env") or {}).items()
1102
+ if k.startswith("ANTHROPIC_")}
1103
+ if ant:
1104
+ try:
1105
+ fd, sp = tempfile.mkstemp(prefix="tutti_claude_settings_", suffix=".json")
1106
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1107
+ json.dump({"env": ant}, f)
1108
+ argv += ["--settings", sp]
1109
+ tmp_files.append(sp)
1110
+ except Exception:
1111
+ pass # 落盘失败退回纯 env 注入(旧路径),不拦运行
935
1112
  stdin_text = prompt
936
1113
  elif kind == "opencode":
937
1114
  argv = resolve_command(agent["command"]) + ["run"]
@@ -954,11 +1131,17 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
954
1131
  if model:
955
1132
  argv += ["-m", model]
956
1133
  stdin_text = prompt
957
- elif kind == "aider":
958
- argv = resolve_command(agent["command"]) + [
959
- "--yes-always", "--no-auto-commits", "--no-check-update",
960
- "--no-gitignore", "--no-pretty", "--no-stream",
961
- # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
1134
+ elif kind == "aider":
1135
+ argv = resolve_command(agent["command"]) + [
1136
+ "--yes-always", "--no-auto-commits", "--no-check-update",
1137
+ "--no-gitignore", "--no-pretty", "--no-stream",
1138
+ # 无头执行没有 Windows 控制台,aider 建 PromptSession 抛错并打一行
1139
+ # "Can't initialize prompt toolkit: No Windows console found..."
1140
+ # (非致命,但它排在输出最前,UI 失败摘要只取头部 → 真错误被顶掉,
1141
+ # 2026-09-22 mo-so 实案整条失败原因显示成这句噪声)。关掉花式输入
1142
+ # 就不再建 PromptSession,噪声从源头消失。
1143
+ "--no-fancy-input",
1144
+ # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
962
1145
  # 纯属刷屏(2026-09-20 实测占满步骤日志头部)
963
1146
  "--no-show-model-warnings", "--message", prompt]
964
1147
  if model:
@@ -1030,7 +1213,7 @@ def _stall_timeout(env_name, default):
1030
1213
  return default
1031
1214
 
1032
1215
 
1033
- def run_agent(agent, prompt, workdir=None, readonly=True,
1216
+ def run_agent(agent, prompt, workdir=None, readonly=True,
1034
1217
  timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
1035
1218
  images=None, require_tools=False):
1036
1219
  """执行一次智能体调用,返回统一结构
@@ -1053,14 +1236,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1053
1236
  orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
1054
1237
  if orch_timeout_ms:
1055
1238
  timeout = float(orch_timeout_ms) / 1000.0
1056
- kind = agent.get("kind", "generic")
1057
- if kind == "aider":
1058
- repo_issue = _git_repo_issue(workdir)
1059
- if repo_issue:
1060
- return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1061
- "tokens": 0, "usage": None, "error": repo_issue,
1062
- "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1063
- "kind": kind, "model": agent.get("model")}
1239
+ kind = agent.get("kind", "generic")
1240
+ if kind == "aider":
1241
+ repo_issue = _git_repo_issue(workdir)
1242
+ if repo_issue:
1243
+ return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1244
+ "tokens": 0, "usage": None, "error": repo_issue,
1245
+ "error_code": ErrorCode.VENDOR_ERROR, "raw": None,
1246
+ "kind": kind, "model": agent.get("model")}
1064
1247
  if kind == "codex":
1065
1248
  stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
1066
1249
  elif kind == "claude":
@@ -1097,6 +1280,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1097
1280
 
1098
1281
  attempts = _resolve_attempts(agent)
1099
1282
  out = None
1283
+ grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1100
1284
  for ai, att in enumerate(attempts):
1101
1285
  env = dict(base_env)
1102
1286
  env.update(att["env"])
@@ -1118,12 +1302,12 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1118
1302
  att["model"], prompt,
1119
1303
  images=images if kind == "codex" else None,
1120
1304
  workdir=workdir)
1121
- try:
1122
- repeat_guard = (("Reconnecting... waiting for network", 5)
1123
- if kind == "codex" else None)
1124
- res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1125
- timeout=timeout, cancel_event=cancel_event, log_path=log_path,
1126
- stall_timeout=stall_t, repeat_abort=repeat_guard)
1305
+ try:
1306
+ repeat_guard = (("Reconnecting... waiting for network", 5)
1307
+ if kind == "codex" else None)
1308
+ res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1309
+ timeout=timeout, cancel_event=cancel_event, log_path=log_path,
1310
+ stall_timeout=stall_t, repeat_abort=repeat_guard)
1127
1311
  finally:
1128
1312
  # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1129
1313
  for tf in tmp_files:
@@ -1131,11 +1315,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1131
1315
  os.remove(tf)
1132
1316
  except OSError:
1133
1317
  pass
1134
- out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1135
- "tokens": 0, "usage": None, "error": "", "error_code": "",
1136
- "sid": "", "raw": res, "kind": kind, "model": att["model"],
1137
- "provider_id": att.get("provider_id") or "",
1138
- "provider": att.get("provider") or {}}
1318
+ out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1319
+ "tokens": 0, "usage": None, "error": "", "error_code": "",
1320
+ "sid": "", "raw": res, "kind": kind, "model": att["model"],
1321
+ "provider_id": att.get("provider_id") or "",
1322
+ "provider": att.get("provider") or {}}
1139
1323
  if not res["ok"]:
1140
1324
  # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
1141
1325
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
@@ -1159,11 +1343,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1159
1343
  parsed_err = _parse_claude_json(res["stdout"] or "")
1160
1344
  if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
1161
1345
  out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
1162
- if kind == "codex" and att.get("provider_id"):
1163
- el = out["error"].lower()
1164
- if (("wire_api" in el and "no longer supported" in el)
1165
- or "no route matched" in el
1166
- or "同一网络错误重复" in out["error"]):
1346
+ if kind == "codex" and att.get("provider_id"):
1347
+ el = out["error"].lower()
1348
+ if (("wire_api" in el and "no longer supported" in el)
1349
+ or "no route matched" in el
1350
+ or "同一网络错误重复" in out["error"]):
1167
1351
  # codex 撞 chat-only 供应商(0.154 只讲 responses):
1168
1352
  # 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
1169
1353
  # 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
@@ -1244,6 +1428,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1244
1428
  break
1245
1429
  _report_key(att, out)
1246
1430
  if out["ok"] or ai == len(attempts) - 1:
1431
+ if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
1432
+ and not (cancel_event is not None and cancel_event.is_set())):
1433
+ # 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
1434
+ # 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
1435
+ grace_left -= 1
1436
+ if _grace_wait(cancel_event, RATE_LIMIT_GRACE_S, log_path=log_path):
1437
+ attempts.append(att)
1438
+ continue
1247
1439
  return out
1248
1440
  # 瞬态网络错误 → 换下一条;欠费/配额耗尽同样换(可能是同厂商的备用 KEY,
1249
1441
  # 也可能是另一家厂商)——账单断了死磕同一把 KEY 没有任何意义。