@seanyao/roll 2026.601.2 → 2026.601.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/CHANGELOG.md +19 -0
- package/bin/roll +113 -16
- package/lib/agent_usage/kimi.py +163 -12
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/i18n/status.sh +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2026.601.4
|
|
4
|
+
|
|
5
|
+
### 稳定性
|
|
6
|
+
|
|
7
|
+
- **从 `.roll/` 子目录跑 `roll` 不再误报"老结构"(FIX-156)** — `.roll/` 是嵌套 roll-meta git repo,从那里跑 `roll loop status` 等命令时 `git rev-parse` 会返回 `.roll/` 自己当 root,然后旧的"新结构存在?"检查找 `.roll/.roll` 找不到,又看到 `.roll/` 里满地 Roll 内容,就误报"老结构、要 migrate";现在检查会向上找直到 `.roll/` 的父目录,认出"这就是该 Roll 项目的私有过程仓",不再误报 `[loop]`
|
|
8
|
+
- **peer review 死等终结(FIX-150c)** — 此前 peer 调用挂了就是无限挂,等不到 verdict cycle 就跟着挂;现在 wall-clock 超 `peer_call_timeout`(默认 3min)直接 SIGTERM/SIGKILL 干掉跑挂的 agent,tmux 内的也送 Ctrl-C 中断,设全局标志 `_PEER_LAST_TIMED_OUT` 让上层落 ledger 用,绝不无限等 `[loop]`
|
|
9
|
+
|
|
10
|
+
## v2026.601.3
|
|
11
|
+
|
|
12
|
+
### 可见性
|
|
13
|
+
|
|
14
|
+
- **kimi cycle 现在也能看到 token 和成本(FIX-154)** — 以前 dashboard 对 kimi 那一行全是 `—/—`,看不到主力 agent 花了多少钱;现在 cycle 跑完读 kimi-code 的 `wire.jsonl`,把 token 数和成本写进事件流,RECENT 视图和成本总闸都看得见 `[loop]`
|
|
15
|
+
|
|
16
|
+
### 稳定性
|
|
17
|
+
|
|
18
|
+
- **loop 把活派给 AI 后现在真会动手,不再空转零产出(FIX-152)** — kimi 等对话式 agent 拿到 SKILL.md 会把它当成"贴过来的文档"反问"What would you like me to do?",8 秒空返没交付;技能正文前置一条 agent 无关的自主执行指令,kimi/claude/pi/codex/agy 现在都会直接动手 `[loop]`
|
|
19
|
+
- **agy 在 loop / cron 自动化里不再卡 tty 等待(FIX-153)** — antigravity(agy)默认要 tty 批准操作,自动化场景拿不到 tty 就一直挂着等;现在 headless 模式自动加 `-p` 和跳过权限标记,跑得到结果 `[loop]`
|
|
20
|
+
- **测试不再在桌面弹空报错终端(FIX-155)** — bats 测试跑完临时沙箱删了,但 peer auto-attach 弹的 Terminal 窗口指向那个已不存在的路径,桌面堆一堆空报错的死窗口;给 peer 弹窗补上和 loop 弹窗一样的测试守卫,测试上下文不再弹 `[loop]`
|
|
21
|
+
|
|
3
22
|
## v2026.601.2
|
|
4
23
|
|
|
5
24
|
### 新功能
|
package/bin/roll
CHANGED
|
@@ -4,7 +4,7 @@ set -euo pipefail
|
|
|
4
4
|
# Roll — AI Agent Convention Manager
|
|
5
5
|
# Single source of truth for how all AI coding agents behave.
|
|
6
6
|
|
|
7
|
-
VERSION="2026.601.
|
|
7
|
+
VERSION="2026.601.4"
|
|
8
8
|
ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
|
|
9
9
|
ROLL_CONFIG="${ROLL_HOME}/config.yaml"
|
|
10
10
|
ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
|
|
@@ -3841,6 +3841,8 @@ _peer_route() {
|
|
|
3841
3841
|
_peer_auto_attach() {
|
|
3842
3842
|
local session="$1"
|
|
3843
3843
|
[ "$(uname)" = "Darwin" ] || return 0
|
|
3844
|
+
[ -n "${BATS_TEST_NUMBER:-}" ] && return 0
|
|
3845
|
+
[ -n "${ROLL_LOOP_NO_POPUP:-}" ] && return 0
|
|
3844
3846
|
[ -f "$_LOOP_MUTE_FILE" ] && return 0
|
|
3845
3847
|
local attach_cmd="${_SHARED_ROOT}/loop/attach-${session}.command"
|
|
3846
3848
|
# Drop `exec` so the wrapping shell survives `tmux attach` exiting; pause
|
|
@@ -3877,7 +3879,18 @@ _peer_dispatch_in_tmux() {
|
|
|
3877
3879
|
sleep 1
|
|
3878
3880
|
elapsed=$((elapsed + 1))
|
|
3879
3881
|
done
|
|
3882
|
+
# FIX-150c: if we hit the wall clock without the done marker, the agent
|
|
3883
|
+
# is still running inside the tmux session. Send Ctrl-C to interrupt it
|
|
3884
|
+
# so the cycle doesn't hang on a peer that's no longer being awaited.
|
|
3885
|
+
# Return 1 (timed out) vs 0 (completed within budget); _peer_call lifts
|
|
3886
|
+
# this into the global _PEER_LAST_TIMED_OUT flag.
|
|
3887
|
+
local _timed_out=0
|
|
3888
|
+
if [[ ! -f "$done_file" ]]; then
|
|
3889
|
+
tmux send-keys -t "${session}:0" C-c 2>/dev/null || true
|
|
3890
|
+
_timed_out=1
|
|
3891
|
+
fi
|
|
3880
3892
|
rm -f "$done_file"
|
|
3893
|
+
return "$_timed_out"
|
|
3881
3894
|
}
|
|
3882
3895
|
|
|
3883
3896
|
_peer_call() {
|
|
@@ -3890,6 +3903,11 @@ _peer_call() {
|
|
|
3890
3903
|
local call_timeout
|
|
3891
3904
|
call_timeout="$(config_get "peer_call_timeout" "180")"
|
|
3892
3905
|
|
|
3906
|
+
# FIX-150c: signal back to caller whether this call hit the wall-clock
|
|
3907
|
+
# limit. Caller (_peer_*_state, ledger writer) records "timeout" verdict.
|
|
3908
|
+
# Reset per call so callers reading the previous result don't mis-attribute.
|
|
3909
|
+
_PEER_LAST_TIMED_OUT=0
|
|
3910
|
+
|
|
3893
3911
|
info "$(msg status.peer_call_timeout_s_peer ${call_timeout})"
|
|
3894
3912
|
|
|
3895
3913
|
if [[ -n "$session" ]] && command -v tmux >/dev/null 2>&1 && tmux has-session -t "$session" 2>/dev/null; then
|
|
@@ -3901,14 +3919,50 @@ _peer_call() {
|
|
|
3901
3919
|
return 1
|
|
3902
3920
|
}
|
|
3903
3921
|
_peer_dispatch_in_tmux "$session" "$cmd_str" "$out_file" "$stderr_log" "$call_timeout"
|
|
3922
|
+
local _dispatch_rc=$?
|
|
3904
3923
|
output="$(cat "$out_file" 2>/dev/null || true)"
|
|
3905
3924
|
rm -f "$out_file"
|
|
3925
|
+
if [[ "$_dispatch_rc" -ne 0 ]]; then
|
|
3926
|
+
_PEER_LAST_TIMED_OUT=1
|
|
3927
|
+
warn "$(msg status.peer_call_timeout_killed "$to" "$call_timeout")"
|
|
3928
|
+
fi
|
|
3906
3929
|
else
|
|
3907
3930
|
_agent_argv "$to" peer "$prompt" || {
|
|
3908
3931
|
err "$(msg status.unsupported_peer_2 $to)"
|
|
3909
3932
|
return 1
|
|
3910
3933
|
}
|
|
3911
|
-
|
|
3934
|
+
# FIX-150c: hard timeout for non-tmux path. macOS has no `timeout`(1),
|
|
3935
|
+
# so use a background watchdog that SIGTERMs (then SIGKILLs after 2 s
|
|
3936
|
+
# grace) the agent process when it overruns. Output captured via tmp
|
|
3937
|
+
# file because we can't keep it in $output while juggling pids.
|
|
3938
|
+
# `wait` returns the agent's exit code; with `set -e` enabled by the
|
|
3939
|
+
# caller (loops, hooks, bats), a non-zero from the killed agent would
|
|
3940
|
+
# short-circuit out before we can read it — `|| _peer_exit=$?` keeps
|
|
3941
|
+
# the value flowing into the timeout check.
|
|
3942
|
+
local _out _peer_exit=0 _peer_pid _watchdog_pid
|
|
3943
|
+
_out=$(mktemp)
|
|
3944
|
+
"${_AGENT_ARGV[@]}" >"$_out" 2>"$stderr_log" &
|
|
3945
|
+
_peer_pid=$!
|
|
3946
|
+
(
|
|
3947
|
+
sleep "$call_timeout"
|
|
3948
|
+
kill -TERM "$_peer_pid" 2>/dev/null && {
|
|
3949
|
+
sleep 2
|
|
3950
|
+
kill -KILL "$_peer_pid" 2>/dev/null
|
|
3951
|
+
}
|
|
3952
|
+
) &
|
|
3953
|
+
_watchdog_pid=$!
|
|
3954
|
+
wait "$_peer_pid" 2>/dev/null || _peer_exit=$?
|
|
3955
|
+
# Cancel watchdog if agent finished on time.
|
|
3956
|
+
kill "$_watchdog_pid" 2>/dev/null || true
|
|
3957
|
+
wait "$_watchdog_pid" 2>/dev/null || true
|
|
3958
|
+
output="$(cat "$_out" 2>/dev/null || true)"
|
|
3959
|
+
rm -f "$_out"
|
|
3960
|
+
# SIGTERM kill → 143, SIGKILL → 137. Either means we tripped the
|
|
3961
|
+
# timeout watchdog (agent itself doesn't normally exit with those).
|
|
3962
|
+
if [[ "$_peer_exit" -eq 143 || "$_peer_exit" -eq 137 ]]; then
|
|
3963
|
+
_PEER_LAST_TIMED_OUT=1
|
|
3964
|
+
warn "$(msg status.peer_call_timeout_killed "$to" "$call_timeout")"
|
|
3965
|
+
fi
|
|
3912
3966
|
fi
|
|
3913
3967
|
|
|
3914
3968
|
printf '%s\n' "$output"
|
|
@@ -4411,10 +4465,12 @@ _agent_argv() {
|
|
|
4411
4465
|
# late 2025. agy reuses ~/.gemini/ for config and reads GEMINI.md
|
|
4412
4466
|
# natively, so the convention sync target is unchanged — only the
|
|
4413
4467
|
# invoked binary changes.
|
|
4468
|
+
# FIX-153: non-interactive modes must use -p (headless) +
|
|
4469
|
+
# --dangerously-skip-permissions so the agent does not hang waiting
|
|
4470
|
+
# for a tty approval that never comes in loop/cron contexts.
|
|
4414
4471
|
case "$mode" in
|
|
4415
4472
|
interactive) _AGENT_ARGV=(agy -i "$prompt") ;;
|
|
4416
|
-
|
|
4417
|
-
*) _AGENT_ARGV=(agy "$prompt") ;;
|
|
4473
|
+
*) _AGENT_ARGV=(agy -p --dangerously-skip-permissions "$prompt") ;;
|
|
4418
4474
|
esac ;;
|
|
4419
4475
|
qwen)
|
|
4420
4476
|
# qwen has the same argv shape in both modes (positional prompt).
|
|
@@ -8162,7 +8218,11 @@ _write_loop_runner_script() {
|
|
|
8162
8218
|
# US-LOOP-026: post-cycle single-shot usage writer for non-claude agents.
|
|
8163
8219
|
# pi -p text mode prints no usage, so we recover it from pi's session jsonl
|
|
8164
8220
|
# exactly once per cycle (loop-fmt passthrough is display-only).
|
|
8221
|
+
# FIX-154: kimi-code's `-p` mode also writes nothing to stdout but persists
|
|
8222
|
+
# usage to wire.jsonl; kimi_emit covers that path. bin/roll dispatches by
|
|
8223
|
+
# agent (pi/deepseek → pi_emit, kimi → kimi_emit).
|
|
8165
8224
|
local pi_emit_script="${ROLL_PKG_DIR}/lib/agent_usage/pi_emit.py"
|
|
8225
|
+
local kimi_emit_script="${ROLL_PKG_DIR}/lib/agent_usage/kimi_emit.py"
|
|
8166
8226
|
local roll_bin="${ROLL_PKG_DIR}/bin/roll"
|
|
8167
8227
|
# US-EVAL-002: pure-function rubric scorer (US-EVAL-001). Baked in at
|
|
8168
8228
|
# generation time so the inner runner can compute result_eval at cycle finish.
|
|
@@ -8831,23 +8891,37 @@ else
|
|
|
8831
8891
|
_phase_end agent_invoke ok
|
|
8832
8892
|
fi
|
|
8833
8893
|
|
|
8834
|
-
# US-LOOP-026: non-claude agents (pi/deepseek/kimi) print no usage
|
|
8835
|
-
# mode. Recover token+cost once per cycle from the agent's session
|
|
8836
|
-
# append a single authoritative usage event. Done here (not in
|
|
8837
|
-
# per-attempt passthrough) so retries can't write N duplicate
|
|
8838
|
-
# dashboard's same-label SUM would inflate. Runs before the
|
|
8839
|
-
# so partial cycles still get whatever usage the session
|
|
8840
|
-
# path is resolved exactly like _loop_event (rt_dir
|
|
8841
|
-
#
|
|
8842
|
-
|
|
8894
|
+
# US-LOOP-026 + FIX-154: non-claude agents (pi/deepseek/kimi) print no usage
|
|
8895
|
+
# in -p text mode. Recover token+cost once per cycle from the agent's session
|
|
8896
|
+
# jsonl and append a single authoritative usage event. Done here (not in
|
|
8897
|
+
# loop-fmt's per-attempt passthrough) so retries can't write N duplicate
|
|
8898
|
+
# events that the dashboard's same-label SUM would inflate. Runs before the
|
|
8899
|
+
# timeout-abort exit so partial cycles still get whatever usage the session
|
|
8900
|
+
# recorded. The events path is resolved exactly like _loop_event (rt_dir
|
|
8901
|
+
# first, shared fallback) so the emitter appends to the same file the reader
|
|
8902
|
+
# consumes. Dispatch by agent so each emitter reads the right session format
|
|
8903
|
+
# (pi.usage_from_session vs kimi.usage_from_session).
|
|
8904
|
+
if [ "\$(_project_agent)" != "claude" ]; then
|
|
8843
8905
|
_pi_rt=\$(_loop_runtime_dir "${slug}" 2>/dev/null || echo "")
|
|
8844
8906
|
if [ -n "\$_pi_rt" ]; then
|
|
8845
8907
|
_pi_evfile="\${_pi_rt}/events.ndjson"
|
|
8846
8908
|
else
|
|
8847
8909
|
_pi_evfile="\${_SHARED_ROOT:-\$HOME/.shared/roll}/loop/events-${slug}.ndjson"
|
|
8848
8910
|
fi
|
|
8849
|
-
|
|
8850
|
-
|
|
8911
|
+
case "\$(_project_agent)" in
|
|
8912
|
+
kimi)
|
|
8913
|
+
if [ -f "${kimi_emit_script}" ]; then
|
|
8914
|
+
python3 "${kimi_emit_script}" --cwd "\$WT" --cycle "\${CYCLE_ID}" \\
|
|
8915
|
+
--slug "${slug}" --events "\$_pi_evfile" 2>/dev/null || true
|
|
8916
|
+
fi
|
|
8917
|
+
;;
|
|
8918
|
+
*)
|
|
8919
|
+
if [ -f "${pi_emit_script}" ]; then
|
|
8920
|
+
python3 "${pi_emit_script}" --cwd "\$WT" --cycle "\${CYCLE_ID}" \\
|
|
8921
|
+
--slug "${slug}" --events "\$_pi_evfile" 2>/dev/null || true
|
|
8922
|
+
fi
|
|
8923
|
+
;;
|
|
8924
|
+
esac
|
|
8851
8925
|
fi
|
|
8852
8926
|
|
|
8853
8927
|
# FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
|
|
@@ -9423,7 +9497,15 @@ _agent_skill_cmd() {
|
|
|
9423
9497
|
for ((i = 1; i < prompt_idx; i++)); do
|
|
9424
9498
|
out+=" ${_AGENT_ARGV[i]}"
|
|
9425
9499
|
done
|
|
9426
|
-
|
|
9500
|
+
# FIX-152: prepend an explicit autonomous-execution directive ahead of the bare
|
|
9501
|
+
# SKILL.md body. Without it, conversational `-p` agents (notably kimi-code) read
|
|
9502
|
+
# the skill doc as pasted context and reply "what would you like me to do?",
|
|
9503
|
+
# returning in seconds with zero output → the cycle ends idle, no delivery.
|
|
9504
|
+
# pi/deepseek/claude tolerate the bare doc, but the directive is agent-agnostic
|
|
9505
|
+
# and hardens every autonomous cron skill (loop/dream/brief share this chokepoint).
|
|
9506
|
+
# Must stay free of " $ ` \ so it survives the later `eval` of the cycle command.
|
|
9507
|
+
local _autorun='[roll 自主模式] 你正在无人值守的自动化循环中运行,这不是对话。请立即、完整地执行下面这份技能文档描述的工作流,直到完成交付或写出 ALERT 为止;严禁反问、严禁等待确认、严禁只复述或总结而不动手。技能文档如下: '
|
|
9508
|
+
echo "${out} \"${_autorun}\$(${strip})\""
|
|
9427
9509
|
}
|
|
9428
9510
|
|
|
9429
9511
|
# FIX-134: build the full per-cycle agent command at RUNTIME, routing-aware.
|
|
@@ -15023,6 +15105,21 @@ _check_structure() {
|
|
|
15023
15105
|
# If new structure exists, allow
|
|
15024
15106
|
[[ -d "$root/.roll" ]] && return 0
|
|
15025
15107
|
|
|
15108
|
+
# FIX-156: nested-repo escape — when cwd is inside `.roll/` (a Roll project's
|
|
15109
|
+
# nested private roll-meta worktree), git rev-parse returns `.roll/` itself
|
|
15110
|
+
# instead of the outer Roll project, so the check above misses the outer
|
|
15111
|
+
# `.roll/` and trips the legacy warning on the project's own roll-meta files
|
|
15112
|
+
# (which contain BACKLOG.md / etc by definition). Walk up from $root and
|
|
15113
|
+
# allow when any ancestor directory has a `.roll/` sibling — that is the
|
|
15114
|
+
# outer Roll project, and the user is operating on it from a sub-checkout.
|
|
15115
|
+
local _probe; _probe="$(dirname "$root")"
|
|
15116
|
+
while [[ "$_probe" != "/" && "$_probe" != "." && -n "$_probe" ]]; do
|
|
15117
|
+
if [[ -d "$_probe/.roll" ]]; then return 0; fi
|
|
15118
|
+
local _parent; _parent="$(dirname "$_probe")"
|
|
15119
|
+
[[ "$_parent" == "$_probe" ]] && break # reached fs root
|
|
15120
|
+
_probe="$_parent"
|
|
15121
|
+
done
|
|
15122
|
+
|
|
15026
15123
|
# US-ONBOARD-019: only treat the directory as a legacy *Roll* project when an
|
|
15027
15124
|
# old-path marker is present AND a Roll-specific content signature confirms
|
|
15028
15125
|
# the project was actually onboarded with Roll. Otherwise we'd block any
|
package/lib/agent_usage/kimi.py
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
"""
|
|
2
2
|
kimi (Moonshot Kimi CLI) agent usage extractor.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
files), the Kimi CLI prints a token-usage summary to stdout at the end of a
|
|
6
|
-
session. So this plugin implements the standard ``extract()`` registry
|
|
7
|
-
contract: scrape the passthrough stdout lines for the usage / model lines.
|
|
4
|
+
Two paths are supported, mirroring pi.py:
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
1. ``extract()`` — the registry stdout-scrape contract, kept for legacy
|
|
7
|
+
callers (and as a fallback when session files are absent).
|
|
8
|
+
2. ``usage_from_session()`` — authoritative recovery from kimi-code's
|
|
9
|
+
persisted session files at ``~/.kimi-code/sessions/wd_*/session_*/agents/main/wire.jsonl``.
|
|
10
|
+
Each wire file is NDJSON with one or more ``{"type":"usage.record","model":...,"usage":{...}}``
|
|
11
|
+
lines whose token fields are summed per cycle.
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
FIX-154 added the session path so loop cycles run by kimi-code (the
|
|
14
|
+
default agent today) no longer show ``—/—`` for tokens and cost in the
|
|
15
|
+
RECENT dashboard.
|
|
13
16
|
|
|
14
|
-
The
|
|
17
|
+
The stdout-scrape contract still recognises (case-insensitive)::
|
|
15
18
|
|
|
19
|
+
Model: kimi-k2
|
|
20
|
+
Tokens: input=15300 output=3120
|
|
16
21
|
Input tokens: 15,300
|
|
17
22
|
Output tokens: 3,120
|
|
18
23
|
Total tokens: 18,420
|
|
19
|
-
model: kimi-k2
|
|
20
24
|
|
|
21
25
|
When an explicit USD cost line isn't present, cost is computed from
|
|
22
|
-
``lib/model_prices.py`` (list price)
|
|
23
|
-
for a recognised kimi cycle. Returns None if no usage line is found,
|
|
24
|
-
so the caller falls back to the null payload (US-LOOP-010 compatible).
|
|
26
|
+
``lib/model_prices.py`` (list price).
|
|
25
27
|
"""
|
|
26
28
|
|
|
29
|
+
import glob
|
|
30
|
+
import json
|
|
27
31
|
import os
|
|
28
32
|
import re
|
|
29
33
|
import sys
|
|
@@ -125,3 +129,150 @@ def extract(stdin_lines: list[str]) -> Optional[dict]:
|
|
|
125
129
|
"cost_list_usd": cost,
|
|
126
130
|
"duration_ms": None,
|
|
127
131
|
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Session-file extraction (authoritative, FIX-154) ───────────────────────
|
|
135
|
+
|
|
136
|
+
# kimi-code persists every CLI session under
|
|
137
|
+
# ``~/.kimi-code/sessions/wd_<cwd-basename>_<8-hex>/session_<uuid>/agents/main/wire.jsonl``
|
|
138
|
+
# where ``<cwd-basename>`` is the basename of the cycle's worktree
|
|
139
|
+
# (e.g. ``roll-ecf079-cycle-20260601-170905-54957``).
|
|
140
|
+
# Each wire file is NDJSON; one or more lines have::
|
|
141
|
+
#
|
|
142
|
+
# {"type": "usage.record", "model": "kimi-code/kimi-for-coding",
|
|
143
|
+
# "usage": {"inputOther": <int>, "output": <int>,
|
|
144
|
+
# "inputCacheRead": <int>, "inputCacheCreation": <int>},
|
|
145
|
+
# "usageScope": "turn", "time": <ms>}
|
|
146
|
+
#
|
|
147
|
+
# We sum across all matching wire files (retries reuse the same worktree).
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _kimi_sessions_base_dir(base_dir: Optional[str]) -> str:
|
|
151
|
+
"""Resolve kimi-code's sessions root: arg → env → default."""
|
|
152
|
+
return (
|
|
153
|
+
base_dir
|
|
154
|
+
or os.environ.get("ROLL_KIMI_SESSIONS_DIR")
|
|
155
|
+
or os.path.expanduser("~/.kimi-code/sessions")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sum_wire_file(path: str) -> Optional[dict]:
|
|
160
|
+
"""Sum ``usage.record`` lines in a single kimi wire.jsonl.
|
|
161
|
+
|
|
162
|
+
Returns a usage dict or None when no usage records are found.
|
|
163
|
+
Field mapping kimi → roll::
|
|
164
|
+
|
|
165
|
+
inputOther → input_tokens
|
|
166
|
+
output → output_tokens
|
|
167
|
+
inputCacheRead → cache_read_tokens
|
|
168
|
+
inputCacheCreation → cache_creation_tokens
|
|
169
|
+
"""
|
|
170
|
+
tin = tout = tcr = tcw = 0
|
|
171
|
+
model = None
|
|
172
|
+
seen = False
|
|
173
|
+
try:
|
|
174
|
+
with open(path) as f:
|
|
175
|
+
for line in f:
|
|
176
|
+
line = line.strip()
|
|
177
|
+
if not line:
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
o = json.loads(line)
|
|
181
|
+
except json.JSONDecodeError:
|
|
182
|
+
continue
|
|
183
|
+
if o.get("type") != "usage.record":
|
|
184
|
+
continue
|
|
185
|
+
u = o.get("usage") or {}
|
|
186
|
+
seen = True
|
|
187
|
+
if o.get("model"):
|
|
188
|
+
model = o["model"]
|
|
189
|
+
tin += int(u.get("inputOther") or 0)
|
|
190
|
+
tout += int(u.get("output") or 0)
|
|
191
|
+
tcr += int(u.get("inputCacheRead") or 0)
|
|
192
|
+
tcw += int(u.get("inputCacheCreation") or 0)
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
if not seen:
|
|
196
|
+
return None
|
|
197
|
+
return {
|
|
198
|
+
"model": model or _DEFAULT_MODEL,
|
|
199
|
+
"input_tokens": tin,
|
|
200
|
+
"output_tokens": tout,
|
|
201
|
+
"cache_creation_tokens": tcw,
|
|
202
|
+
"cache_read_tokens": tcr,
|
|
203
|
+
"duration_ms": None,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def usage_from_session(
|
|
208
|
+
cwd: Optional[str] = None,
|
|
209
|
+
cycle_id: Optional[str] = None,
|
|
210
|
+
slug: Optional[str] = None,
|
|
211
|
+
base_dir: Optional[str] = None,
|
|
212
|
+
) -> Optional[dict]:
|
|
213
|
+
"""Recover a kimi cycle's usage by reading its persisted wire file(s).
|
|
214
|
+
|
|
215
|
+
Matching: scan ``<base>/wd_*/session_*/agents/main/wire.jsonl`` and
|
|
216
|
+
select files whose ``wd_*`` directory name contains the worktree
|
|
217
|
+
basename (authoritative when ``cwd`` is given) or the ``cycle_id``
|
|
218
|
+
substring (fallback).
|
|
219
|
+
|
|
220
|
+
Retries can produce multiple wire files for the same cycle; their
|
|
221
|
+
usage is SUMMED so token totals reflect retry work too.
|
|
222
|
+
|
|
223
|
+
Returns the merged usage dict (tokens + model), or None when nothing
|
|
224
|
+
matches / zero tokens — caller writes nothing in that case, preserving
|
|
225
|
+
"n/a, not fake zero".
|
|
226
|
+
"""
|
|
227
|
+
base = _kimi_sessions_base_dir(base_dir)
|
|
228
|
+
files = sorted(glob.glob(
|
|
229
|
+
os.path.join(base, "wd_*", "session_*", "agents", "main", "wire.jsonl")
|
|
230
|
+
))
|
|
231
|
+
if not files:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
cwd_basename = os.path.basename(cwd.rstrip("/")) if cwd else None
|
|
235
|
+
matched = []
|
|
236
|
+
for path in files:
|
|
237
|
+
# Session dir name: wd_<cwd-basename>_<8-hex>
|
|
238
|
+
# Path: <base>/wd_<cwd-basename>_<hash>/session_<uuid>/agents/main/wire.jsonl
|
|
239
|
+
wd_seg = path[len(base):].lstrip(os.sep).split(os.sep, 1)[0]
|
|
240
|
+
if cwd_basename and ("wd_%s_" % cwd_basename) in (wd_seg + "_"):
|
|
241
|
+
matched.append(path)
|
|
242
|
+
continue
|
|
243
|
+
if cycle_id and ("cycle-%s" % cycle_id) in wd_seg:
|
|
244
|
+
matched.append(path)
|
|
245
|
+
|
|
246
|
+
if not matched:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
agg = {
|
|
250
|
+
"model": None,
|
|
251
|
+
"input_tokens": 0,
|
|
252
|
+
"output_tokens": 0,
|
|
253
|
+
"cache_creation_tokens": 0,
|
|
254
|
+
"cache_read_tokens": 0,
|
|
255
|
+
"duration_ms": None,
|
|
256
|
+
}
|
|
257
|
+
got = False
|
|
258
|
+
for path in matched:
|
|
259
|
+
s = _sum_wire_file(path)
|
|
260
|
+
if s is None:
|
|
261
|
+
continue
|
|
262
|
+
got = True
|
|
263
|
+
agg["model"] = agg["model"] or s["model"]
|
|
264
|
+
agg["input_tokens"] += s["input_tokens"]
|
|
265
|
+
agg["output_tokens"] += s["output_tokens"]
|
|
266
|
+
agg["cache_creation_tokens"] += s["cache_creation_tokens"]
|
|
267
|
+
agg["cache_read_tokens"] += s["cache_read_tokens"]
|
|
268
|
+
|
|
269
|
+
if not got:
|
|
270
|
+
return None
|
|
271
|
+
has_tokens = (
|
|
272
|
+
agg["input_tokens"] or agg["output_tokens"]
|
|
273
|
+
or agg["cache_creation_tokens"] or agg["cache_read_tokens"]
|
|
274
|
+
)
|
|
275
|
+
if not has_tokens:
|
|
276
|
+
return None
|
|
277
|
+
agg["model"] = agg["model"] or _DEFAULT_MODEL
|
|
278
|
+
return agg
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
kimi_emit — write ONE authoritative usage event for a finished kimi cycle.
|
|
4
|
+
|
|
5
|
+
Mirror of ``pi_emit.py``: invoked once by bin/roll after the agent phase
|
|
6
|
+
when ROLL_LOOP_AGENT == "kimi". Recovers the cycle's real usage from
|
|
7
|
+
kimi-code's persisted ``wire.jsonl`` files via ``kimi.usage_from_session``
|
|
8
|
+
and appends a single ``stage=="usage"`` event to the loop events file.
|
|
9
|
+
|
|
10
|
+
Exactly one event per cycle — the dashboard SUMS token fields across
|
|
11
|
+
same-label usage events, so a per-retry write path would inflate ×N.
|
|
12
|
+
|
|
13
|
+
Cost is frozen at the active price snapshot via
|
|
14
|
+
``model_prices.compute_list_cost`` in the model's native currency.
|
|
15
|
+
|
|
16
|
+
When ``usage_from_session`` finds nothing (no matching session, zero
|
|
17
|
+
tokens) we write nothing — preserving "show n/a, not a fake zero".
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
|
|
27
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
_LIB_DIR = os.path.dirname(_THIS_DIR)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_model_prices():
|
|
32
|
+
spec = importlib.util.spec_from_file_location(
|
|
33
|
+
"model_prices", os.path.join(_LIB_DIR, "model_prices.py")
|
|
34
|
+
)
|
|
35
|
+
mp = importlib.util.module_from_spec(spec)
|
|
36
|
+
spec.loader.exec_module(mp)
|
|
37
|
+
return mp
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_kimi():
|
|
41
|
+
spec = importlib.util.spec_from_file_location(
|
|
42
|
+
"agent_usage_kimi", os.path.join(_THIS_DIR, "kimi.py")
|
|
43
|
+
)
|
|
44
|
+
kimi = importlib.util.module_from_spec(spec)
|
|
45
|
+
spec.loader.exec_module(kimi)
|
|
46
|
+
return kimi
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_event(cwd=None, cycle_id=None, slug=None, base_dir=None):
|
|
50
|
+
"""Return the (line dict) usage event for a kimi cycle, or None to skip."""
|
|
51
|
+
kimi = _load_kimi()
|
|
52
|
+
u = kimi.usage_from_session(
|
|
53
|
+
cwd=cwd, cycle_id=cycle_id, slug=slug, base_dir=base_dir
|
|
54
|
+
)
|
|
55
|
+
if u is None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
mp = _load_model_prices()
|
|
59
|
+
model = u.get("model") or "kimi-k2.5"
|
|
60
|
+
totals = {
|
|
61
|
+
"input_tokens": int(u.get("input_tokens") or 0),
|
|
62
|
+
"output_tokens": int(u.get("output_tokens") or 0),
|
|
63
|
+
"cache_creation_tokens": int(u.get("cache_creation_tokens") or 0),
|
|
64
|
+
"cache_read_tokens": int(u.get("cache_read_tokens") or 0),
|
|
65
|
+
}
|
|
66
|
+
cost_list = mp.compute_list_cost(model, **totals)
|
|
67
|
+
currency = mp.currency_for(model)
|
|
68
|
+
|
|
69
|
+
payload = {
|
|
70
|
+
"model": model,
|
|
71
|
+
"input_tokens": totals["input_tokens"],
|
|
72
|
+
"output_tokens": totals["output_tokens"],
|
|
73
|
+
"cache_creation_tokens": totals["cache_creation_tokens"],
|
|
74
|
+
"cache_read_tokens": totals["cache_read_tokens"],
|
|
75
|
+
"duration_ms": u.get("duration_ms"),
|
|
76
|
+
"cost_list_usd": cost_list,
|
|
77
|
+
"cost_currency": currency,
|
|
78
|
+
"prices_version": getattr(mp, "VERSION", None),
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
82
|
+
"stage": "usage",
|
|
83
|
+
"label": cycle_id,
|
|
84
|
+
"detail": payload,
|
|
85
|
+
"outcome": "ok",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default_events_path(slug, shared):
|
|
90
|
+
base = shared or os.environ.get("LOOP_SHARED_ROOT") \
|
|
91
|
+
or os.path.expanduser("~/.shared/roll")
|
|
92
|
+
return os.path.join(base, "loop", "events-%s.ndjson" % slug)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv=None):
|
|
96
|
+
ap = argparse.ArgumentParser(description="emit one kimi usage event")
|
|
97
|
+
ap.add_argument("--cwd", help="cycle worktree path (authoritative match)")
|
|
98
|
+
ap.add_argument("--cycle", help="cycle id (label + dir-name fallback)")
|
|
99
|
+
ap.add_argument("--slug", help="project slug (events filename)")
|
|
100
|
+
ap.add_argument("--shared", help="shared root (for default events path)")
|
|
101
|
+
ap.add_argument("--events", help="explicit events file path (preferred)")
|
|
102
|
+
ap.add_argument("--base-dir", help="kimi sessions root override (tests)")
|
|
103
|
+
args = ap.parse_args(argv)
|
|
104
|
+
|
|
105
|
+
event = build_event(
|
|
106
|
+
cwd=args.cwd, cycle_id=args.cycle, slug=args.slug, base_dir=args.base_dir
|
|
107
|
+
)
|
|
108
|
+
if event is None:
|
|
109
|
+
return 0 # nothing recoverable — write nothing (n/a, not fake zero)
|
|
110
|
+
|
|
111
|
+
evfile = args.events or _default_events_path(args.slug, args.shared)
|
|
112
|
+
try:
|
|
113
|
+
os.makedirs(os.path.dirname(evfile), exist_ok=True)
|
|
114
|
+
with open(evfile, "a") as f:
|
|
115
|
+
f.write(json.dumps(event) + "\n")
|
|
116
|
+
except OSError as e:
|
|
117
|
+
print("[kimi_emit] failed to write %s: %s" % (evfile, e), file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
package/lib/i18n/status.sh
CHANGED
|
@@ -13,6 +13,8 @@ _i18n_set en status.in_sync " %s=%s %s: %s (in sync /"
|
|
|
13
13
|
_i18n_set zh status.in_sync "已同步)"
|
|
14
14
|
_i18n_set en status.peer_call_timeout_s_peer "Peer call timeout: %ss Peer"
|
|
15
15
|
_i18n_set zh status.peer_call_timeout_s_peer "调用超时: %ss"
|
|
16
|
+
_i18n_set en status.peer_call_timeout_killed "Peer %s killed after %ss wall-clock budget (FIX-150c)"
|
|
17
|
+
_i18n_set zh status.peer_call_timeout_killed "Peer %s 超过 %ss 上限,已强制终止 (FIX-150c)"
|
|
16
18
|
_i18n_set en status.unsupported_peer "Unsupported peer: %s"
|
|
17
19
|
_i18n_set zh status.unsupported_peer "不支持的 peer: %s"
|
|
18
20
|
_i18n_set en status.unsupported_peer_2 "Unsupported peer: %s"
|