ccx-native 0.0.1 → 0.1.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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ import time
5
+ from typing import Any
6
+
7
+ from ccx.store.paths import iter_subagent_transcripts
8
+ from ccx.claude.transcript import (
9
+ _iter_transcript,
10
+ _merge_usage_objects,
11
+ _parse_event_ts_ms,
12
+ _process_command_message,
13
+ )
14
+
15
+ ANALYTICS_SCHEMA_VERSION = 3
16
+ TOKEN_FIELDS = (
17
+ ("input", "input_tokens"),
18
+ ("output", "output_tokens"),
19
+ ("cacheRead", "cache_read_input_tokens"),
20
+ ("cacheCreation", "cache_creation_input_tokens"),
21
+ )
22
+
23
+
24
+ def _zero_tokens() -> dict[str, int]:
25
+ return {"input": 0, "output": 0, "cacheRead": 0, "cacheCreation": 0}
26
+
27
+
28
+ def _add_tokens(dst: dict[str, int], src: dict[str, int]) -> None:
29
+ for key in _zero_tokens():
30
+ dst[key] = int(dst.get(key) or 0) + int(src.get(key) or 0)
31
+
32
+
33
+ def _tokens_from_usage(usage: dict[str, Any]) -> dict[str, int]:
34
+ out = _zero_tokens()
35
+ for out_key, usage_key in TOKEN_FIELDS:
36
+ val = usage.get(usage_key)
37
+ if isinstance(val, int):
38
+ out[out_key] += val
39
+ return out
40
+
41
+
42
+ def _message_id(message: dict[str, Any]) -> str | None:
43
+ value = message.get("id")
44
+ return value if isinstance(value, str) and value else None
45
+
46
+
47
+ def _add_model_usage(
48
+ prompt: dict[str, Any],
49
+ totals: dict[str, Any],
50
+ model: str | None,
51
+ tokens: dict[str, int],
52
+ *,
53
+ level: str = "main",
54
+ events: int = 1,
55
+ ) -> None:
56
+ if model == "<synthetic>":
57
+ return
58
+ model = model or "unknown"
59
+ prompt_model = prompt.setdefault("byModel", {}).setdefault(
60
+ model, {"events": 0, "tokens": _zero_tokens()}
61
+ )
62
+ prompt_model["events"] += int(events)
63
+ _add_tokens(prompt_model["tokens"], tokens)
64
+
65
+ bucket = _model_bucket(totals, model)
66
+ bucket["events"] += int(events)
67
+ bucket["level"] = level if bucket.get("level") in (None, level) else "mixed"
68
+ _add_tokens(bucket["tokens"], tokens)
69
+
70
+
71
+ def _content_blocks(content: Any) -> list[dict[str, Any]]:
72
+ if isinstance(content, list):
73
+ return [c for c in content if isinstance(c, dict)]
74
+ if isinstance(content, str):
75
+ return [{"type": "text", "text": content}]
76
+ return []
77
+
78
+
79
+ def _text_from_user_content(content: Any) -> str:
80
+ parts = []
81
+ for block in _content_blocks(content):
82
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
83
+ processed = _process_command_message(block["text"])
84
+ if processed is not None:
85
+ parts.append(processed)
86
+ return "\n".join(parts).strip()
87
+
88
+
89
+ def _is_synthetic_user_text(text: str) -> bool:
90
+ stripped = text.strip()
91
+ if stripped == "Continue from where you left off.":
92
+ return True
93
+ return stripped.startswith(("<bash-input>", "<bash-stdout>", "<bash-stderr>"))
94
+
95
+
96
+ def _is_pure_tool_result(content: Any) -> bool:
97
+ blocks = _content_blocks(content)
98
+ return bool(blocks) and all(b.get("type") == "tool_result" for b in blocks)
99
+
100
+
101
+ def _contains_queued_command(value: Any) -> int:
102
+ if isinstance(value, dict):
103
+ count = 1 if value.get("type") == "queued_command" else 0
104
+ return count + sum(_contains_queued_command(v) for v in value.values())
105
+ if isinstance(value, list):
106
+ return sum(_contains_queued_command(v) for v in value)
107
+ return 0
108
+
109
+
110
+ def _model_bucket(totals: dict[str, Any], model: str) -> dict[str, Any]:
111
+ by_model = totals.setdefault("byModel", {})
112
+ bucket = by_model.setdefault(model, {"events": 0, "tokens": _zero_tokens()})
113
+ return bucket
114
+
115
+
116
+ def _new_prompt(prompt_id: str, ts_ms: int | None, turn_index: int) -> dict[str, Any]:
117
+ return {
118
+ "promptId": prompt_id,
119
+ "turnIndex": turn_index,
120
+ "tsMs": ts_ms,
121
+ "durationMs": 0,
122
+ "tokens": _zero_tokens(),
123
+ "byModel": {},
124
+ "tools": {},
125
+ "toolErrors": 0,
126
+ "askUserQuestions": 0,
127
+ "steering": {"enqueued": 0, "boundary": 0, "idle": 0},
128
+ "subagents": {"count": 0, "tokens": _zero_tokens(), "byAgent": {}},
129
+ }
130
+
131
+
132
+ def _add_model_tokens(
133
+ prompt: dict[str, Any],
134
+ totals: dict[str, Any],
135
+ model: str | None,
136
+ tokens: dict[str, int],
137
+ *,
138
+ level: str = "main",
139
+ events: int = 1,
140
+ ) -> None:
141
+ _add_model_usage(
142
+ prompt, totals, model, tokens, level=level, events=events
143
+ )
144
+
145
+
146
+ def _add_tool(prompt: dict[str, Any], totals: dict[str, Any], name: str) -> None:
147
+ prompt_tools = prompt.setdefault("tools", {})
148
+ prompt_tools[name] = int(prompt_tools.get(name) or 0) + 1
149
+ totals_tools = totals.setdefault("tools", {})
150
+ totals_tools[name] = int(totals_tools.get(name) or 0) + 1
151
+ if name == "AskUserQuestion":
152
+ prompt["askUserQuestions"] = int(prompt.get("askUserQuestions") or 0) + 1
153
+ totals["askUserQuestions"] = int(totals.get("askUserQuestions") or 0) + 1
154
+
155
+
156
+ def _prompt_by_id(prompts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
157
+ return {p["promptId"]: p for p in prompts}
158
+
159
+
160
+ def _agent_label(evt: dict[str, Any], path: pathlib.Path) -> str:
161
+ return (
162
+ evt.get("attributionAgent")
163
+ or evt.get("attributionSkill")
164
+ or evt.get("entrypoint")
165
+ or evt.get("agentId")
166
+ or path.stem
167
+ )
168
+
169
+
170
+ def _read_subagent_rollup(
171
+ path: pathlib.Path, stats: dict[str, int]
172
+ ) -> dict[str, Any]:
173
+ rollup = {
174
+ "path": str(path),
175
+ "agent": path.stem,
176
+ "promptId": None,
177
+ "tokens": _zero_tokens(),
178
+ "byModel": {},
179
+ }
180
+ local_stats: dict[str, int] = {}
181
+ usage_by_message: dict[str, dict[str, Any]] = {}
182
+ idless_usage_index = 0
183
+ try:
184
+ for evt in _iter_transcript(path, local_stats, include_sidechain=True):
185
+ if rollup["promptId"] is None and evt.get("promptId"):
186
+ rollup["promptId"] = evt.get("promptId")
187
+ rollup["agent"] = _agent_label(evt, path)
188
+ msg = evt.get("message") or {}
189
+ if evt.get("type") != "assistant" or not isinstance(msg, dict):
190
+ continue
191
+ usage = msg.get("usage") or {}
192
+ if not isinstance(usage, dict) or not usage:
193
+ continue
194
+ mid = _message_id(msg)
195
+ if mid is None:
196
+ idless_usage_index += 1
197
+ key = f"<idless>:{idless_usage_index}"
198
+ else:
199
+ key = mid
200
+ model = msg.get("model")
201
+ model = model if isinstance(model, str) and model else None
202
+ prior = usage_by_message.get(key)
203
+ if prior is None:
204
+ usage_by_message[key] = {
205
+ "usage": _merge_usage_objects(None, usage),
206
+ "model": model,
207
+ }
208
+ else:
209
+ prior["usage"] = _merge_usage_objects(
210
+ prior.get("usage"), usage
211
+ )
212
+ if model is not None:
213
+ prior["model"] = model
214
+ except OSError:
215
+ stats["subagentFilesUnreadable"] = (
216
+ stats.get("subagentFilesUnreadable", 0) + 1
217
+ )
218
+
219
+ for item in usage_by_message.values():
220
+ tokens = _tokens_from_usage(item.get("usage") or {})
221
+ _add_tokens(rollup["tokens"], tokens)
222
+ model = item.get("model")
223
+ if model and model != "<synthetic>":
224
+ bucket = rollup["byModel"].setdefault(
225
+ model, {"events": 0, "tokens": _zero_tokens()}
226
+ )
227
+ bucket["events"] += 1
228
+ _add_tokens(bucket["tokens"], tokens)
229
+
230
+ stats["malformedLines"] = (
231
+ stats.get("malformedLines", 0)
232
+ + int(local_stats.get("malformedLines") or 0)
233
+ )
234
+ return rollup
235
+
236
+
237
+ def _add_subagent_rollups(rec: dict[str, Any], prompts: list[dict[str, Any]],
238
+ totals: dict[str, Any], stats: dict[str, int]) -> None:
239
+ cwd = rec.get("cwd") or ""
240
+ session_id = rec.get("sessionId") or ""
241
+ if not cwd or not session_id:
242
+ return
243
+ prompt_map = _prompt_by_id(prompts)
244
+ fallback_prompt = prompts[-1] if prompts else None
245
+ sub_total = totals.setdefault("subagents", {"count": 0, "tokens": _zero_tokens(), "byAgent": {}})
246
+ for path in iter_subagent_transcripts(cwd, session_id) or []:
247
+ rollup = _read_subagent_rollup(path, stats)
248
+ prompt = prompt_map.get(rollup.get("promptId")) or fallback_prompt
249
+ if prompt is None:
250
+ continue
251
+ sub_total["count"] = int(sub_total.get("count") or 0) + 1
252
+ _add_tokens(sub_total["tokens"], rollup["tokens"])
253
+ _add_tokens(totals["tokens"], rollup["tokens"])
254
+ agent_bucket = sub_total.setdefault("byAgent", {}).setdefault(
255
+ rollup["agent"], {"count": 0, "tokens": _zero_tokens(), "byModel": {}}
256
+ )
257
+ agent_bucket["count"] += 1
258
+ _add_tokens(agent_bucket["tokens"], rollup["tokens"])
259
+ for model, model_data in rollup["byModel"].items():
260
+ abm = agent_bucket["byModel"].setdefault(model, {"events": 0, "tokens": _zero_tokens()})
261
+ abm["events"] += int(model_data.get("events") or 0)
262
+ _add_tokens(abm["tokens"], model_data.get("tokens") or {})
263
+ _add_model_tokens(
264
+ prompt,
265
+ totals,
266
+ model,
267
+ model_data.get("tokens") or {},
268
+ level="subagent",
269
+ events=int(model_data.get("events") or 0),
270
+ )
271
+
272
+ prompt_sub = prompt.setdefault("subagents", {"count": 0, "tokens": _zero_tokens(), "byAgent": {}})
273
+ prompt_sub["count"] = int(prompt_sub.get("count") or 0) + 1
274
+ _add_tokens(prompt_sub["tokens"], rollup["tokens"])
275
+ _add_tokens(prompt["tokens"], rollup["tokens"])
276
+ ps_agent = prompt_sub.setdefault("byAgent", {}).setdefault(
277
+ rollup["agent"], {"count": 0, "tokens": _zero_tokens()}
278
+ )
279
+ ps_agent["count"] += 1
280
+ _add_tokens(ps_agent["tokens"], rollup["tokens"])
281
+
282
+
283
+ def compute_session_analytics(
284
+ rec: dict[str, Any], *, before_offset: int | None = None,
285
+ ) -> dict[str, Any]:
286
+ stats = {"malformedLines": 0, "subagentFilesUnreadable": 0}
287
+ totals: dict[str, Any] = {
288
+ "turns": 0,
289
+ "startedAt": None,
290
+ "endedAt": None,
291
+ "activeMs": 0,
292
+ "transcriptSpanMs": 0,
293
+ "tokens": _zero_tokens(),
294
+ "byModel": {},
295
+ "tools": {},
296
+ "toolErrors": 0,
297
+ "askUserQuestions": 0,
298
+ "subagents": {"count": 0, "tokens": _zero_tokens(), "byAgent": {}},
299
+ "steering": {"enqueued": 0, "boundary": 0, "idle": 0},
300
+ }
301
+ prompts: list[dict[str, Any]] = []
302
+ prompts_by_id: dict[str, dict[str, Any]] = {}
303
+ current: dict[str, Any] | None = None
304
+ first_ts: int | None = None
305
+ last_ts: int | None = None
306
+ generated_prompt_index = 0
307
+ queued_pending = 0
308
+ usage_by_message: dict[str, dict[str, Any]] = {}
309
+ idless_usage_index = 0
310
+ seen_tool_blocks: set[tuple[str, str]] = set()
311
+
312
+ transcript = rec.get("transcript") or ""
313
+ # A fork's transcript opens with its source's copied history; usage counts
314
+ # only what this worker incurred after the fork point.
315
+ from ccx.ops.forking import inherited_offset
316
+ for evt in _iter_transcript(
317
+ transcript, stats, after_offset=inherited_offset(rec),
318
+ before_offset=before_offset,
319
+ ):
320
+ ts_ms = _parse_event_ts_ms(evt)
321
+ if ts_ms is not None:
322
+ first_ts = ts_ms if first_ts is None else min(first_ts, ts_ms)
323
+ last_ts = ts_ms if last_ts is None else max(last_ts, ts_ms)
324
+
325
+ etype = evt.get("type")
326
+ if etype == "queue-operation":
327
+ op = evt.get("operation")
328
+ if op == "enqueue":
329
+ queued_pending += 1
330
+ totals["steering"]["enqueued"] += 1
331
+ if current is not None:
332
+ current["steering"]["enqueued"] += 1
333
+ elif op in ("dequeue", "remove"):
334
+ queued_pending = max(0, queued_pending - 1)
335
+ if current is not None and ts_ms is not None:
336
+ current["_lastTsMs"] = ts_ms
337
+ continue
338
+
339
+ queued_attachments = _contains_queued_command(evt)
340
+ if queued_attachments:
341
+ totals["steering"]["boundary"] += queued_attachments
342
+ if current is not None:
343
+ current["steering"]["boundary"] += queued_attachments
344
+ if ts_ms is not None:
345
+ current["_lastTsMs"] = ts_ms
346
+ queued_pending = max(0, queued_pending - queued_attachments)
347
+
348
+ msg = evt.get("message") or {}
349
+ content = msg.get("content") if isinstance(msg, dict) else None
350
+ if etype == "user":
351
+ if _is_pure_tool_result(content):
352
+ for block in _content_blocks(content):
353
+ if block.get("type") == "tool_result" and block.get("is_error"):
354
+ totals["toolErrors"] += 1
355
+ if current is not None:
356
+ current["toolErrors"] += 1
357
+ if current is not None and ts_ms is not None:
358
+ current["_lastTsMs"] = ts_ms
359
+ continue
360
+ text = _text_from_user_content(content)
361
+ if text and not _is_synthetic_user_text(text):
362
+ prompt_id = str(evt.get("promptId") or f"turn-{generated_prompt_index + 1}")
363
+ if prompt_id in prompts_by_id:
364
+ current = prompts_by_id[prompt_id]
365
+ else:
366
+ generated_prompt_index += 1
367
+ current = _new_prompt(prompt_id, ts_ms, generated_prompt_index)
368
+ prompts_by_id[prompt_id] = current
369
+ prompts.append(current)
370
+ if queued_pending:
371
+ current["steering"]["idle"] += queued_pending
372
+ totals["steering"]["idle"] += queued_pending
373
+ queued_pending = 0
374
+ continue
375
+
376
+ if etype != "assistant" or current is None or not isinstance(msg, dict):
377
+ if current is not None and ts_ms is not None:
378
+ current["_lastTsMs"] = ts_ms
379
+ continue
380
+ if msg.get("model") == "<synthetic>":
381
+ continue
382
+ usage = msg.get("usage") or {}
383
+ if isinstance(usage, dict) and usage:
384
+ model = msg.get("model")
385
+ model = model if isinstance(model, str) and model else None
386
+ mid = _message_id(msg)
387
+ if mid is None:
388
+ idless_usage_index += 1
389
+ key = f"<idless>:{idless_usage_index}"
390
+ else:
391
+ key = mid
392
+ prior = usage_by_message.get(key)
393
+ if prior is None:
394
+ usage_by_message[key] = {
395
+ "usage": _merge_usage_objects(None, usage),
396
+ "prompt": current,
397
+ "model": model,
398
+ }
399
+ else:
400
+ prior["usage"] = _merge_usage_objects(
401
+ prior.get("usage"), usage
402
+ )
403
+ if model is not None:
404
+ prior["model"] = model
405
+ # First prompt attribution wins. A repeated API message id
406
+ # appearing after another user boundary is still one message,
407
+ # not fresh usage for the later prompt.
408
+ for block in _content_blocks(content):
409
+ if block.get("type") == "tool_use":
410
+ mid = _message_id(msg)
411
+ block_id = block.get("id")
412
+ if (
413
+ isinstance(mid, str)
414
+ and mid
415
+ and isinstance(block_id, str)
416
+ and block_id
417
+ ):
418
+ tool_key = (mid, block_id)
419
+ if tool_key in seen_tool_blocks:
420
+ continue
421
+ seen_tool_blocks.add(tool_key)
422
+ _add_tool(current, totals, block.get("name") or "?")
423
+ if ts_ms is not None:
424
+ current["_lastTsMs"] = ts_ms
425
+
426
+ for item in usage_by_message.values():
427
+ target_prompt = item.get("prompt")
428
+ if not isinstance(target_prompt, dict):
429
+ continue
430
+ tokens = _tokens_from_usage(item.get("usage") or {})
431
+ _add_tokens(target_prompt["tokens"], tokens)
432
+ _add_tokens(totals["tokens"], tokens)
433
+ _add_model_usage(
434
+ target_prompt,
435
+ totals,
436
+ item.get("model"),
437
+ tokens,
438
+ level="main",
439
+ events=1,
440
+ )
441
+
442
+ totals["turns"] = len(prompts)
443
+ for prompt in prompts:
444
+ last_prompt_ts = prompt.pop("_lastTsMs", None)
445
+ if prompt.get("tsMs") is not None and last_prompt_ts is not None:
446
+ prompt["durationMs"] = max(0, last_prompt_ts - prompt["tsMs"])
447
+ totals["activeMs"] += int(prompt.get("durationMs") or 0)
448
+ if first_ts is not None and last_ts is not None:
449
+ totals["startedAt"] = first_ts
450
+ totals["endedAt"] = last_ts
451
+ totals["transcriptSpanMs"] = max(0, last_ts - first_ts)
452
+
453
+ _add_subagent_rollups(rec, prompts, totals, stats)
454
+ totals["subagents"]["tokens"] = dict(totals["subagents"]["tokens"])
455
+
456
+ return {
457
+ "analyticsSchemaVersion": ANALYTICS_SCHEMA_VERSION,
458
+ "generatedAt": int(time.time() * 1000),
459
+ "short": rec.get("short"),
460
+ "sessionId": rec.get("sessionId"),
461
+ "name": rec.get("name") or rec.get("short"),
462
+ "cwd": rec.get("cwd"),
463
+ "totals": totals,
464
+ "prompts": prompts,
465
+ "stats": stats,
466
+ }