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.
- package/docs/architecture.md +6 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- 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/dispatch_state.py +27 -4
- 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 +224 -49
- package/runtime/python/okstra_token_usage/pricing.py +8 -4
- package/runtime/skills/okstra-run/SKILL.md +6 -2
package/docs/architecture.md
CHANGED
|
@@ -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 `~/.
|
|
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
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
782
|
-
project_root: Path,
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|