okstra 0.190.0 → 0.191.1
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/docs/architecture.md +6 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-report-translate.py +29 -4
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +11 -5
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -2
- package/runtime/python/okstra_ctl/convergence_engine.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +11 -1
- package/runtime/python/okstra_ctl/dispatch_state.py +27 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -6
- package/runtime/python/okstra_ctl/wizard/engine.py +22 -1
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +75 -0
- package/runtime/python/okstra_ctl/wizard/state.py +2 -0
- package/runtime/python/okstra_token_usage/codex.py +87 -2
- package/runtime/python/okstra_token_usage/collect.py +227 -49
- package/runtime/python/okstra_token_usage/grok.py +72 -25
- package/runtime/python/okstra_token_usage/pricing.py +8 -4
- package/runtime/skills/okstra-run/SKILL.md +6 -2
- package/runtime/validators/validate-run.py +6 -1
- package/runtime/validators/validate_session_conformance.py +38 -1
|
@@ -19,6 +19,7 @@ from .codex import (
|
|
|
19
19
|
codex_session_ids,
|
|
20
20
|
codex_session_is_worker,
|
|
21
21
|
codex_session_total,
|
|
22
|
+
codex_session_window_total,
|
|
22
23
|
find_codex_sessions,
|
|
23
24
|
)
|
|
24
25
|
from .antigravity import (
|
|
@@ -31,10 +32,11 @@ from .grok import (
|
|
|
31
32
|
find_grok_sessions,
|
|
32
33
|
grok_session_is_non_interactive,
|
|
33
34
|
grok_session_total,
|
|
35
|
+
grok_session_window_total,
|
|
34
36
|
)
|
|
35
|
-
from .paths import claude_project_dir, utc_now
|
|
37
|
+
from .paths import claude_project_dir, find_session_jsonl, utc_now
|
|
36
38
|
from .pricing import antigravity_cost_usd, provider_cost_usd
|
|
37
|
-
from okstra_ctl.dispatch_state import worker_session_ids
|
|
39
|
+
from okstra_ctl.dispatch_state import worker_dispatch_records, worker_session_ids
|
|
38
40
|
from okstra_ctl.models import provider_wrappers
|
|
39
41
|
from okstra_ctl.wrapper_status import (
|
|
40
42
|
log_path_for_prompt,
|
|
@@ -637,11 +639,21 @@ def _cli_sessions_for_windows(
|
|
|
637
639
|
return sessions
|
|
638
640
|
|
|
639
641
|
|
|
640
|
-
def _cli_session_totals(
|
|
642
|
+
def _cli_session_totals(
|
|
643
|
+
provider: str,
|
|
644
|
+
session_paths: list[Path],
|
|
645
|
+
*,
|
|
646
|
+
window: tuple[str, str] | None = None,
|
|
647
|
+
) -> list[dict]:
|
|
648
|
+
"""세션별 합계. `window` 는 리드 전용 — run 보다 먼저 열린 세션을 창으로 자른다."""
|
|
641
649
|
totals = []
|
|
642
650
|
for session_path in session_paths:
|
|
643
|
-
if provider == "codex":
|
|
651
|
+
if provider == "codex" and window is not None:
|
|
652
|
+
total = codex_session_window_total(session_path, *window)
|
|
653
|
+
elif provider == "codex":
|
|
644
654
|
total = codex_session_total(session_path)
|
|
655
|
+
elif provider == "grok" and window is not None:
|
|
656
|
+
total = grok_session_window_total(session_path, *window)
|
|
645
657
|
elif provider == "antigravity":
|
|
646
658
|
total = (
|
|
647
659
|
antigravity_status_total(session_path)
|
|
@@ -778,19 +790,50 @@ def collect_cli_usage(
|
|
|
778
790
|
return block
|
|
779
791
|
|
|
780
792
|
|
|
781
|
-
def
|
|
782
|
-
project_root: Path,
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
prompt_path = _resolve_project_path(project_root, str(worker.get("promptPath") or ""))
|
|
793
|
+
def _prompt_cli_window(
|
|
794
|
+
project_root: Path, prompt_path_raw: str,
|
|
795
|
+
) -> tuple[tuple[str, str] | None, Path | None]:
|
|
796
|
+
"""한 dispatch 의 래퍼 창 — 프롬프트 옆 status 사이드카의 started/ended."""
|
|
797
|
+
prompt_path = _resolve_project_path(project_root, prompt_path_raw)
|
|
787
798
|
status_path = status_path_for_prompt(prompt_path) if prompt_path is not None else None
|
|
788
799
|
execution = wrapper_execution(status_path)
|
|
789
800
|
started_at = execution.get("startedAt")
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
801
|
+
if not started_at:
|
|
802
|
+
return None, status_path
|
|
803
|
+
return (started_at, execution.get("endedAt") or utc_now()), status_path
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _worker_prompt_paths(worker: dict, records: list[dict]) -> list[str]:
|
|
807
|
+
"""이 워커가 띄운 dispatch 의 프롬프트 경로 전부 — 원장 행이 없으면(v1) 명부 행의 하나."""
|
|
808
|
+
paths: list[str] = []
|
|
809
|
+
for record in records:
|
|
810
|
+
raw = str(record.get("promptPath") or "").strip()
|
|
811
|
+
if raw and raw not in paths:
|
|
812
|
+
paths.append(raw)
|
|
813
|
+
if not paths:
|
|
814
|
+
raw = str(worker.get("promptPath") or "").strip()
|
|
815
|
+
if raw:
|
|
816
|
+
paths.append(raw)
|
|
817
|
+
return paths
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _worker_session_cwds(project_root: Path, records: list[dict]) -> list[Path]:
|
|
821
|
+
"""세션 디렉터리가 인코딩된 cwd 후보 — grok·kimi 는 워크트리 안에서 돈다.
|
|
822
|
+
|
|
823
|
+
`providers/grok/adapter.py` 는 `request.worktree_path or request.project_root`
|
|
824
|
+
를 cwd 로 넘기고 세션 디렉터리는 그 경로를 퍼센트 인코딩한 이름이다. 프로젝트
|
|
825
|
+
루트로만 찾으면 워크트리 안에서 돈 세션은 0건이다(실측 2026-09-08 jobs
|
|
826
|
+
implementation-planning r01: grok critic 세션이 워크트리 이름 아래 있었다).
|
|
827
|
+
codex·claude 는 루트에서 돌므로 워크트리 후보는 빈 결과로 끝난다.
|
|
828
|
+
"""
|
|
829
|
+
cwds: list[Path] = []
|
|
830
|
+
for record in records:
|
|
831
|
+
raw = str(record.get("worktreePath") or "").strip()
|
|
832
|
+
if raw and Path(raw) not in cwds:
|
|
833
|
+
cwds.append(Path(raw))
|
|
834
|
+
if project_root not in cwds:
|
|
835
|
+
cwds.append(project_root)
|
|
836
|
+
return cwds
|
|
794
837
|
|
|
795
838
|
|
|
796
839
|
def _antigravity_usage_sources(
|
|
@@ -828,25 +871,114 @@ def _worker_cli_usage_block(
|
|
|
828
871
|
provider: str,
|
|
829
872
|
project_root: Path,
|
|
830
873
|
worker: dict,
|
|
874
|
+
records: list[dict],
|
|
831
875
|
fallback_windows: list[tuple[str, str]],
|
|
832
876
|
) -> dict:
|
|
833
|
-
"""공급자 프로세스 트랜스크립트와 실행 증거로 워커 사용량 블록을 만든다.
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
877
|
+
"""공급자 프로세스 트랜스크립트와 실행 증거로 워커 사용량 블록을 만든다.
|
|
878
|
+
|
|
879
|
+
dispatch 원장 행마다 래퍼 창을 하나씩 잡아 그 창의 세션을 전부 합산한다 —
|
|
880
|
+
재검증·critic-gap·plan-verify 로 다시 띄운 실행이 각각 새 세션이므로
|
|
881
|
+
첫 프롬프트 하나만 보면 나머지는 통째로 빠진다. 실행 상태는 마지막
|
|
882
|
+
dispatch 의 것, 소요 시간은 래퍼 창의 합이다.
|
|
883
|
+
"""
|
|
884
|
+
windows: list[tuple[str, str]] = []
|
|
885
|
+
prompt_paths = _worker_prompt_paths(worker, records)
|
|
886
|
+
status_paths: list[Path | None] = []
|
|
887
|
+
for raw in prompt_paths:
|
|
888
|
+
window, status_path = _prompt_cli_window(project_root, raw)
|
|
889
|
+
status_paths.append(status_path)
|
|
890
|
+
if window is not None:
|
|
891
|
+
windows.append(window)
|
|
892
|
+
used_fallback = not windows
|
|
893
|
+
if used_fallback:
|
|
894
|
+
windows = fallback_windows
|
|
895
|
+
session_paths: list[Path] = []
|
|
896
|
+
for cwd in _worker_session_cwds(project_root, records):
|
|
897
|
+
for path in _cli_sessions_for_windows(provider, cwd, windows):
|
|
898
|
+
if path not in session_paths:
|
|
899
|
+
session_paths.append(path)
|
|
900
|
+
status_path = status_paths[-1] if status_paths else None
|
|
840
901
|
if provider == "antigravity":
|
|
841
|
-
for
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
902
|
+
for raw, source_status in zip(prompt_paths, status_paths):
|
|
903
|
+
for source in _antigravity_usage_sources(
|
|
904
|
+
project_root, {"promptPath": raw}, source_status,
|
|
905
|
+
):
|
|
906
|
+
if source not in session_paths:
|
|
907
|
+
session_paths.append(source)
|
|
908
|
+
block = collect_cli_usage(
|
|
845
909
|
provider=provider,
|
|
846
910
|
status_path=status_path,
|
|
847
911
|
sessions=session_paths,
|
|
848
912
|
fallback_window_used=used_fallback,
|
|
849
913
|
)
|
|
914
|
+
durations = [
|
|
915
|
+
wrapper_execution(path).get("durationMs")
|
|
916
|
+
for path in status_paths
|
|
917
|
+
if path is not None
|
|
918
|
+
]
|
|
919
|
+
measured = [value for value in durations if isinstance(value, int)]
|
|
920
|
+
if len(measured) > 1:
|
|
921
|
+
block["durationMs"] = sum(measured)
|
|
922
|
+
return block
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _wrapper_claude_worker(worker: dict) -> bool:
|
|
926
|
+
"""호스트가 claude 가 아닐 때 래퍼로 띄운 claude 워커."""
|
|
927
|
+
provider = str(worker.get("provider") or worker.get("agent") or "").strip()
|
|
928
|
+
runner = str(worker.get("runner") or "").strip()
|
|
929
|
+
return provider in _SESSION_JSONL_PROVIDERS and runner in {"", "cli-wrapper"}
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _claude_wrapper_usage_block(
|
|
933
|
+
*,
|
|
934
|
+
project_root: Path,
|
|
935
|
+
worker_id: str,
|
|
936
|
+
state: dict,
|
|
937
|
+
window: tuple[str | None, str | None],
|
|
938
|
+
incremental: bool,
|
|
939
|
+
) -> dict:
|
|
940
|
+
"""래퍼로 띄운 claude 워커의 세션 jsonl — dispatch 가 발급한 세션 id 로 찾는다.
|
|
941
|
+
|
|
942
|
+
`okstra-claude-exec.sh` 는 `claude -p --session-id <id>` 로 돌고 그 id 는
|
|
943
|
+
`workerDispatches[].sessionId` 에 적힌다(`_dispatch_record`). 트랜스크립트는
|
|
944
|
+
`~/.claude/projects/<루트 인코딩>/<id>.jsonl` 에 있는데, claude 가 아닌
|
|
945
|
+
호스트의 수집기는 이 워커를 "세션 jsonl 공급자" 라며 건너뛰어 항상
|
|
946
|
+
`unavailable` 이었다(실측 2026-09-08 jobs implementation-planning r01:
|
|
947
|
+
claude planner 5회·report-writer 2회 세션이 전부 디스크에 있었다). claude
|
|
948
|
+
호스트의 `collect_claude_runtime_usage` 가 같은 id 로 하는 일을 여기서 한다.
|
|
949
|
+
"""
|
|
950
|
+
since, until = window
|
|
951
|
+
session_ids = worker_session_ids(state, worker_id)
|
|
952
|
+
if not session_ids:
|
|
953
|
+
return na_block(
|
|
954
|
+
"claude wrapper worker has no dispatch session id recorded in workerDispatches"
|
|
955
|
+
)
|
|
956
|
+
totals: list[dict] = []
|
|
957
|
+
paths: list[Path] = []
|
|
958
|
+
attributed: list[str] = []
|
|
959
|
+
for session_id in session_ids:
|
|
960
|
+
path = find_session_jsonl(session_id, project_root)
|
|
961
|
+
if path is None:
|
|
962
|
+
continue
|
|
963
|
+
session_totals = claude_session_totals(
|
|
964
|
+
path, since=since, until=until, incremental=incremental,
|
|
965
|
+
)
|
|
966
|
+
# 창 밖 세션은 0 토큰 totals 가 되어 허위 0 으로 보고된다 — claude 호스트
|
|
967
|
+
# 경로와 같은 `startedAt` 검사로 거른다.
|
|
968
|
+
if not session_totals.get("startedAt"):
|
|
969
|
+
continue
|
|
970
|
+
totals.append(session_totals)
|
|
971
|
+
paths.append(path)
|
|
972
|
+
attributed.append(session_id)
|
|
973
|
+
if not totals:
|
|
974
|
+
return na_block(
|
|
975
|
+
"claude session jsonl not found under "
|
|
976
|
+
f"{claude_project_dir(project_root)} for dispatch session ids {session_ids}"
|
|
977
|
+
)
|
|
978
|
+
block = usage_block(_aggregate_totals(totals), source="claude-jsonl")
|
|
979
|
+
block["sessionIds"] = attributed
|
|
980
|
+
block["sessionPaths"] = [str(path) for path in paths]
|
|
981
|
+
return block
|
|
850
982
|
|
|
851
983
|
|
|
852
984
|
def _attach_cli_usage(
|
|
@@ -886,27 +1018,46 @@ def _attach_cli_usage(
|
|
|
886
1018
|
|
|
887
1019
|
|
|
888
1020
|
def _collect_cli_runtime_usage(
|
|
889
|
-
state: dict,
|
|
1021
|
+
state: dict,
|
|
1022
|
+
project_root: Path,
|
|
1023
|
+
team_state_path: Path | None = None,
|
|
1024
|
+
*,
|
|
1025
|
+
incremental: bool = True,
|
|
890
1026
|
) -> dict:
|
|
891
1027
|
windows_by_worker = _codex_worker_windows(project_root, state)
|
|
1028
|
+
run_window: tuple[str | None, str | None] = (None, None)
|
|
1029
|
+
if team_state_path is not None:
|
|
1030
|
+
run_window = resolve_run_window(team_state_path, state)
|
|
892
1031
|
for worker in state.get("workers", []):
|
|
893
1032
|
if not isinstance(worker, dict):
|
|
894
1033
|
continue
|
|
1034
|
+
worker_id = str(worker.get("workerId") or "").strip()
|
|
1035
|
+
records = [
|
|
1036
|
+
dict(record) for record in worker_dispatch_records(state, worker_id)
|
|
1037
|
+
] if worker_id else []
|
|
895
1038
|
provider = _cli_assignment_provider(worker)
|
|
896
|
-
if
|
|
1039
|
+
if provider:
|
|
1040
|
+
worker["usage"] = _worker_cli_usage_block(
|
|
1041
|
+
provider=provider,
|
|
1042
|
+
project_root=project_root,
|
|
1043
|
+
worker=worker,
|
|
1044
|
+
records=records,
|
|
1045
|
+
fallback_windows=windows_by_worker.get(worker_id, []),
|
|
1046
|
+
)
|
|
1047
|
+
elif worker_id and _wrapper_claude_worker(worker):
|
|
1048
|
+
worker["usage"] = _claude_wrapper_usage_block(
|
|
1049
|
+
project_root=project_root,
|
|
1050
|
+
worker_id=worker_id,
|
|
1051
|
+
state=state,
|
|
1052
|
+
window=run_window,
|
|
1053
|
+
incremental=incremental,
|
|
1054
|
+
)
|
|
1055
|
+
else:
|
|
897
1056
|
worker["usage"] = na_block(
|
|
898
1057
|
"worker usage is not read from a provider CLI transcript "
|
|
899
|
-
"(host-native
|
|
1058
|
+
"(host-native or no registered CLI provider): "
|
|
900
1059
|
f"{worker.get('provider') or worker.get('agent') or worker.get('workerId')}"
|
|
901
1060
|
)
|
|
902
|
-
continue
|
|
903
|
-
worker_id = str(worker.get("workerId") or "").strip()
|
|
904
|
-
worker["usage"] = _worker_cli_usage_block(
|
|
905
|
-
provider=provider,
|
|
906
|
-
project_root=project_root,
|
|
907
|
-
worker=worker,
|
|
908
|
-
fallback_windows=windows_by_worker.get(worker_id, []),
|
|
909
|
-
)
|
|
910
1061
|
state["leadUsage"] = _cli_lead_usage(state, project_root, team_state_path)
|
|
911
1062
|
_populate_usage_summary(state, team_name=resolve_team_name(state),
|
|
912
1063
|
sessions_found=0, needle_source="none")
|
|
@@ -952,22 +1103,29 @@ def _cli_lead_usage(
|
|
|
952
1103
|
if isinstance(worker, dict)
|
|
953
1104
|
for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
|
|
954
1105
|
}
|
|
1106
|
+
# in-session 리드(codex·grok)는 run 보다 먼저 열린 세션이다 — 창 안에서
|
|
1107
|
+
# 시작한 세션만 보면 없다고 나오고, 세션 전체를 더하면 다른 task 의 턴이
|
|
1108
|
+
# 섞인다. 창 안에서 활동한 세션을 후보에 넣고 토큰은 창으로 잘라 센다.
|
|
1109
|
+
window = (run_since, run_until)
|
|
1110
|
+
if provider == "codex":
|
|
1111
|
+
candidates = find_codex_sessions(
|
|
1112
|
+
project_root, run_since, run_until, active_before_start=True,
|
|
1113
|
+
)
|
|
1114
|
+
else:
|
|
1115
|
+
candidates = find_grok_sessions(
|
|
1116
|
+
project_root, run_since, run_until, active_before_start=True,
|
|
1117
|
+
)
|
|
955
1118
|
sessions = _select_cli_lead_sessions(
|
|
956
1119
|
provider,
|
|
957
|
-
[
|
|
958
|
-
path
|
|
959
|
-
for path in _cli_sessions_for_windows(
|
|
960
|
-
provider, project_root, [(run_since, run_until)],
|
|
961
|
-
)
|
|
962
|
-
if path not in worker_paths
|
|
963
|
-
],
|
|
1120
|
+
[path for path in candidates if path not in worker_paths],
|
|
964
1121
|
state,
|
|
1122
|
+
window=window,
|
|
965
1123
|
)
|
|
966
|
-
totals = _cli_session_totals(provider, sessions)
|
|
1124
|
+
totals = _cli_session_totals(provider, sessions, window=window)
|
|
967
1125
|
if not totals:
|
|
968
1126
|
return na_block(
|
|
969
1127
|
f"{provider} lead usage accounting is unavailable because no host session "
|
|
970
|
-
"
|
|
1128
|
+
"was active in the run window."
|
|
971
1129
|
)
|
|
972
1130
|
return _cli_usage_block(provider, _aggregate_totals(totals), sessions)
|
|
973
1131
|
|
|
@@ -981,7 +1139,11 @@ def _cli_worker_session(provider: str, path: Path) -> bool:
|
|
|
981
1139
|
|
|
982
1140
|
|
|
983
1141
|
def _select_cli_lead_sessions(
|
|
984
|
-
provider: str,
|
|
1142
|
+
provider: str,
|
|
1143
|
+
sessions: list[Path],
|
|
1144
|
+
state: dict,
|
|
1145
|
+
*,
|
|
1146
|
+
window: tuple[str, str] | None = None,
|
|
985
1147
|
) -> list[Path]:
|
|
986
1148
|
"""워커 래퍼 세션을 빼고, 아이디가 있으면 그 세션, 없으면 벽시계가 가장 긴 대화."""
|
|
987
1149
|
candidates = [
|
|
@@ -997,7 +1159,7 @@ def _select_cli_lead_sessions(
|
|
|
997
1159
|
best = candidates[0]
|
|
998
1160
|
best_ms = -1
|
|
999
1161
|
for path in candidates:
|
|
1000
|
-
totals = _cli_session_totals(provider, [path])
|
|
1162
|
+
totals = _cli_session_totals(provider, [path], window=window)
|
|
1001
1163
|
total = totals[0] if totals else {}
|
|
1002
1164
|
wall = _wall_ms(total.get("startedAt"), total.get("endedAt")) if total else None
|
|
1003
1165
|
if wall is None:
|
|
@@ -1056,14 +1218,22 @@ def _populate_usage_summary(
|
|
|
1056
1218
|
worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
|
|
1057
1219
|
|
|
1058
1220
|
unmatched_models: list[str] = []
|
|
1059
|
-
if
|
|
1221
|
+
if (
|
|
1222
|
+
lead.get("model")
|
|
1223
|
+
and lead.get("estimatedCostUsd") is None
|
|
1224
|
+
and lead.get("cliEstimatedCostUsd") is None
|
|
1225
|
+
and (lead.get("totalTokens") or 0) > 0
|
|
1226
|
+
):
|
|
1060
1227
|
unmatched_models.append(lead["model"])
|
|
1061
1228
|
for w in workers:
|
|
1062
1229
|
u = w.get("usage") or {}
|
|
1230
|
+
# CLI 블록의 가격은 `cliEstimatedCostUsd` 에 붙는다 — 그 키를 안 보면
|
|
1231
|
+
# 가격이 붙은 grok 도 미매칭으로 찍힌다.
|
|
1063
1232
|
if (
|
|
1064
1233
|
u.get("source") not in {"codex-cli", "agy-cli"}
|
|
1065
1234
|
and u.get("model")
|
|
1066
1235
|
and u.get("estimatedCostUsd") is None
|
|
1236
|
+
and u.get("cliEstimatedCostUsd") is None
|
|
1067
1237
|
and (u.get("totalTokens") or 0) > 0
|
|
1068
1238
|
):
|
|
1069
1239
|
unmatched_models.append(u["model"])
|
|
@@ -1269,6 +1439,12 @@ def collect_claude_runtime_usage(
|
|
|
1269
1439
|
provider=provider,
|
|
1270
1440
|
project_root=cwd,
|
|
1271
1441
|
worker=worker,
|
|
1442
|
+
records=[
|
|
1443
|
+
dict(record)
|
|
1444
|
+
for record in worker_dispatch_records(
|
|
1445
|
+
state, str(worker_id or "").strip()
|
|
1446
|
+
)
|
|
1447
|
+
] if worker_id else [],
|
|
1272
1448
|
fallback_windows=cli_windows_by_worker.get(
|
|
1273
1449
|
str(worker_id or "").strip(), []
|
|
1274
1450
|
),
|
|
@@ -1363,7 +1539,9 @@ def collect_cli_runtime_usage(
|
|
|
1363
1539
|
) -> dict:
|
|
1364
1540
|
state = json.loads(team_state_path.read_text())
|
|
1365
1541
|
cwd = project_root or _infer_project_root(team_state_path, state)
|
|
1366
|
-
return _collect_cli_runtime_usage(
|
|
1542
|
+
return _collect_cli_runtime_usage(
|
|
1543
|
+
state, cwd, team_state_path, incremental=incremental,
|
|
1544
|
+
)
|
|
1367
1545
|
|
|
1368
1546
|
|
|
1369
1547
|
def collect(
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Grok Build session collectors.
|
|
2
2
|
|
|
3
3
|
공식 세션 문서는 대화를 ``~/.grok/sessions/`` 에 둔다. cwd 는 퍼센트 인코딩된
|
|
4
|
-
디렉터리 이름이다.
|
|
5
|
-
``
|
|
4
|
+
디렉터리 이름이다. ``updates.jsonl`` 의 ``params.update.usage`` 행 하나는
|
|
5
|
+
**프롬프트 한 번**의 사용량이다 — ``numTurns`` 는 그 프롬프트 안의 모델 호출
|
|
6
|
+
수이고 ``inputTokens`` 는 매번 컨텍스트 전체라 값이 오르내린다(실측
|
|
7
|
+
2026-09-08, 대화형 세션 3개 39행: 83k → 798k → 406k → 187k …). 세션 합계는
|
|
8
|
+
행의 합이고, 마지막 행은 마지막 프롬프트일 뿐이다. exec 래퍼 워커는 프롬프트가
|
|
9
|
+
하나라 행도 하나다.
|
|
6
10
|
"""
|
|
7
11
|
from __future__ import annotations
|
|
8
12
|
|
|
@@ -53,39 +57,55 @@ def _model_snapshot(usage: dict) -> tuple[dict, str | None]:
|
|
|
53
57
|
return usage, None
|
|
54
58
|
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
""
|
|
58
|
-
|
|
60
|
+
_SUM_KEYS = (
|
|
61
|
+
("totalTokens", "totalTokens"),
|
|
62
|
+
("inputTokens", "inputTokens"),
|
|
63
|
+
("outputTokens", "outputTokens"),
|
|
64
|
+
("cachedInputTokens", "cachedReadTokens"),
|
|
65
|
+
("reasoningOutputTokens", "reasoningTokens"),
|
|
66
|
+
("durationMs", "apiDurationMs"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def grok_session_window_total(
|
|
71
|
+
updates_path: Path, since: str | None = None, until: str | None = None,
|
|
72
|
+
) -> dict:
|
|
73
|
+
"""창 안 usage 행의 합. 창이 없으면 세션 전체.
|
|
74
|
+
|
|
75
|
+
한 행이 프롬프트 한 번의 사용량이므로 더한다 — 마지막 행만 읽으면 여러
|
|
76
|
+
프롬프트를 돌린 세션(in-session 리드)은 마지막 프롬프트만 남는다. 창은
|
|
77
|
+
run 보다 먼저 열린 리드 세션에서 다른 task 의 프롬프트를 걸러 낸다.
|
|
78
|
+
"""
|
|
79
|
+
sums = {name: 0 for name, _raw in _SUM_KEYS}
|
|
59
80
|
model: str | None = None
|
|
60
81
|
started: str | None = None
|
|
61
82
|
ended: str | None = None
|
|
83
|
+
counted = 0
|
|
62
84
|
for record in iter_jsonl(updates_path):
|
|
63
85
|
usage = _usage_payload(record)
|
|
64
86
|
if usage is None:
|
|
65
87
|
continue
|
|
66
88
|
iso = _iso_from_unix(record.get("timestamp"))
|
|
89
|
+
if iso and not ts_in_window(iso, since, until):
|
|
90
|
+
continue
|
|
91
|
+
snapshot, snapshot_model = _model_snapshot(usage)
|
|
92
|
+
for name, raw in _SUM_KEYS:
|
|
93
|
+
sums[name] += snapshot.get(raw, 0) or 0
|
|
94
|
+
if snapshot_model:
|
|
95
|
+
model = snapshot_model
|
|
67
96
|
if iso and started is None:
|
|
68
97
|
started = iso
|
|
69
98
|
if iso:
|
|
70
99
|
ended = iso
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if snapshot_model:
|
|
74
|
-
model = snapshot_model
|
|
75
|
-
if last is None:
|
|
100
|
+
counted += 1
|
|
101
|
+
if not counted:
|
|
76
102
|
return {"totalTokens": 0, "available": False}
|
|
77
|
-
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"durationMs": last.get("apiDurationMs", 0) or 0,
|
|
84
|
-
"model": model,
|
|
85
|
-
"startedAt": started,
|
|
86
|
-
"endedAt": ended,
|
|
87
|
-
"available": True,
|
|
88
|
-
}
|
|
103
|
+
return {**sums, "model": model, "startedAt": started, "endedAt": ended, "available": True}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def grok_session_total(updates_path: Path) -> dict:
|
|
107
|
+
"""세션 전체 — 모든 usage 행의 합."""
|
|
108
|
+
return grok_session_window_total(updates_path)
|
|
89
109
|
|
|
90
110
|
|
|
91
111
|
def _session_started_in_window(
|
|
@@ -102,14 +122,38 @@ def _session_started_in_window(
|
|
|
102
122
|
return False
|
|
103
123
|
|
|
104
124
|
|
|
125
|
+
def _session_active_in_window(
|
|
126
|
+
updates_path: Path, started_at: str, ended_at: str,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""창보다 먼저 열렸지만 창 안에도 usage 행이 있는 세션 — in-session 리드."""
|
|
129
|
+
first: str | None = None
|
|
130
|
+
for record in iter_jsonl(updates_path):
|
|
131
|
+
iso = _iso_from_unix(record.get("timestamp"))
|
|
132
|
+
if not iso:
|
|
133
|
+
continue
|
|
134
|
+
if first is None:
|
|
135
|
+
first = iso
|
|
136
|
+
if not ts_in_window(first, None, started_at):
|
|
137
|
+
return False
|
|
138
|
+
if ts_in_window(iso, started_at, ended_at):
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
105
143
|
def find_grok_sessions(
|
|
106
144
|
cwd: Path,
|
|
107
145
|
started_at: str,
|
|
108
146
|
ended_at: str,
|
|
109
147
|
*,
|
|
110
148
|
session_root: Path | None = None,
|
|
149
|
+
active_before_start: bool = False,
|
|
111
150
|
) -> list[Path]:
|
|
112
|
-
"""cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl.
|
|
151
|
+
"""cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl.
|
|
152
|
+
|
|
153
|
+
`active_before_start=True` 는 창보다 먼저 시작했지만 창 안에서도 프롬프트를
|
|
154
|
+
돌린 세션을 더한다 — in-session 리드의 모양이고, 토큰은
|
|
155
|
+
`grok_session_window_total` 이 창으로 잘라 센다.
|
|
156
|
+
"""
|
|
113
157
|
if not started_at or not ended_at:
|
|
114
158
|
return []
|
|
115
159
|
root = session_root or grok_sessions_root()
|
|
@@ -125,8 +169,11 @@ def find_grok_sessions(
|
|
|
125
169
|
continue
|
|
126
170
|
for session in child.iterdir():
|
|
127
171
|
updates = session / "updates.jsonl"
|
|
128
|
-
if updates.is_file()
|
|
129
|
-
|
|
172
|
+
if not updates.is_file():
|
|
173
|
+
continue
|
|
174
|
+
if _session_started_in_window(updates, started_at, ended_at) or (
|
|
175
|
+
active_before_start
|
|
176
|
+
and _session_active_in_window(updates, started_at, ended_at)
|
|
130
177
|
):
|
|
131
178
|
matches.append(updates)
|
|
132
179
|
return sorted(matches)
|
|
@@ -59,8 +59,12 @@ CLAUDE_PRICING = {
|
|
|
59
59
|
# Claude Opus 5.
|
|
60
60
|
"opus-5": (5.0, 6.25, 0.50, 25.0), # Opus 5 (cache prices derived from ratios)
|
|
61
61
|
|
|
62
|
-
# Claude Sonnet 5
|
|
63
|
-
|
|
62
|
+
# Claude Sonnet 5 — $2/$10 launched as introductory pricing and became the
|
|
63
|
+
# standard price (the announced 2026-09-01 rise to $3/$15 was withdrawn;
|
|
64
|
+
# platform.claude.com/docs/en/about-claude/pricing, read 2026-09-08). The
|
|
65
|
+
# old (3.0, 3.75, 0.30, 15.0) row was the Sonnet 4.6 rate and overbilled
|
|
66
|
+
# every Sonnet 5 worker by 1.5x.
|
|
67
|
+
"sonnet-5": (2.0, 2.50, 0.20, 10.0), # Sonnet 5 (cache prices derived from ratios)
|
|
64
68
|
|
|
65
69
|
# Claude 4 point releases (explicit so future divergence is easy to see).
|
|
66
70
|
"opus-4-8": (5.0, 6.25, 0.50, 25.0), # Opus 4.8 (cache prices derived from ratios)
|
|
@@ -90,8 +94,8 @@ _LEGACY_CODEX_PRICING = {
|
|
|
90
94
|
|
|
91
95
|
# GPT-5 series.
|
|
92
96
|
# gpt-5.6 was dropped from the provider catalog; its rate stays so past runs
|
|
93
|
-
# keep pricing.
|
|
94
|
-
#
|
|
97
|
+
# keep pricing. sol / terra / luna no longer land here: their catalog rows
|
|
98
|
+
# carry per-variant prices, and `_match_pricing` prefers the longer key.
|
|
95
99
|
"gpt-5.6": (5.00, 0.50, 30.0),
|
|
96
100
|
"gpt-5.2-pro": (21.0, 2.10, 168.0),
|
|
97
101
|
"gpt-5.1": (1.25, 0.125, 10.0),
|
|
@@ -41,7 +41,7 @@ Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit i
|
|
|
41
41
|
|
|
42
42
|
On `ok: false`, re-prompt with the same `current.step` using the error message. The wizard never advances on validation failure; the user retries the same step. **`current` may be `null`** when the current step itself cannot render (e.g. the approved plan's Stage Map is corrupt) — that case is terminal: show `error` and stop, the user must fix the plan file before retrying. Never re-prompt off a `null` `current`.
|
|
43
43
|
|
|
44
|
-
The wizard tells you which relay operation to use via `next.interaction.kind`. Step 1 loads the current registered host's relay contract. Use the matching entry under its `interactions` object
|
|
44
|
+
The wizard tells you which relay operation to use via `next.interaction.kind`. Step 1 loads the current registered host's relay contract. Use the matching entry under its `interactions` object, including the runtime-generated navigation and completion options. If the entry is absent, stop instead of inventing a host function or falling back silently. Use the explicit `Legacy text relay compatibility` mapping only when the preflight response has no `relayContract`.
|
|
45
45
|
|
|
46
46
|
- `native-single` → use the current registered host's relay contract for its native single-select function. Pass every option in its original order and submit the selected `options[].value`.
|
|
47
47
|
- `native-multi` → use the relay contract's native multi-select function. Pass every option in its original order and submit the selected values as one CSV answer; submit an empty selection as `--answer ""`.
|
|
@@ -53,7 +53,9 @@ The wizard tells you which relay operation to use via `next.interaction.kind`. S
|
|
|
53
53
|
- `kind: "done"` → input collection finished; move to Step 5.
|
|
54
54
|
- `kind: "aborted"` → the user picked abort; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
When native single selection is available, the runtime divides long lists and unsupported multi-selections into selectable screens. Render the returned screen without rebuilding the full list. This applies to plan confirmation, stages, role counts, and provider/model lists. Submit navigation and completion option values normally; the runtime retains the current step until its answer is complete. A ban on textual choice lists does not authorize asking the user to type a model identifier. If a required selector is unavailable, preserve state and follow the relay's recovery. Genuine text steps still collect text.
|
|
57
|
+
|
|
58
|
+
Submit the answer shape required by `interaction.answerProtocol`; do not add normalization beyond the registered relay's explicit mapping. Invalid, out-of-range, or ambiguous answers return `ok: false` and must re-render the same complete interaction.
|
|
57
59
|
|
|
58
60
|
The final `confirm` step is a normal `pick` step with three options — `Proceed` / `Edit` / `Abort`(abort) — and is rendered the same way (no special handling). `Edit` rewinds to any earlier step (including `base-ref`); `Abort` terminally cancels the wizard. The branch/worktree decision the run will actually use (for `implementation`, the **stage worktree** — not the task-key directory) is folded into the Step 4 confirmation summary block as a `worktree` line, so there is no separate branch-confirm prompt.
|
|
59
61
|
|
|
@@ -87,6 +89,8 @@ If the successful fixed projection has `Relay contract: -`, enter the compatibil
|
|
|
87
89
|
|
|
88
90
|
Before calculating the effective intersection, apply the registered relay's client-specific tool selection and live mode restrictions. A callable question tool does not necessarily render a selector in the current client. In Codex, use the synchronous picker when permitted; asynchronous question cards are a desktop fallback, not a terminal picker. If the user requires a selectable interface and the current client cannot provide it, preserve the wizard state and follow the relay's recovery instead of printing the option list.
|
|
89
91
|
|
|
92
|
+
Plan adoption (`approve_plan_confirm`) is a workflow choice: present the wizard's existing options using the same selector as other `pick` steps. Follow the relay's distinction between plan decisions and execution permissions; the word "approval" alone is not a reason to replace a selector with a typed confirmation.
|
|
93
|
+
|
|
90
94
|
### Legacy text relay compatibility
|
|
91
95
|
|
|
92
96
|
Use this built-in mapping only when the successful preflight response omitted `relayContract`. It is not a fallback for an unreadable, malformed, mismatched, or incomplete relay contract. If a present relay contract omits the wizard's interaction kind, keep the fail-closed rule above and stop.
|
|
@@ -569,7 +569,12 @@ def _validate_agent_dispatch_contract(
|
|
|
569
569
|
dispatch_id = str(link.get("dispatchId") or "").strip()
|
|
570
570
|
result_path = str(link.get("resultPath") or "").strip()
|
|
571
571
|
if dispatch_id not in ids or not result_path:
|
|
572
|
-
|
|
572
|
+
# 같은 문장이 링크 수만큼 반복되면 어느 링크인지 알 수 없다 — 대상을 이름한다.
|
|
573
|
+
failures.append(
|
|
574
|
+
"agent result link has no matching dispatch record: "
|
|
575
|
+
f"dispatchId={dispatch_id or '<empty>'} "
|
|
576
|
+
f"resultPath={Path(result_path).name if result_path else '<empty>'}"
|
|
577
|
+
)
|
|
573
578
|
continue
|
|
574
579
|
if uses_v2_identity:
|
|
575
580
|
dispatch = ids[dispatch_id]
|