okstra 0.190.0 → 0.191.0

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.
@@ -937,11 +937,15 @@ Tokens used in each run are collected from lead/worker session transcripts and w
937
937
  - Helper CLI: `scripts/okstra-token-usage.py`
938
938
  - Collection sources:
939
939
  - Claude lead/workers: per-message `message.usage` in `~/.claude/projects/<cwd-as-dashes>/<sessionId>.jsonl` or `~/.claude/projects/<cwd-as-dashes>/<lead-session>/subagents/agent-a<worker-name>-<hash>.jsonl`. Worker names are recovered from nested-subagent filenames, and only the directory for the current run's `team-state.lead.sessionId` is counted.
940
- - Codex CLI: final `total_token_usage.total_tokens` in `~/.agent/sessions/Y/M/D/rollout-*.jsonl`
940
+ - Codex CLI: final `total_token_usage.total_tokens` in `~/.codex/sessions/Y/M/D/rollout-*.jsonl` (`$CODEX_HOME/sessions` first, then `~/.agent/sessions`). An in-session codex lead (`entryMode: current-session`) opened its rollout before the run and keeps it after, so its cumulative snapshot is the whole session; the lead is counted as the sum of `last_token_usage` over the `token_count` events inside the run window (`codex_session_window_total`), and a session that started before the window still qualifies when it wrote inside it (`find_codex_sessions(active_before_start=True)`).
941
+ - Grok CLI: the last `usage` snapshot in `~/.grok/sessions/<percent-encoded cwd>/<sessionId>/updates.jsonl`. grok (and kimi) run *inside* the stage worktree, so the directory is encoded from the worktree path, not the project root.
942
+ - Claude workers on a non-Claude host: the wrapper runs `claude -p --session-id <id>`, and the transcript is `~/.claude/projects/<cwd-as-dashes>/<id>.jsonl`, found by the id `workerDispatches[].sessionId` recorded at dispatch.
943
+ - **Attribution key is the dispatch ledger, not the roster row.** `workers[].promptPath` names one prompt — the first dispatch — and v2 lead events carry no `workerId`. Every re-dispatch (reverify, critic-gap, plan-verify, report-writer re-author) is a new wrapper run with its own prompt, `.status.json`, and transcript, and only `team-state.workerDispatches[]` lists them all (`dispatch_state.worker_dispatch_records`, keyed by `workerId` or the last segment of `assignmentRef`). The collector takes one wrapper window per record, unions the transcripts found under each record's `worktreePath` and the project root, and sums them; wall-clock is the sum of the wrapper windows. Observed before this rule (2026-09-08, a codex-host planning run): codex counted 1 of 5 sessions, grok 0 of 1, claude 0 of 7.
941
944
  - Antigravity CLI: the `usage` snapshot in the wrapper `<prompt>.status.json` — the runner records the last `usage` the `agy` stream reported (`result.usage` on a run that closed). The CLI writes no transcript under the home directory, and the worker `.log` is the stream rewritten as readable lines, not stream-json.
942
945
  - CLI execution evidence and token attribution are independent. A wrapper `.status.json` proves `not-started`, `started`, `exited`, `timeout`, or `failed` and supplies the worker's collection window; only a matching transcript with a final token snapshot — or, for a provider without a home transcript, the `usage` snapshot the runner wrote into that `.status.json` — proves attributable usage. If a wrapper exited successfully but no attributable transcript exists, the worker remains `source: "unavailable"` with `cliExecutionStatus: "exited"` and a reason instead of becoming zero usage or being described as never invoked.
943
946
  - Records billable-equivalent token math and USD cost estimates. It applies Anthropic billing ratios (`cache_creation_5m=1.25x`, `cache_creation_1h=2.0x`, `cache_read=0.1x`, `output=5x`). When the transcript provides separate `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` values, they are counted separately.
944
- - Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py`. Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
947
+ - Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py` (Claude and Gemini rate tables) and in each provider catalog's `ModelSpec(pricing=...)` (codex, grok, kimi — merged through `catalog_pricing`). Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
948
+ - **Cost is always the public list price, regardless of how the account is billed.** The report answers "how much was consumed", not "what the invoice says": a model served under a ChatGPT/Claude subscription is still priced at its API rate, so every selectable catalog row must carry a price. The one row without a price is `codex-auto-review`, for which no public rate exists.
945
949
  - Project-wide historical usage is exposed through the read-only `okstra usage-report` command (`scripts/okstra_ctl/usage_report.py`) and the `okstra-usage` skill. It defaults to the whole current project's last 30 days and returns run coverage, raw and billable-equivalent tokens, known USD cost, CPU-sum milliseconds, and wall-clock milliseconds grouped by task type. Runs without usable Phase 7 usage are excluded from resource totals and reported through unavailable reason counts rather than treated as zero usage; unmatched model names remain visible when their tokens and time are included but their cost is not. Use `okstra-inspect` for one task's elapsed/context detail and `okstra-rollup` for task-group or project status/report digests.
946
950
  - **Incremental scan cache (P6)**: To avoid rescanning session jsonl files, a per-file byte cursor and the extracted usage events before windowing are stored in `$OKSTRA_HOME/cache/token-usage/<transcript-dir>/<sessionId>.json` (`scripts/okstra_token_usage/cursor.py`). The run window (since/until) is reevaluated over events on every invocation, so even if a rerun narrows the window, the total matches a full scan. The cache is derived data; identifier mismatch, truncation, or corruption automatically falls back to a full rescan, and `okstra-token-usage.py --no-cache` forces a bypass.
947
951
  - **Phase timeline (P0 instrumentation)**: The collector extracts `PROGRESS: phase-*` checkpoint lines (see "Progress reporting" in prompts/lead/okstra-lead-contract.md) from the lead session jsonl scoped to the run window, and records them in team-state as a `phaseTimeline` block (`{source, phases: [{phase, firstAt, lastAt, markerCount, wallMsToNext}]}`) (`scripts/okstra_token_usage/collect.py :: phase_timeline`). This provides measurement points for per-phase wall-clock time within the run and is consumed by the "Per-run phase breakdown" in the `okstra-inspect` time facet. Runs without markers explicitly report that measurement is unavailable with `phases: []`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.190.0",
3
+ "version": "0.191.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.190.0",
3
- "builtAt": "2026-09-08T19:21:43.911Z",
2
+ "package": "0.191.0",
3
+ "builtAt": "2026-09-08T21:03:17.927Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -115,11 +115,25 @@ For `request_user_input`, send one to three questions. Each question carries `id
115
115
  For a `host-text` mapping, render each numbered item as its option label followed by its description verbatim; preserve every item and its order. The next user message is the raw answer: do not translate a number such as `1`, a CSV reply such as `1, 3`, an option label, or an option value before `okstra wizard step`. For `sequential-group`, collect one raw reply per question in order and build one compact JSON object keyed by the corresponding `questions[].step`; the wizard owns all normalization.
116
116
 
117
117
 
118
+ ### Runtime-generated selectable screens
119
+
120
+ `wizard/engine.py` adapts choice screens before returning `next` when the session declares `native_single_select`. `wizard/picker_navigation.py` preserves all original choices while paging long lists, collecting multi-selection through toggle/complete choices, and disambiguating duplicate labels. Oversized or unsupported groups are presented one member at a time. These paths are exercised by `tests/domain/wizard/test_picker_navigation.py` and `test_role_model_selection.py`.
121
+
122
+ Render exactly the returned current screen using its `interaction.kind`, including navigation and completion options. Submit their original values through `okstra wizard step` like other options. The runtime keeps navigation and partial selections in the state file and does not submit the underlying workflow decision until selection is complete. Do not reconstruct the full model list, perform pagination yourself, or replace model choices with a request to type `provider/model`. A repeated step ID after navigation or a toggle is the next screen, not a duplicated question; render that newly returned screen once.
123
+
124
+ If the runtime still returns a numbered interaction when the user requires a selector, preserve the state and check installation and live capabilities. Do not invent a text-input exception. Text steps remain text steps; a direct-input choice is selected through the picker before the runtime asks for the custom value.
125
+
126
+ ### Plan decisions and execution permissions
127
+
128
+ Classify the requested action, not the word "approval". The wizard's `approve_plan_confirm` is a workflow decision about adopting the selected plan; it is a `pick` prompt built by `_build_approve_plan_confirm` in `wizard/steps_plan.py`. Render its existing options through the available native selector, just like task type and plan selection. Explain the effect of each option in the question tool and map the selected label back to its original value (`yes`, `no`, or `yes_apply` when offered). Recording that decision in okstra artifacts does not turn the question into a host permission request. Do not ask the user to type an approval word or switch to prose merely because this step is called approval.
129
+
130
+ Host permission requests concern execution privileges, sandbox escalation, or access to a protected resource. Use the host's permission mechanism for those requests; a wizard answer does not grant those privileges. If a subsequent command requires such permission, handle it separately at that command.
131
+
118
132
  ### Client-aware question tool selection
119
133
 
120
134
  Before intersecting `semanticFunctions` with live capabilities, prefer `request_user_input` when the current session permits that call. Use its live restrictions rather than assuming it is always Plan-only: Codex CLI can enable it in Default mode with `default_mode_request_user_input`. The JSON mapping above remains the terminal picker contract.
121
135
 
122
- Use `request_user_input_async` only when the current client explicitly supports interactive asynchronous question cards, such as the Codex desktop app, and the synchronous tool is unavailable. Callable does not mean selectable: the Codex terminal (`codex-tui`, session source `cli`) can accept an asynchronous question but render it as a plain bulleted agent message. Do not count that terminal delivery as `native_single_select` or `native_question_group`. For a supported desktop client, replace the `function` of both native entries above with `request_user_input_async` and use the mapping below. Keep the conservative `nativeLimits` and the numbered mapping for multi-selection.
136
+ Use `request_user_input_async` only when the current client explicitly supports interactive asynchronous question cards, such as the Codex desktop app, and the synchronous tool is unavailable. Callable does not mean selectable: the Codex terminal (`codex-tui`, session source `cli`) can accept an asynchronous question but render it as a plain bulleted agent message. Do not count that terminal delivery as `native_single_select` or `native_question_group`. For a supported desktop client, replace the `function` of both native entries above with `request_user_input_async` and use the mapping below. Keep the declared `nativeLimits`; the runtime generates the selectable screens within those limits.
123
137
 
124
138
  If the user requested a selectable interface in the terminal and `request_user_input` is unavailable, keep the current wizard step pending instead of printing its options. `okstra install` enables `features.default_mode_request_user_input` when a Codex home exists, through `ensureCodexQuestionPicker` in `src/lib/host-config.mts`. Restart or resume the Codex session after installation and reread this relay. Changing the feature does not replace the current session's tool catalog. Preserve the existing wizard state-file path and call `okstra wizard step --state-file <existing-path> --no-submit` after resuming; do not initialize a replacement wizard or submit a guessed answer. If installation reports a configuration shape it cannot safely edit, its recovery command is `codex features enable default_mode_request_user_input`. Apply a configuration change only when authorized by the user, otherwise present the recovery command. The feature's availability is reported by `codex features list`; older clients without it need a client update or a mode in which the synchronous tool is permitted.
125
139
 
@@ -21,11 +21,17 @@ import okstra_ctl.model_discovery as model_discovery
21
21
  # `gpt-5.6` is not a slug that catalog offers at all, so it is gone from here;
22
22
  # its rate moved to `_LEGACY_CODEX_PRICING` so past runs still price.
23
23
  CODEX = {
24
- # ChatGPT-account hosts serve these models without per-token billing.
25
- "gpt-6-astra": ModelSpec("gpt-6-astra", "gpt-6-astra", "gpt-6-astra"),
26
- "gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol"),
27
- "gpt-5.6-terra": ModelSpec("gpt-5.6-terra", "gpt-5.6-terra", "gpt-5.6-terra"),
28
- "gpt-5.6-luna": ModelSpec("gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-luna"),
24
+ # 비용은 계정이 구독이든 API 공개 API 단가(입력·캐시 입력·출력 USD/1M)로
25
+ # 추정한다 — 리포트가 답하는 것은 "얼마나 썼는가" 이지 "청구서에 얼마가
26
+ # 찍히는가" 아니다. 단가를 비우면 그 워커의 비용이 `--` 로 빠지고, 접두어
27
+ # 폴백(`_LEGACY_CODEX_PRICING`)에 걸리면 terra·luna 가 sol 단가로 과대
28
+ # 계상된다(실측 2026-09-08). 출처: OpenAI 표준 등급, 2026-09 기준
29
+ # (morphllm.com/openai-api-pricing, cloudzero.com/blog/openai-pricing,
30
+ # layer3labs.io/guides/gpt-6-astra-api-pricing).
31
+ "gpt-6-astra": ModelSpec("gpt-6-astra", "gpt-6-astra", "gpt-6-astra", pricing=(10.0, 1.0, 50.0)),
32
+ "gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", pricing=(5.0, 0.50, 30.0)),
33
+ "gpt-5.6-terra": ModelSpec("gpt-5.6-terra", "gpt-5.6-terra", "gpt-5.6-terra", pricing=(2.0, 0.20, 12.0)),
34
+ "gpt-5.6-luna": ModelSpec("gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-luna", pricing=(0.20, 0.02, 1.20)),
29
35
  # picker 에서는 감춘다. 엔트리는 남긴다 — 과거 run 의 토큰 사용량을
30
36
  # 정산할 때 pricing 을 이 표에서 찾는다.
31
37
  "gpt-5.4-mini": ModelSpec("gpt-5.4-mini", "gpt-5.4-mini", "gpt-5.4-mini", pricing=(0.75, 0.075, 4.50), selectable=False),
@@ -19,9 +19,12 @@ from okstra_ctl.domain.worker_stream import content_block_events
19
19
  KIMI = {
20
20
  "kimi-k3": ModelSpec("kimi-k3", "Kimi K3", "kimi-k3", aliases=("kimi k3",), pricing=(3.00, 0.30, 15.00)),
21
21
  # picker 에서는 감춘다: kimi-k3 와 표시명·실체가 같아 목록에 두 줄로 뜬다.
22
- "k3": ModelSpec("k3", "Kimi K3", "k3", selectable=False),
22
+ # 단가는 kimi-k3 같다 같은 모델의 다른 슬러그이고, `_match_pricing` 은
23
+ # `kimi-k3` 키를 `k3` 관측값에 맞추지 못하므로 행마다 값을 둔다. k3-256k 는
24
+ # Moonshot 이 별도 단가를 공개하지 않아 K3 단가를 쓴다(2026-09-08 확인).
25
+ "k3": ModelSpec("k3", "Kimi K3", "k3", pricing=(3.00, 0.30, 15.00), selectable=False),
23
26
  # picker 에서는 감춘다(사유는 codex 의 gpt-5.4-mini 와 동일).
24
- "k3-256k": ModelSpec("k3-256k", "Kimi K3 256K", "k3-256k", aliases=("kimi k3 256k",), selectable=False),
27
+ "k3-256k": ModelSpec("k3-256k", "Kimi K3 256K", "k3-256k", aliases=("kimi k3 256k",), pricing=(3.00, 0.30, 15.00), selectable=False),
25
28
  }
26
29
 
27
30
 
@@ -1692,16 +1692,39 @@ def worker_session_ids(
1692
1692
  here, the same way `add_observed_session` keeps it for the keys it appends to.
1693
1693
  """
1694
1694
  session_ids: list[str] = []
1695
+ for record in worker_dispatch_records(team_state, worker_id):
1696
+ session_id = str(record.get("sessionId") or "").strip()
1697
+ if session_id and session_id not in session_ids:
1698
+ session_ids.append(session_id)
1699
+ return session_ids
1700
+
1701
+
1702
+ def worker_dispatch_records(
1703
+ team_state: Mapping[str, Any], worker_id: str | None = None
1704
+ ) -> list[Mapping[str, Any]]:
1705
+ """한 워커(또는 run 전체)의 `workerDispatches[]` 행을 기록된 순서대로.
1706
+
1707
+ 사용량 수집이 워커의 실행 증거를 찾는 열쇠는 이 행들이다 — 프롬프트 경로
1708
+ (status 사이드카 → 래퍼 창), 세션 id(claude 트랜스크립트), 워크트리 경로
1709
+ (grok·kimi 는 그 안에서 돌아 세션 디렉터리가 그 경로로 인코딩된다).
1710
+ `workers[]` 행의 `promptPath` 는 첫 dispatch 하나만 가리키므로 재검증·
1711
+ critic-gap·plan-verify 로 다시 띄운 세션은 거기서 보이지 않는다(실측
1712
+ 2026-09-08 jobs implementation-planning r01: codex 5회 dispatch 중 1회만
1713
+ 집계, v2 명부 행 `scope` 는 `promptPath` 가 비어 0회).
1714
+
1715
+ v1 행은 `workerId`, v2 행은 `assignmentRef` 마지막 마디로 워커에 묶인다
1716
+ (`_dispatch_worker_key`). 리스트가 아니면 빈 결과 — `worker_session_ids`
1717
+ 와 같은 이유로 호출자를 깨지 않는다.
1718
+ """
1695
1719
  dispatches = team_state.get("workerDispatches")
1720
+ records: list[Mapping[str, Any]] = []
1696
1721
  for record in dispatches if isinstance(dispatches, list) else []:
1697
1722
  if not isinstance(record, Mapping):
1698
1723
  continue
1699
1724
  if worker_id is not None and _dispatch_worker_key(record) != worker_id:
1700
1725
  continue
1701
- session_id = str(record.get("sessionId") or "").strip()
1702
- if session_id and session_id not in session_ids:
1703
- session_ids.append(session_id)
1704
- return session_ids
1726
+ records.append(record)
1727
+ return records
1705
1728
 
1706
1729
 
1707
1730
  def _dispatch_worker_key(record: Mapping[str, Any]) -> str:
@@ -33,6 +33,7 @@ from .ids import (
33
33
  )
34
34
  from .state import Prompt, WizardError, WizardState, _is_role_selection_step
35
35
  from .prompts import _domain_prompt
36
+ from .picker_navigation import present_picker, accept_picker_answer
36
37
  from .roles import _submit_role_prompt, next_role_prompt
37
38
  from .steps_identity import _submit_task_pick
38
39
  from .steps_analysis import _advance_design_prep_item
@@ -125,11 +126,24 @@ def next_prompt(state: WizardState) -> Prompt:
125
126
  for _ in range(len(STEPS)):
126
127
  prompt = _next_prompt_screen(state)
127
128
  if not _is_lone_free_input_pick(prompt):
128
- return prompt
129
+ return _native_picker_screen(state, prompt)
129
130
  _take_free_input_branch(state, prompt)
130
131
  return _next_prompt_screen(state)
131
132
 
132
133
 
134
+ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
135
+ if "native_single_select" not in state.available_functions:
136
+ return prompt
137
+ if prompt.kind == "pick_group":
138
+ if _interaction_plan(state, prompt).kind == "native-group":
139
+ return prompt
140
+ prompt = prompt.questions[0]
141
+ if _interaction_plan(state, prompt).kind == "native-multi":
142
+ return prompt
143
+ limit = default_host_registry().resolve(state.host_runtime).interaction().native_option_limit
144
+ return present_picker(state, prompt, limit=limit)
145
+
146
+
133
147
  def _next_prompt_screen(state: WizardState) -> Prompt:
134
148
  if state.aborted:
135
149
  return Prompt(step=S_ABORTED, kind="aborted")
@@ -345,6 +359,13 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
345
359
  return {"echo": "", "next": prompt_payload(state, prompt)}
346
360
  plan = _interaction_plan(state, prompt)
347
361
  value = _normalize_interaction_answer(state, prompt, plan, value)
362
+ if plan.kind == "native-single":
363
+ original = _next_prompt_screen(state)
364
+ if original.kind == "pick_group":
365
+ original = next(q for q in original.questions if q.step == prompt.step)
366
+ value = accept_picker_answer(state, original, value)
367
+ if value is None:
368
+ return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
348
369
  if prompt.kind == "pick_group":
349
370
  return _submit_group(state, prompt, value)
350
371
  if _is_role_selection_step(prompt.step):
@@ -0,0 +1,75 @@
1
+ """호스트 선택기 한도 안에서 원래 선택지를 보존하는 화면 변환."""
2
+ from dataclasses import replace
3
+
4
+ from okstra_ctl.wizard_stage_intent import WHOLE_TASK_STAGE
5
+
6
+ from .ids import ALL_STAGES, PICK_TYPE_CUSTOM
7
+ from .state import Option, Prompt, WizardError, WizardState
8
+
9
+ _PAGE_PREFIX = "__okstra_picker_page__:"
10
+ _DONE = "__okstra_picker_done__"
11
+
12
+
13
+ def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
14
+ if prompt.kind != "pick" or not prompt.options:
15
+ return prompt
16
+ selected = state.picker_selected.get(prompt.step, [])
17
+ labels = [option.label for option in prompt.options]
18
+ options = [replace(
19
+ option,
20
+ label=("✓ " if option.value in selected else "") + option.label
21
+ + (f" [{option.value}]" if labels.count(option.label) > 1 else ""),
22
+ ) for option in prompt.options]
23
+ reserve = 2 if prompt.multi else 1
24
+ paged = len(options) + int(prompt.multi) > limit
25
+ size = max(1, limit - reserve) if paged else len(options)
26
+ offset = state.picker_offsets.get(prompt.step, 0)
27
+ offset = offset if 0 <= offset < len(options) else 0
28
+ shown = options[offset:offset + size]
29
+ label = prompt.label
30
+ if paged:
31
+ next_offset = offset + size if offset + size < len(options) else 0
32
+ shown.append(Option(
33
+ f"{_PAGE_PREFIX}{next_offset}",
34
+ "다음 선택지" if next_offset else "처음 선택지로",
35
+ ))
36
+ label += f" ({offset + 1}–{min(offset + size, len(options))}/{len(options)})"
37
+ if prompt.multi:
38
+ shown.append(Option(_DONE, f"선택 완료 ({len(selected)}개)"))
39
+ label += " · 항목을 선택하면 선택/해제됩니다. 완료를 누르면 제출합니다."
40
+ if len(shown) == 1:
41
+ shown.append(Option(f"{_PAGE_PREFIX}0", "다시 보기"))
42
+ shown = [o for o in shown if o.value != PICK_TYPE_CUSTOM] + [
43
+ o for o in shown if o.value == PICK_TYPE_CUSTOM
44
+ ]
45
+ return replace(prompt, label=label, options=shown, multi=False)
46
+
47
+
48
+ def accept_picker_answer(state: WizardState, prompt: Prompt, value: str) -> str | None:
49
+ if value.startswith(_PAGE_PREFIX):
50
+ raw_offset = value.removeprefix(_PAGE_PREFIX)
51
+ if not raw_offset.isdecimal() or not 0 <= int(raw_offset) < len(prompt.options):
52
+ raise WizardError("invalid picker page; select a returned navigation option")
53
+ state.picker_offsets[prompt.step] = int(raw_offset)
54
+ return None
55
+ if not prompt.multi:
56
+ state.picker_offsets.pop(prompt.step, None)
57
+ return value
58
+ if value != _DONE and value not in {o.value for o in prompt.options}:
59
+ raise WizardError("invalid picker choice; select an option from the current screen")
60
+ if value in {_DONE, PICK_TYPE_CUSTOM}:
61
+ selected = state.picker_selected.pop(prompt.step, [])
62
+ state.picker_offsets.pop(prompt.step, None)
63
+ return (
64
+ ",".join(o.value for o in prompt.options if o.value in selected)
65
+ if value == _DONE else value
66
+ )
67
+ selected = state.picker_selected.setdefault(prompt.step, [])
68
+ if value in selected:
69
+ selected.remove(value)
70
+ elif value in {ALL_STAGES, WHOLE_TASK_STAGE}:
71
+ selected[:] = [value]
72
+ else:
73
+ selected[:] = [v for v in selected if v not in {ALL_STAGES, WHOLE_TASK_STAGE}]
74
+ selected.append(value)
75
+ return None
@@ -42,6 +42,8 @@ class WizardState:
42
42
  host_runtime: str = "claude-code"
43
43
  host_entry_mode: str = "current-session"
44
44
  available_functions: list[str] = field(default_factory=list)
45
+ picker_offsets: dict[str, int] = field(default_factory=dict)
46
+ picker_selected: dict[str, list[str]] = field(default_factory=dict)
45
47
 
46
48
  # task identity
47
49
  is_new_task: Optional[bool] = None
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
  from .jsonl_io import iter_jsonl
8
9
  from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
@@ -50,6 +51,67 @@ def codex_session_total(jsonl_path: Path) -> dict:
50
51
  }
51
52
 
52
53
 
54
+ def codex_session_window_total(jsonl_path: Path, since: str, until: str) -> dict:
55
+ """창 안에서 이 세션이 쓴 토큰 — token_count 이벤트의 `last_token_usage` 합.
56
+
57
+ in-session 리드(`entryMode: current-session`)의 rollout 은 run 보다 먼저
58
+ 열려 다른 task 도 돌린 세션이라 마지막 `total_token_usage` 스냅샷은 세션
59
+ 전체다. 누적치는 이벤트마다 `last_token_usage` 만큼 늘고 컨텍스트 압축
60
+ 뒤 되돌아가므로(실측 2026-09-08 jobs 리드 세션: 601 이벤트 중 2회 감소),
61
+ 창 끝·시작 누적치의 차가 아니라 창 안 이벤트의 `last_token_usage` 를
62
+ 더한다. `last_token_usage` 가 없는 옛 기록은 직전 누적치와의 차(0 이상)로
63
+ 센다. 창 안 이벤트가 없으면 `available: False`.
64
+ """
65
+ keys = (
66
+ ("totalTokens", "total_tokens"),
67
+ ("inputTokens", "input_tokens"),
68
+ ("cachedInputTokens", "cached_input_tokens"),
69
+ ("outputTokens", "output_tokens"),
70
+ ("reasoningOutputTokens", "reasoning_output_tokens"),
71
+ )
72
+ sums = {name: 0 for name, _raw in keys}
73
+ previous: dict = {}
74
+ model_val: str | None = None
75
+ cwd_val: str | None = None
76
+ started: str | None = None
77
+ first: str | None = None
78
+ last: str | None = None
79
+ for rec in iter_jsonl(jsonl_path):
80
+ kind = rec.get("type")
81
+ payload = rec.get("payload") or {}
82
+ if kind == "session_meta":
83
+ cwd_val = payload.get("cwd")
84
+ started = payload.get("timestamp")
85
+ elif kind == "turn_context":
86
+ if model_val is None and payload.get("model"):
87
+ model_val = payload["model"]
88
+ elif kind == "event_msg" and payload.get("type") == "token_count":
89
+ info = payload.get("info") or {}
90
+ cumulative = info.get("total_token_usage") or {}
91
+ turn = info.get("last_token_usage")
92
+ timestamp = str(rec.get("timestamp") or "")
93
+ if timestamp and ts_in_window(timestamp, since, until):
94
+ for name, raw in keys:
95
+ if isinstance(turn, dict):
96
+ sums[name] += turn.get(raw, 0) or 0
97
+ else:
98
+ sums[name] += max(
99
+ 0, (cumulative.get(raw, 0) or 0) - (previous.get(raw, 0) or 0)
100
+ )
101
+ if first is None:
102
+ first = timestamp
103
+ last = timestamp
104
+ previous = cumulative
105
+ if first is None:
106
+ return {"totalTokens": 0, "cwd": cwd_val, "model": model_val, "available": False}
107
+ # 창 안에서 열린 세션은 세션 시작이 곧 창 안 활동의 시작이다. 창보다 먼저
108
+ # 열린 세션(in-session 리드)은 창 안 첫 이벤트부터 센다.
109
+ if started and ts_in_window(started, since, until):
110
+ first = started
111
+ return {**sums, "cwd": cwd_val, "model": model_val,
112
+ "startedAt": first, "endedAt": last, "available": True}
113
+
114
+
53
115
  def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None:
54
116
  """Find the latest codex rollout jsonl in the requested window."""
55
117
  sessions = find_codex_sessions(cwd, started_at, ended_at)
@@ -104,14 +166,32 @@ def codex_session_ids(path: Path) -> set[str]:
104
166
  return {item for item in ids if item}
105
167
 
106
168
 
169
+ def _modified_in_window(path: Path, started_at: str, until: str) -> bool:
170
+ try:
171
+ mtime = path.stat().st_mtime
172
+ except OSError:
173
+ return False
174
+ modified = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
175
+ return ts_in_window(modified, started_at, None)
176
+
177
+
107
178
  def find_codex_sessions(
108
179
  cwd: Path,
109
180
  started_at: str,
110
181
  ended_at: str,
111
182
  *,
112
183
  session_roots: tuple[Path, ...] | None = None,
184
+ active_before_start: bool = False,
113
185
  ) -> list[Path]:
114
- """Find codex rollout jsonls whose meta.cwd matches the window."""
186
+ """Find codex rollout jsonls whose meta.cwd matches the window.
187
+
188
+ 기본은 창 안에서 *시작한* 세션이다 — exec 래퍼 워커는 dispatch 마다 새
189
+ rollout 을 열므로 그것으로 충분하다. `active_before_start=True` 는 창보다
190
+ 먼저 시작했지만 창 안에서도 기록이 이어진 세션(파일 mtime 이 창 시작 이후)을
191
+ 더한다. in-session 리드가 그 모양이다: 세션은 run 보다 먼저 태어났으니
192
+ 시작 시각으로 거르면 `no host session started in the run window` 로 빠지고,
193
+ 토큰은 `codex_session_window_total` 이 창으로 잘라 센다.
194
+ """
115
195
  if not started_at or not ended_at:
116
196
  return []
117
197
  if session_roots is None:
@@ -137,7 +217,12 @@ def find_codex_sessions(
137
217
  if session_cwd != target_cwd:
138
218
  continue
139
219
  if not ts_in_window(ts, started_at, ended_at):
140
- continue
220
+ if not (
221
+ active_before_start
222
+ and ts_in_window(ts, None, started_at)
223
+ and _modified_in_window(p, started_at, ended_at)
224
+ ):
225
+ continue
141
226
  candidates.append((ts, p))
142
227
  if not candidates:
143
228
  return []
@@ -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 (
@@ -32,9 +33,9 @@ from .grok import (
32
33
  grok_session_is_non_interactive,
33
34
  grok_session_total,
34
35
  )
35
- from .paths import claude_project_dir, utc_now
36
+ from .paths import claude_project_dir, find_session_jsonl, utc_now
36
37
  from .pricing import antigravity_cost_usd, provider_cost_usd
37
- from okstra_ctl.dispatch_state import worker_session_ids
38
+ from okstra_ctl.dispatch_state import worker_dispatch_records, worker_session_ids
38
39
  from okstra_ctl.models import provider_wrappers
39
40
  from okstra_ctl.wrapper_status import (
40
41
  log_path_for_prompt,
@@ -637,10 +638,18 @@ def _cli_sessions_for_windows(
637
638
  return sessions
638
639
 
639
640
 
640
- def _cli_session_totals(provider: str, session_paths: list[Path]) -> list[dict]:
641
+ def _cli_session_totals(
642
+ provider: str,
643
+ session_paths: list[Path],
644
+ *,
645
+ window: tuple[str, str] | None = None,
646
+ ) -> list[dict]:
647
+ """세션별 합계. `window` 는 codex 리드 전용 — run 보다 먼저 열린 세션을 창으로 자른다."""
641
648
  totals = []
642
649
  for session_path in session_paths:
643
- if provider == "codex":
650
+ if provider == "codex" and window is not None:
651
+ total = codex_session_window_total(session_path, *window)
652
+ elif provider == "codex":
644
653
  total = codex_session_total(session_path)
645
654
  elif provider == "antigravity":
646
655
  total = (
@@ -778,19 +787,50 @@ def collect_cli_usage(
778
787
  return block
779
788
 
780
789
 
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 ""))
790
+ def _prompt_cli_window(
791
+ project_root: Path, prompt_path_raw: str,
792
+ ) -> tuple[tuple[str, str] | None, Path | None]:
793
+ """한 dispatch 의 래퍼 창 — 프롬프트 옆 status 사이드카의 started/ended."""
794
+ prompt_path = _resolve_project_path(project_root, prompt_path_raw)
787
795
  status_path = status_path_for_prompt(prompt_path) if prompt_path is not None else None
788
796
  execution = wrapper_execution(status_path)
789
797
  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
798
+ if not started_at:
799
+ return None, status_path
800
+ return (started_at, execution.get("endedAt") or utc_now()), status_path
801
+
802
+
803
+ def _worker_prompt_paths(worker: dict, records: list[dict]) -> list[str]:
804
+ """이 워커가 띄운 dispatch 의 프롬프트 경로 전부 — 원장 행이 없으면(v1) 명부 행의 하나."""
805
+ paths: list[str] = []
806
+ for record in records:
807
+ raw = str(record.get("promptPath") or "").strip()
808
+ if raw and raw not in paths:
809
+ paths.append(raw)
810
+ if not paths:
811
+ raw = str(worker.get("promptPath") or "").strip()
812
+ if raw:
813
+ paths.append(raw)
814
+ return paths
815
+
816
+
817
+ def _worker_session_cwds(project_root: Path, records: list[dict]) -> list[Path]:
818
+ """세션 디렉터리가 인코딩된 cwd 후보 — grok·kimi 는 워크트리 안에서 돈다.
819
+
820
+ `providers/grok/adapter.py` 는 `request.worktree_path or request.project_root`
821
+ 를 cwd 로 넘기고 세션 디렉터리는 그 경로를 퍼센트 인코딩한 이름이다. 프로젝트
822
+ 루트로만 찾으면 워크트리 안에서 돈 세션은 0건이다(실측 2026-09-08 jobs
823
+ implementation-planning r01: grok critic 세션이 워크트리 이름 아래 있었다).
824
+ codex·claude 는 루트에서 돌므로 워크트리 후보는 빈 결과로 끝난다.
825
+ """
826
+ cwds: list[Path] = []
827
+ for record in records:
828
+ raw = str(record.get("worktreePath") or "").strip()
829
+ if raw and Path(raw) not in cwds:
830
+ cwds.append(Path(raw))
831
+ if project_root not in cwds:
832
+ cwds.append(project_root)
833
+ return cwds
794
834
 
795
835
 
796
836
  def _antigravity_usage_sources(
@@ -828,25 +868,114 @@ def _worker_cli_usage_block(
828
868
  provider: str,
829
869
  project_root: Path,
830
870
  worker: dict,
871
+ records: list[dict],
831
872
  fallback_windows: list[tuple[str, str]],
832
873
  ) -> 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)
874
+ """공급자 프로세스 트랜스크립트와 실행 증거로 워커 사용량 블록을 만든다.
875
+
876
+ dispatch 원장 행마다 래퍼 창을 하나씩 잡아 그 창의 세션을 전부 합산한다 —
877
+ 재검증·critic-gap·plan-verify 로 다시 띄운 실행이 각각 새 세션이므로
878
+ 첫 프롬프트 하나만 보면 나머지는 통째로 빠진다. 실행 상태는 마지막
879
+ dispatch 의 것, 소요 시간은 래퍼 창의 합이다.
880
+ """
881
+ windows: list[tuple[str, str]] = []
882
+ prompt_paths = _worker_prompt_paths(worker, records)
883
+ status_paths: list[Path | None] = []
884
+ for raw in prompt_paths:
885
+ window, status_path = _prompt_cli_window(project_root, raw)
886
+ status_paths.append(status_path)
887
+ if window is not None:
888
+ windows.append(window)
889
+ used_fallback = not windows
890
+ if used_fallback:
891
+ windows = fallback_windows
892
+ session_paths: list[Path] = []
893
+ for cwd in _worker_session_cwds(project_root, records):
894
+ for path in _cli_sessions_for_windows(provider, cwd, windows):
895
+ if path not in session_paths:
896
+ session_paths.append(path)
897
+ status_path = status_paths[-1] if status_paths else None
840
898
  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(
899
+ for raw, source_status in zip(prompt_paths, status_paths):
900
+ for source in _antigravity_usage_sources(
901
+ project_root, {"promptPath": raw}, source_status,
902
+ ):
903
+ if source not in session_paths:
904
+ session_paths.append(source)
905
+ block = collect_cli_usage(
845
906
  provider=provider,
846
907
  status_path=status_path,
847
908
  sessions=session_paths,
848
909
  fallback_window_used=used_fallback,
849
910
  )
911
+ durations = [
912
+ wrapper_execution(path).get("durationMs")
913
+ for path in status_paths
914
+ if path is not None
915
+ ]
916
+ measured = [value for value in durations if isinstance(value, int)]
917
+ if len(measured) > 1:
918
+ block["durationMs"] = sum(measured)
919
+ return block
920
+
921
+
922
+ def _wrapper_claude_worker(worker: dict) -> bool:
923
+ """호스트가 claude 가 아닐 때 래퍼로 띄운 claude 워커."""
924
+ provider = str(worker.get("provider") or worker.get("agent") or "").strip()
925
+ runner = str(worker.get("runner") or "").strip()
926
+ return provider in _SESSION_JSONL_PROVIDERS and runner in {"", "cli-wrapper"}
927
+
928
+
929
+ def _claude_wrapper_usage_block(
930
+ *,
931
+ project_root: Path,
932
+ worker_id: str,
933
+ state: dict,
934
+ window: tuple[str | None, str | None],
935
+ incremental: bool,
936
+ ) -> dict:
937
+ """래퍼로 띄운 claude 워커의 세션 jsonl — dispatch 가 발급한 세션 id 로 찾는다.
938
+
939
+ `okstra-claude-exec.sh` 는 `claude -p --session-id <id>` 로 돌고 그 id 는
940
+ `workerDispatches[].sessionId` 에 적힌다(`_dispatch_record`). 트랜스크립트는
941
+ `~/.claude/projects/<루트 인코딩>/<id>.jsonl` 에 있는데, claude 가 아닌
942
+ 호스트의 수집기는 이 워커를 "세션 jsonl 공급자" 라며 건너뛰어 항상
943
+ `unavailable` 이었다(실측 2026-09-08 jobs implementation-planning r01:
944
+ claude planner 5회·report-writer 2회 세션이 전부 디스크에 있었다). claude
945
+ 호스트의 `collect_claude_runtime_usage` 가 같은 id 로 하는 일을 여기서 한다.
946
+ """
947
+ since, until = window
948
+ session_ids = worker_session_ids(state, worker_id)
949
+ if not session_ids:
950
+ return na_block(
951
+ "claude wrapper worker has no dispatch session id recorded in workerDispatches"
952
+ )
953
+ totals: list[dict] = []
954
+ paths: list[Path] = []
955
+ attributed: list[str] = []
956
+ for session_id in session_ids:
957
+ path = find_session_jsonl(session_id, project_root)
958
+ if path is None:
959
+ continue
960
+ session_totals = claude_session_totals(
961
+ path, since=since, until=until, incremental=incremental,
962
+ )
963
+ # 창 밖 세션은 0 토큰 totals 가 되어 허위 0 으로 보고된다 — claude 호스트
964
+ # 경로와 같은 `startedAt` 검사로 거른다.
965
+ if not session_totals.get("startedAt"):
966
+ continue
967
+ totals.append(session_totals)
968
+ paths.append(path)
969
+ attributed.append(session_id)
970
+ if not totals:
971
+ return na_block(
972
+ "claude session jsonl not found under "
973
+ f"{claude_project_dir(project_root)} for dispatch session ids {session_ids}"
974
+ )
975
+ block = usage_block(_aggregate_totals(totals), source="claude-jsonl")
976
+ block["sessionIds"] = attributed
977
+ block["sessionPaths"] = [str(path) for path in paths]
978
+ return block
850
979
 
851
980
 
852
981
  def _attach_cli_usage(
@@ -886,27 +1015,46 @@ def _attach_cli_usage(
886
1015
 
887
1016
 
888
1017
  def _collect_cli_runtime_usage(
889
- state: dict, project_root: Path, team_state_path: Path | None = None,
1018
+ state: dict,
1019
+ project_root: Path,
1020
+ team_state_path: Path | None = None,
1021
+ *,
1022
+ incremental: bool = True,
890
1023
  ) -> dict:
891
1024
  windows_by_worker = _codex_worker_windows(project_root, state)
1025
+ run_window: tuple[str | None, str | None] = (None, None)
1026
+ if team_state_path is not None:
1027
+ run_window = resolve_run_window(team_state_path, state)
892
1028
  for worker in state.get("workers", []):
893
1029
  if not isinstance(worker, dict):
894
1030
  continue
1031
+ worker_id = str(worker.get("workerId") or "").strip()
1032
+ records = [
1033
+ dict(record) for record in worker_dispatch_records(state, worker_id)
1034
+ ] if worker_id else []
895
1035
  provider = _cli_assignment_provider(worker)
896
- if not provider:
1036
+ if provider:
1037
+ worker["usage"] = _worker_cli_usage_block(
1038
+ provider=provider,
1039
+ project_root=project_root,
1040
+ worker=worker,
1041
+ records=records,
1042
+ fallback_windows=windows_by_worker.get(worker_id, []),
1043
+ )
1044
+ elif worker_id and _wrapper_claude_worker(worker):
1045
+ worker["usage"] = _claude_wrapper_usage_block(
1046
+ project_root=project_root,
1047
+ worker_id=worker_id,
1048
+ state=state,
1049
+ window=run_window,
1050
+ incremental=incremental,
1051
+ )
1052
+ else:
897
1053
  worker["usage"] = na_block(
898
1054
  "worker usage is not read from a provider CLI transcript "
899
- "(host-native, session-jsonl provider, or no registered CLI provider): "
1055
+ "(host-native or no registered CLI provider): "
900
1056
  f"{worker.get('provider') or worker.get('agent') or worker.get('workerId')}"
901
1057
  )
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
1058
  state["leadUsage"] = _cli_lead_usage(state, project_root, team_state_path)
911
1059
  _populate_usage_summary(state, team_name=resolve_team_name(state),
912
1060
  sessions_found=0, needle_source="none")
@@ -952,22 +1100,29 @@ def _cli_lead_usage(
952
1100
  if isinstance(worker, dict)
953
1101
  for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
954
1102
  }
1103
+ # in-session codex 리드는 run 보다 먼저 열린 세션이다 — 창 안에서 시작한
1104
+ # 세션만 보면 없다고 나오고, 세션 전체를 더하면 다른 task 의 턴이 섞인다.
1105
+ # 창 안에서 활동한 세션을 후보에 넣고 토큰은 창으로 잘라 센다.
1106
+ window = (run_since, run_until) if provider == "codex" else None
1107
+ if provider == "codex":
1108
+ candidates = find_codex_sessions(
1109
+ project_root, run_since, run_until, active_before_start=True,
1110
+ )
1111
+ else:
1112
+ candidates = _cli_sessions_for_windows(
1113
+ provider, project_root, [(run_since, run_until)],
1114
+ )
955
1115
  sessions = _select_cli_lead_sessions(
956
1116
  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
- ],
1117
+ [path for path in candidates if path not in worker_paths],
964
1118
  state,
1119
+ window=window,
965
1120
  )
966
- totals = _cli_session_totals(provider, sessions)
1121
+ totals = _cli_session_totals(provider, sessions, window=window)
967
1122
  if not totals:
968
1123
  return na_block(
969
1124
  f"{provider} lead usage accounting is unavailable because no host session "
970
- "started in the run window."
1125
+ "was active in the run window."
971
1126
  )
972
1127
  return _cli_usage_block(provider, _aggregate_totals(totals), sessions)
973
1128
 
@@ -981,7 +1136,11 @@ def _cli_worker_session(provider: str, path: Path) -> bool:
981
1136
 
982
1137
 
983
1138
  def _select_cli_lead_sessions(
984
- provider: str, sessions: list[Path], state: dict,
1139
+ provider: str,
1140
+ sessions: list[Path],
1141
+ state: dict,
1142
+ *,
1143
+ window: tuple[str, str] | None = None,
985
1144
  ) -> list[Path]:
986
1145
  """워커 래퍼 세션을 빼고, 아이디가 있으면 그 세션, 없으면 벽시계가 가장 긴 대화."""
987
1146
  candidates = [
@@ -997,7 +1156,7 @@ def _select_cli_lead_sessions(
997
1156
  best = candidates[0]
998
1157
  best_ms = -1
999
1158
  for path in candidates:
1000
- totals = _cli_session_totals(provider, [path])
1159
+ totals = _cli_session_totals(provider, [path], window=window)
1001
1160
  total = totals[0] if totals else {}
1002
1161
  wall = _wall_ms(total.get("startedAt"), total.get("endedAt")) if total else None
1003
1162
  if wall is None:
@@ -1056,14 +1215,22 @@ def _populate_usage_summary(
1056
1215
  worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
1057
1216
 
1058
1217
  unmatched_models: list[str] = []
1059
- if lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
1218
+ if (
1219
+ lead.get("model")
1220
+ and lead.get("estimatedCostUsd") is None
1221
+ and lead.get("cliEstimatedCostUsd") is None
1222
+ and (lead.get("totalTokens") or 0) > 0
1223
+ ):
1060
1224
  unmatched_models.append(lead["model"])
1061
1225
  for w in workers:
1062
1226
  u = w.get("usage") or {}
1227
+ # CLI 블록의 가격은 `cliEstimatedCostUsd` 에 붙는다 — 그 키를 안 보면
1228
+ # 가격이 붙은 grok 도 미매칭으로 찍힌다.
1063
1229
  if (
1064
1230
  u.get("source") not in {"codex-cli", "agy-cli"}
1065
1231
  and u.get("model")
1066
1232
  and u.get("estimatedCostUsd") is None
1233
+ and u.get("cliEstimatedCostUsd") is None
1067
1234
  and (u.get("totalTokens") or 0) > 0
1068
1235
  ):
1069
1236
  unmatched_models.append(u["model"])
@@ -1269,6 +1436,12 @@ def collect_claude_runtime_usage(
1269
1436
  provider=provider,
1270
1437
  project_root=cwd,
1271
1438
  worker=worker,
1439
+ records=[
1440
+ dict(record)
1441
+ for record in worker_dispatch_records(
1442
+ state, str(worker_id or "").strip()
1443
+ )
1444
+ ] if worker_id else [],
1272
1445
  fallback_windows=cli_windows_by_worker.get(
1273
1446
  str(worker_id or "").strip(), []
1274
1447
  ),
@@ -1363,7 +1536,9 @@ def collect_cli_runtime_usage(
1363
1536
  ) -> dict:
1364
1537
  state = json.loads(team_state_path.read_text())
1365
1538
  cwd = project_root or _infer_project_root(team_state_path, state)
1366
- return _collect_cli_runtime_usage(state, cwd, team_state_path)
1539
+ return _collect_cli_runtime_usage(
1540
+ state, cwd, team_state_path, incremental=incremental,
1541
+ )
1367
1542
 
1368
1543
 
1369
1544
  def collect(
@@ -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.