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.
@@ -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(provider: str, session_paths: list[Path]) -> list[dict]:
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 _worker_cli_windows(
782
- project_root: Path,
783
- worker: dict,
784
- fallback_windows: list[tuple[str, str]],
785
- ) -> tuple[list[tuple[str, str]], Path | None, bool]:
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
- ended_at = execution.get("endedAt")
791
- if started_at:
792
- return [(started_at, ended_at or utc_now())], status_path, False
793
- return fallback_windows, status_path, True
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
- windows, status_path, used_fallback = _worker_cli_windows(
835
- project_root,
836
- worker,
837
- fallback_windows,
838
- )
839
- session_paths = _cli_sessions_for_windows(provider, project_root, windows)
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 source in _antigravity_usage_sources(project_root, worker, status_path):
842
- if source not in session_paths:
843
- session_paths.append(source)
844
- return collect_cli_usage(
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, project_root: Path, team_state_path: Path | None = None,
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 not provider:
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, session-jsonl provider, or no registered CLI provider): "
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
- "started in the run window."
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, sessions: list[Path], state: dict,
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 lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
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(state, cwd, team_state_path)
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
- 디렉터리 이름이다. 누적 사용량은 ``updates.jsonl`` 의
5
- ``params.update.usage.modelUsage`` 마지막 스냅샷이다.
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
- def grok_session_total(updates_path: Path) -> dict:
57
- """마지막 modelUsage 스냅샷. 세션 누적이라 더하지 않는다."""
58
- last: dict | None = None
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
- snapshot, snapshot_model = _model_snapshot(usage)
72
- last = snapshot
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
- "totalTokens": last.get("totalTokens", 0) or 0,
79
- "inputTokens": last.get("inputTokens", 0) or 0,
80
- "outputTokens": last.get("outputTokens", 0) or 0,
81
- "cachedInputTokens": last.get("cachedReadTokens", 0) or 0,
82
- "reasoningOutputTokens": last.get("reasoningTokens", 0) or 0,
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() and _session_started_in_window(
129
- updates, started_at, ended_at
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
- "sonnet-5": (3.0, 3.75, 0.30, 15.0), # Sonnet 5 (cache prices derived from ratios)
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. Substring matching means sol / terra / luna resolve here too
94
- # -- the 5.6 family rate, not a per-variant price we have a source for.
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 exactly; if the entry is absent, stop instead of inventing a host function or falling back silently. The only exception is the explicit `Legacy text relay compatibility` mapping selected in Step 1 when the preflight response has no `relayContract`.
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
- Submit the answer shape required by `interaction.answerProtocol`; do not add normalization that the protocol does not request. Invalid, out-of-range, or ambiguous answers return `ok: false` and must re-render the same complete interaction.
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
- failures.append("agent result link has no matching dispatch record")
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]