shikiru-cli 0.2.0__py3-none-any.whl

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 (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1018 @@
1
+ """Claude Code stream-json turn parser and process sequence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import uuid
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ from shikiru.agents.contracts import (
13
+ PERCENT,
14
+ QUOTA_WINDOW_FIVE_HOUR,
15
+ QUOTA_WINDOW_SEVEN_DAY,
16
+ AgentSignal,
17
+ Compacted,
18
+ FailureKind,
19
+ Message,
20
+ QuotaReported,
21
+ QuotaWindow,
22
+ SessionStarted,
23
+ ToolFinished,
24
+ ToolOutcome,
25
+ ToolStarted,
26
+ TurnEnded,
27
+ TurnFailed,
28
+ TurnSpec,
29
+ Usage,
30
+ UsageReported,
31
+ module_command,
32
+ )
33
+ from shikiru.agents.sdk.turn import (
34
+ EMPTY_OUTPUT,
35
+ TurnProcess,
36
+ TurnProcessResult,
37
+ TurnProcessSequence,
38
+ )
39
+ from shikiru.plugins.agents.claude.context import (
40
+ compact_limit_tokens,
41
+ read_transcript,
42
+ transcript_context_tokens,
43
+ )
44
+ from shikiru.plugins.agents.claude.options import (
45
+ ADD_DIR_FLAG,
46
+ CLAUDE_COMMAND,
47
+ EFFORT_FLAG,
48
+ MODEL_FLAG,
49
+ OUTPUT_FORMAT_FLAG,
50
+ PRINT_FLAG,
51
+ RESUME_FLAG,
52
+ SESSION_ID_FLAG,
53
+ SETTINGS_FLAG,
54
+ STATUS_FORBIDDEN,
55
+ STATUS_PAYMENT_REQUIRED,
56
+ STATUS_TOO_MANY_REQUESTS,
57
+ STATUS_UNAUTHORIZED,
58
+ )
59
+
60
+ if TYPE_CHECKING:
61
+ from collections.abc import Callable, Sequence
62
+ from datetime import datetime
63
+
64
+ from shikiru.agents.sdk.reset_time import ResetTimeParser
65
+ from shikiru.clock import Clock
66
+
67
+ _STREAM_JSON = "stream-json"
68
+ _VERBOSE_FLAG = "--verbose"
69
+ _COMPACT_PROMPT = "/compact"
70
+ CONTINUE_PROMPT = "Continue the task exactly where you stopped."
71
+ HOOK_MODULE = "shikiru.plugins.agents.claude.hook"
72
+ COMPACT_MARKER_SUFFIX = ".compact-marker"
73
+ COMPACT_STATE_SUFFIX = ".compact-state"
74
+ CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
75
+ HOME_ENV_VAR = "HOME"
76
+ CLAUDE_DIRNAME = ".claude"
77
+ PROJECTS_DIRNAME = "projects"
78
+ TRANSCRIPT_SUFFIX = ".jsonl"
79
+ _HOOKS_FIELD = "hooks"
80
+ _POST_TOOL_USE_EVENT = "PostToolUse"
81
+ _HOOK_MATCHER_FIELD = "matcher"
82
+ _ANY_TOOL_MATCHER = "*"
83
+ _HOOK_TYPE_FIELD = "type"
84
+ _COMMAND_HOOK_TYPE = "command"
85
+ _HOOK_COMMAND_FIELD = "command"
86
+ _SYSTEM_TYPE = "system"
87
+ _ASSISTANT_TYPE = "assistant"
88
+ _USER_TYPE = "user"
89
+ _RESULT_TYPE = "result"
90
+ _RATE_LIMIT_TYPE = "rate_limit_event"
91
+ _INIT_SUBTYPE = "init"
92
+ _COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
93
+ _SUCCESS_SUBTYPE = "success"
94
+ _ERROR_TERMINAL_REASONS = (
95
+ "error",
96
+ "api_error",
97
+ "prompt_too_long",
98
+ "context_length_exceeded",
99
+ )
100
+ _LIST_COST_BASIS = "list"
101
+ _EMPTY_RESULT = ""
102
+ _TEXT_BLOCK_TYPE = "text"
103
+ _TOOL_USE_BLOCK_TYPE = "tool_use"
104
+ _TOOL_RESULT_BLOCK_TYPE = "tool_result"
105
+ _TEXT_FIELD = "text"
106
+ _CONTENT_FIELD = "content"
107
+ _MESSAGE_FIELD = "message"
108
+ _USAGE_FIELD = "usage"
109
+ _INPUT_TOKENS_FIELD = "input_tokens"
110
+ _OUTPUT_TOKENS_FIELD = "output_tokens"
111
+ _CACHE_READ_TOKENS_FIELD = "cache_read_input_tokens"
112
+ _CACHE_WRITE_TOKENS_FIELD = "cache_creation_input_tokens"
113
+ _SESSION_ID_FIELD = "session_id"
114
+ _MODEL_FIELD = "model"
115
+ _MODEL_USAGE_FIELD = "modelUsage"
116
+ _MODEL_USAGE_DELTA_FIELD = "model_usage_since_last_result"
117
+ _INPUT_TOKENS_DELTA_FIELD = "inputTokens"
118
+ _OUTPUT_TOKENS_DELTA_FIELD = "outputTokens"
119
+ _CACHE_READ_TOKENS_DELTA_FIELD = "cacheReadInputTokens"
120
+ _CACHE_WRITE_TOKENS_DELTA_FIELD = "cacheCreationInputTokens"
121
+ _CONTEXT_WINDOW_FIELD = "contextWindow"
122
+ _COST_BASIS_FIELD = "costBasis"
123
+ _COST_USD_FIELD = "costUSD"
124
+ _TOTAL_COST_FIELD = "total_cost_usd"
125
+ _COMPACT_METADATA_FIELD = "compact_metadata"
126
+ _ORIGIN_FIELD = "origin"
127
+ _ORIGIN_KIND_FIELD = "kind"
128
+ _TASK_NOTIFICATION_ORIGIN_KIND = "task-notification"
129
+ _TASK_STARTED_SUBTYPE = "task_started"
130
+ _TASK_UPDATED_SUBTYPE = "task_updated"
131
+ _TASK_NOTIFICATION_SUBTYPE = "task_notification"
132
+ _TASK_ID_FIELD = "task_id"
133
+ _TASK_DESCRIPTION_FIELD = "description"
134
+ _TASK_SUMMARY_FIELD = "summary"
135
+ _TASK_STATUS_FIELD = "status"
136
+ _TASK_PATCH_FIELD = "patch"
137
+ _IS_BACKGROUNDED_FIELD = "is_backgrounded"
138
+ _RESULT_INDEX_FIELD = "result_index"
139
+ _STOP_REASON_FIELD = "stop_reason"
140
+ _PRE_TOKENS_FIELD = "pre_tokens"
141
+ _POST_TOKENS_FIELD = "post_tokens"
142
+ _RATE_LIMIT_INFO_FIELD = "rate_limit_info"
143
+ _UNIFIED_WINDOWS_FIELD = "unifiedWindows"
144
+ _FIVE_HOUR_PROTOCOL_KEY = "five_hour"
145
+ _SEVEN_DAY_PROTOCOL_KEY = "seven_day"
146
+ _QUOTA_WINDOW_NAMES = {
147
+ _FIVE_HOUR_PROTOCOL_KEY: QUOTA_WINDOW_FIVE_HOUR,
148
+ _SEVEN_DAY_PROTOCOL_KEY: QUOTA_WINDOW_SEVEN_DAY,
149
+ }
150
+ _UTILIZATION_FIELD = "utilization"
151
+ _RESETS_AT_FIELD = "resetsAt"
152
+ _API_ERROR_STATUS_FIELD = "api_error_status"
153
+ _STATUS_FIELD = "status"
154
+ _IS_ERROR_FIELD = "is_error"
155
+ _TERMINAL_REASON_FIELD = "terminal_reason"
156
+ _RESULT_FIELD = "result"
157
+ _ERROR_FIELD = "error"
158
+ _ERRORS_FIELD = "errors"
159
+ _TOOL_NAME_FIELD = "name"
160
+ _TOOL_ID_FIELD = "id"
161
+ _TOOL_USE_ID_FIELD = "tool_use_id"
162
+ _TOOL_INPUT_FIELD = "input"
163
+ _TYPE_FIELD = "type"
164
+ _SUBTYPE_FIELD = "subtype"
165
+ _MESSAGE_TEXT_FIELD = "message"
166
+ _ERROR_TAG_PATTERN = re.compile(r"\[claude-code:(?P<code>[a-z0-9_-]+)\]", re.IGNORECASE)
167
+ _ERROR_STATUSES = (
168
+ STATUS_UNAUTHORIZED,
169
+ STATUS_PAYMENT_REQUIRED,
170
+ STATUS_FORBIDDEN,
171
+ STATUS_TOO_MANY_REQUESTS,
172
+ )
173
+ _UNAVAILABLE_STATUS_MIN = 500
174
+ _UNAVAILABLE_STATUS_MAX = 599
175
+ _CONTEXT_ERROR_TEXTS = (
176
+ "prompt is too long",
177
+ "prompt_too_long",
178
+ "context length",
179
+ "context_length_exceeded",
180
+ "context window",
181
+ )
182
+ _QUOTA_ERROR_TEXTS = ("usage limit", "rate limit", "too many requests")
183
+ _UNAVAILABLE_ERROR_TEXTS = ("overloaded", "unrecognized_model", "unrecognized model")
184
+ _AUTH_ERROR_TEXTS = ("authentication", "unauthorized", "invalid api key")
185
+ _TOOL_DETAIL_MAX_CHARS = 160
186
+ _UNKNOWN_ERROR_MESSAGE = "Claude stream ended without a result"
187
+ _PROCESS_FAILURE_MESSAGE = "Claude process exited unsuccessfully after emitting a result"
188
+ _INVALID_COMPACTION_MESSAGE = "Claude compaction ended without compact_boundary"
189
+ _MALFORMED_COMPACTION_MESSAGE = "Claude compaction reported malformed token counts"
190
+ _INEFFECTIVE_COMPACTION_MESSAGE = (
191
+ "Claude compaction left {post_tokens} tokens, not below the {limit}-token compaction limit"
192
+ )
193
+ _MALFORMED_STREAM_MESSAGE = "Claude emitted malformed stream-json"
194
+ _STATUS_MESSAGE = "Claude returned API status {status}"
195
+ _STDERR_MESSAGE = "Claude exited with status {exit_code}: {message}"
196
+
197
+
198
+ class ClaudeTurnParser:
199
+ """Map Claude's stream-json records and process exit to agent signals."""
200
+
201
+ def __init__(
202
+ self,
203
+ *,
204
+ session_id: str,
205
+ model: str,
206
+ initial_usage: Usage | None,
207
+ clock: Clock,
208
+ reset_time: ResetTimeParser,
209
+ compact_limit_tokens: int | None = None,
210
+ ) -> None:
211
+ self._session_id = session_id
212
+ self._model = model
213
+ self._initial_usage = initial_usage or _empty_usage()
214
+ self._clock = clock
215
+ self._reset_time = reset_time
216
+ self._compact_limit_tokens = compact_limit_tokens
217
+ self._tool_names: dict[str, str] = {}
218
+ self._tool_ids: list[str] = []
219
+ self._latest_assistant_usage: Mapping[str, Any] | None = None
220
+ self._latest_model_usage: Mapping[str, Any] | None = None
221
+ self._latest_cost_basis: str | None = None
222
+ self._latest_cost: float | None = None
223
+ self._latest_message = ""
224
+ self._record_order = 0
225
+ self._latest_message_order = -1
226
+ self._latest_result_order = -1
227
+ self._saw_result_usage = False
228
+ self._result_input_tokens = 0
229
+ self._result_output_tokens = 0
230
+ self._result_cache_read_tokens = 0
231
+ self._result_cache_write_tokens = 0
232
+ self._session_started = False
233
+ self._started_session_id: str | None = None
234
+ self._pending_activity: list[AgentSignal] = []
235
+ self._latest_quota: tuple[QuotaWindow, ...] = ()
236
+ self._terminal = False
237
+ self._malformed_line: str | None = None
238
+ self._did_compact = False
239
+ self._post_compaction_tokens: int | None = None
240
+ self._malformed_compaction_metadata = False
241
+ self._failure_payload: Mapping[str, Any] | None = None
242
+ self._success_payload: Mapping[str, Any] | None = None
243
+ self._context_exceeded = False
244
+
245
+ @property
246
+ def context_exceeded(self) -> bool:
247
+ """Return whether this process failed because its context was full."""
248
+ return self._context_exceeded
249
+
250
+ def feed(self, line: str) -> list[AgentSignal]:
251
+ if self._terminal:
252
+ return []
253
+ try:
254
+ raw = json.loads(line)
255
+ except json.JSONDecodeError:
256
+ self._malformed_line = self._malformed_line or line
257
+ return []
258
+ if not isinstance(raw, Mapping):
259
+ self._malformed_line = self._malformed_line or line
260
+ return []
261
+ payload = cast("Mapping[str, Any]", raw)
262
+ self._record_order += 1
263
+ handlers = {
264
+ _SYSTEM_TYPE: self._system,
265
+ _ASSISTANT_TYPE: self._assistant,
266
+ _USER_TYPE: self._user,
267
+ _RATE_LIMIT_TYPE: self._rate_limit,
268
+ _RESULT_TYPE: self._result,
269
+ }
270
+ handler = handlers.get(cast("str", payload.get(_TYPE_FIELD)))
271
+ return handler(payload) if handler is not None else []
272
+
273
+ def finish(
274
+ self, exit_code: int, stderr: str, *, interrupted: bool = False
275
+ ) -> list[AgentSignal]:
276
+ if self._terminal:
277
+ return []
278
+ if self._malformed_line is not None:
279
+ self._terminal = True
280
+ return [
281
+ TurnFailed(
282
+ FailureKind.UNKNOWN,
283
+ _stderr_message(exit_code, stderr, _MALFORMED_STREAM_MESSAGE),
284
+ None,
285
+ )
286
+ ]
287
+ if self._failure_payload is not None:
288
+ self._terminal = True
289
+ return self._fail(self._failure_payload, stderr)
290
+ if self._success_payload is not None:
291
+ if exit_code != 0:
292
+ return self._finish_failed_process(exit_code, stderr)
293
+ return self._finish_success(self._success_payload)
294
+ return self._finish_without_result(exit_code, stderr, interrupted=interrupted)
295
+
296
+ def _finish_success(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
297
+ compaction_problem = self._compaction_problem()
298
+ if compaction_problem is not None:
299
+ self._terminal = True
300
+ return [TurnFailed(FailureKind.OTHER, compaction_problem, None)]
301
+ usage = self._reported_usage()
302
+ result_text = payload.get(_RESULT_FIELD)
303
+ if self._latest_message_order > self._latest_result_order:
304
+ last_message = self._latest_message
305
+ elif isinstance(result_text, str):
306
+ last_message = result_text
307
+ else:
308
+ last_message = self._latest_message
309
+ self._terminal = True
310
+ signals: list[AgentSignal] = []
311
+ if usage is not None:
312
+ signals.append(UsageReported(usage))
313
+ signals.append(TurnEnded(last_message))
314
+ return signals
315
+
316
+ def _finish_without_result(
317
+ self, exit_code: int, stderr: str, *, interrupted: bool = False
318
+ ) -> list[AgentSignal]:
319
+ failure = self._classify_failure(None, stderr)
320
+ if failure is not None:
321
+ kind, message, resets_at = failure
322
+ self._terminal = True
323
+ return [TurnFailed(kind, _stderr_message(exit_code, stderr, message), resets_at)]
324
+ compaction_problem = self._compaction_problem()
325
+ if interrupted and compaction_problem is None:
326
+ self._terminal = True
327
+ return []
328
+ message = compaction_problem or _UNKNOWN_ERROR_MESSAGE
329
+ kind = FailureKind.OTHER if compaction_problem is not None else FailureKind.UNKNOWN
330
+ self._terminal = True
331
+ return [TurnFailed(kind, _stderr_message(exit_code, stderr, message), None)]
332
+
333
+ def _finish_failed_process(self, exit_code: int, stderr: str) -> list[AgentSignal]:
334
+ failure = self._classify_failure(None, stderr)
335
+ kind, message, resets_at = failure or (
336
+ FailureKind.UNKNOWN,
337
+ _PROCESS_FAILURE_MESSAGE,
338
+ None,
339
+ )
340
+ self._terminal = True
341
+ return [TurnFailed(kind, _stderr_message(exit_code, stderr, message), resets_at)]
342
+
343
+ def _system(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
344
+ subtype = payload.get(_SUBTYPE_FIELD)
345
+ if subtype == _INIT_SUBTYPE:
346
+ return self._init_session(payload)
347
+ if subtype == _COMPACT_BOUNDARY_SUBTYPE:
348
+ return self._compact_boundary(payload)
349
+ return self._system_activity(payload)
350
+
351
+ def _init_session(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
352
+ raw_session_id = payload.get(_SESSION_ID_FIELD)
353
+ if not isinstance(raw_session_id, str) or not raw_session_id.strip():
354
+ return []
355
+ session_id = raw_session_id
356
+ model = payload.get(_MODEL_FIELD)
357
+ if isinstance(model, str):
358
+ self._model = model
359
+ if self._session_started and session_id == self._started_session_id:
360
+ return []
361
+ if self._session_started:
362
+ return [SessionStarted(session_id)]
363
+ self._session_started = True
364
+ self._started_session_id = session_id
365
+ pending, self._pending_activity = self._pending_activity, []
366
+ return [SessionStarted(session_id), *pending]
367
+
368
+ def _compact_boundary(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
369
+ self._did_compact = True
370
+ metadata = _mapping(payload.get(_COMPACT_METADATA_FIELD))
371
+ if metadata is not None:
372
+ self._malformed_compaction_metadata |= any(
373
+ key in metadata and _optional_nonnegative_integer(metadata, key) is None
374
+ for key in (_PRE_TOKENS_FIELD, _POST_TOKENS_FIELD)
375
+ )
376
+ self._post_compaction_tokens = _optional_nonnegative_integer(metadata, _POST_TOKENS_FIELD)
377
+ if self._malformed_compaction_metadata:
378
+ return []
379
+ return [
380
+ Compacted(
381
+ _optional_nonnegative_integer(metadata, _PRE_TOKENS_FIELD),
382
+ self._post_compaction_tokens,
383
+ )
384
+ ]
385
+
386
+ def _system_activity(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
387
+ activity = self._background_activity(payload)
388
+ if not activity:
389
+ return []
390
+ if self._session_started:
391
+ return activity
392
+ self._pending_activity.extend(activity)
393
+ return []
394
+
395
+ def _background_activity(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
396
+ subtype = payload.get(_SUBTYPE_FIELD)
397
+ if subtype == _TASK_STARTED_SUBTYPE:
398
+ description = payload.get(_TASK_DESCRIPTION_FIELD)
399
+ if payload.get(_IS_BACKGROUNDED_FIELD) is True and isinstance(description, str):
400
+ return [Message(description)] if description else []
401
+ if subtype == _TASK_NOTIFICATION_SUBTYPE:
402
+ summary = payload.get(_TASK_SUMMARY_FIELD)
403
+ if isinstance(summary, str) and summary:
404
+ return [Message(summary)]
405
+ if subtype == _TASK_UPDATED_SUBTYPE:
406
+ patch = _mapping(payload.get(_TASK_PATCH_FIELD))
407
+ status = patch.get(_TASK_STATUS_FIELD) if patch is not None else None
408
+ task_id = payload.get(_TASK_ID_FIELD)
409
+ if isinstance(status, str) and isinstance(task_id, str):
410
+ return [Message(f"Background task {task_id}: {status}")]
411
+ return []
412
+
413
+ def _assistant(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
414
+ blocks = self._assistant_content(payload)
415
+ if not isinstance(blocks, list):
416
+ return []
417
+ signals: list[AgentSignal] = []
418
+ for block in cast("list[object]", blocks):
419
+ signals.extend(self._assistant_block(block))
420
+ return signals
421
+
422
+ def _assistant_content(self, payload: Mapping[str, Any]) -> object:
423
+ message = _mapping(payload.get(_MESSAGE_FIELD))
424
+ if message is not None:
425
+ usage = _mapping(message.get(_USAGE_FIELD))
426
+ if usage is not None:
427
+ self._latest_assistant_usage = usage
428
+ return message.get(_CONTENT_FIELD)
429
+ return payload.get(_CONTENT_FIELD)
430
+
431
+ def _assistant_block(self, block: object) -> list[AgentSignal]:
432
+ item = _mapping(block)
433
+ if item is None:
434
+ return []
435
+ if item.get(_TYPE_FIELD) == _TEXT_BLOCK_TYPE:
436
+ text = item.get(_TEXT_FIELD)
437
+ if isinstance(text, str) and text:
438
+ self._latest_message = text
439
+ self._latest_message_order = self._record_order
440
+ return [Message(text)]
441
+ if item.get(_TYPE_FIELD) == _TOOL_USE_BLOCK_TYPE:
442
+ return self._tool_started(item)
443
+ return []
444
+
445
+ def _tool_started(self, item: Mapping[str, Any]) -> list[AgentSignal]:
446
+ name = item.get(_TOOL_NAME_FIELD)
447
+ if not isinstance(name, str):
448
+ return []
449
+ signals: list[AgentSignal] = [ToolStarted(name, _tool_detail(item.get(_TOOL_INPUT_FIELD)))]
450
+ tool_id = item.get(_TOOL_ID_FIELD)
451
+ if isinstance(tool_id, str):
452
+ self._tool_names[tool_id] = name
453
+ self._tool_ids.append(tool_id)
454
+ return signals
455
+
456
+ def _user(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
457
+ message = _mapping(payload.get(_MESSAGE_FIELD))
458
+ blocks = message.get(_CONTENT_FIELD) if message is not None else None
459
+ if not isinstance(blocks, list):
460
+ return []
461
+ signals: list[AgentSignal] = []
462
+ for block in cast("list[object]", blocks):
463
+ item = _mapping(block)
464
+ if item is None or item.get(_TYPE_FIELD) != _TOOL_RESULT_BLOCK_TYPE:
465
+ continue
466
+ tool_id = item.get(_TOOL_USE_ID_FIELD)
467
+ if not isinstance(tool_id, str) and self._tool_ids:
468
+ tool_id = self._tool_ids[0]
469
+ if isinstance(tool_id, str):
470
+ name = self._tool_names.pop(tool_id, None)
471
+ if tool_id in self._tool_ids:
472
+ self._tool_ids.remove(tool_id)
473
+ if name is not None:
474
+ is_error = item.get(_IS_ERROR_FIELD)
475
+ outcome = (
476
+ ToolOutcome.FAILURE
477
+ if is_error is True
478
+ else ToolOutcome.SUCCESS
479
+ if is_error is False
480
+ else None
481
+ )
482
+ signals.append(ToolFinished(name, outcome))
483
+ return signals
484
+
485
+ def _rate_limit(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
486
+ info = _mapping(payload.get(_RATE_LIMIT_INFO_FIELD))
487
+ if info is None:
488
+ return []
489
+ windows = _mapping(info.get(_UNIFIED_WINDOWS_FIELD)) or info
490
+ observed_at = self._clock.now()
491
+ reported = tuple(
492
+ self._quota_window(name, raw, observed_at)
493
+ for key, name in _QUOTA_WINDOW_NAMES.items()
494
+ if (raw := _mapping(windows.get(key))) is not None
495
+ and isinstance(raw.get(_UTILIZATION_FIELD), int | float)
496
+ )
497
+ if not reported:
498
+ return []
499
+ self._latest_quota = reported
500
+ return [QuotaReported(reported)]
501
+
502
+ def _quota_window(
503
+ self, name: str, raw: Mapping[str, Any], observed_at: datetime
504
+ ) -> QuotaWindow:
505
+ reset_value = raw.get(_RESETS_AT_FIELD)
506
+ resets_at = self._reset_time.parse(str(reset_value)) if reset_value is not None else None
507
+ return QuotaWindow(
508
+ name=name,
509
+ used_pct=float(cast("int | float", raw[_UTILIZATION_FIELD])) * PERCENT,
510
+ resets_at=resets_at,
511
+ observed_at=observed_at,
512
+ )
513
+
514
+ def _result(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
515
+ if self._malformed_line is not None:
516
+ self._terminal = True
517
+ return [TurnFailed(FailureKind.UNKNOWN, _MALFORMED_STREAM_MESSAGE, None)]
518
+ if _is_error_result(payload):
519
+ self._failure_payload = payload
520
+ return []
521
+ if _is_no_work_task_notification_result(payload, self._model):
522
+ return []
523
+ self._success_payload = payload
524
+ self._latest_result_order = self._record_order
525
+ self._accumulate_result_usage(payload)
526
+ model_usage = _mapping(payload.get(_MODEL_USAGE_FIELD))
527
+ selected_model_usage = _select_model_usage(model_usage, self._model)
528
+ if selected_model_usage is not None:
529
+ latest_model_usage = dict(self._latest_model_usage or {})
530
+ latest_model_usage.update(selected_model_usage)
531
+ self._latest_model_usage = latest_model_usage
532
+ cost_basis = selected_model_usage.get(_COST_BASIS_FIELD)
533
+ if isinstance(cost_basis, str):
534
+ self._latest_cost_basis = cost_basis
535
+ total_cost = payload.get(_TOTAL_COST_FIELD)
536
+ if isinstance(total_cost, int | float) and not isinstance(total_cost, bool):
537
+ self._latest_cost = float(total_cost)
538
+ elif selected_model_usage is not None:
539
+ model_cost = selected_model_usage.get(_COST_USD_FIELD)
540
+ if isinstance(model_cost, int | float) and not isinstance(model_cost, bool):
541
+ self._latest_cost = float(model_cost)
542
+ return []
543
+
544
+ def _accumulate_result_usage(self, payload: Mapping[str, Any]) -> None:
545
+ source = _result_usage_delta(payload, self._model)
546
+ if source is None:
547
+ return
548
+ self._saw_result_usage = True
549
+ self._result_input_tokens += _integer(source, _INPUT_TOKENS_FIELD)
550
+ self._result_output_tokens += _integer(source, _OUTPUT_TOKENS_FIELD)
551
+ self._result_cache_read_tokens += _integer(source, _CACHE_READ_TOKENS_FIELD)
552
+ self._result_cache_write_tokens += _integer(source, _CACHE_WRITE_TOKENS_FIELD)
553
+
554
+ def _reported_usage(self) -> Usage | None:
555
+ source = self._latest_assistant_usage
556
+ token_fields = (
557
+ _INPUT_TOKENS_FIELD,
558
+ _OUTPUT_TOKENS_FIELD,
559
+ _CACHE_READ_TOKENS_FIELD,
560
+ _CACHE_WRITE_TOKENS_FIELD,
561
+ )
562
+ if not self._saw_result_usage and (
563
+ source is None or not any(field in source for field in token_fields)
564
+ ):
565
+ return None
566
+ input_delta = (
567
+ self._result_input_tokens
568
+ if self._saw_result_usage
569
+ else _integer(source, _INPUT_TOKENS_FIELD)
570
+ )
571
+ output_delta = (
572
+ self._result_output_tokens
573
+ if self._saw_result_usage
574
+ else _integer(source, _OUTPUT_TOKENS_FIELD)
575
+ )
576
+ cache_read_delta = (
577
+ self._result_cache_read_tokens
578
+ if self._saw_result_usage
579
+ else _integer(source, _CACHE_READ_TOKENS_FIELD)
580
+ )
581
+ cache_write_delta = (
582
+ self._result_cache_write_tokens
583
+ if self._saw_result_usage
584
+ else _integer(source, _CACHE_WRITE_TOKENS_FIELD)
585
+ )
586
+ input_tokens = self._initial_usage.input_tokens + input_delta
587
+ output_tokens = self._initial_usage.output_tokens + output_delta
588
+ cache_read_tokens = self._initial_usage.cache_read_tokens + cache_read_delta
589
+ cache_write_tokens = self._initial_usage.cache_write_tokens + cache_write_delta
590
+ priced = self._latest_cost_basis == _LIST_COST_BASIS and self._latest_cost is not None
591
+ context_window = (
592
+ self._latest_model_usage.get(_CONTEXT_WINDOW_FIELD)
593
+ if self._latest_model_usage is not None
594
+ else None
595
+ )
596
+ context_tokens = _context_tokens(self._latest_assistant_usage)
597
+ context_pct = (
598
+ context_tokens / context_window * PERCENT
599
+ if context_tokens is not None
600
+ and isinstance(context_window, int | float)
601
+ and not isinstance(context_window, bool)
602
+ and context_window > 0
603
+ else None
604
+ )
605
+ return Usage(
606
+ input_tokens=input_tokens,
607
+ output_tokens=output_tokens,
608
+ cache_read_tokens=cache_read_tokens,
609
+ cache_write_tokens=cache_write_tokens,
610
+ cost_usd=self._latest_cost if priced else None,
611
+ cost_basis="api_equivalent" if priced else "unknown",
612
+ context_used_pct=context_pct,
613
+ )
614
+
615
+ def _fail(self, payload: Mapping[str, Any], stderr: str) -> list[AgentSignal]:
616
+ failure = self._classify_failure(payload, stderr)
617
+ if failure is None:
618
+ failure = (FailureKind.UNKNOWN, _UNKNOWN_ERROR_MESSAGE, None)
619
+ kind, message, resets_at = failure
620
+ if kind is FailureKind.QUOTA and resets_at is None:
621
+ resets_at = _latest_reset(self._latest_quota)
622
+ if kind is FailureKind.QUOTA and resets_at is None:
623
+ resets_at = self._reset_time.parse(message)
624
+ return [TurnFailed(kind, message, resets_at)]
625
+
626
+ def _classify_failure(
627
+ self, payload: Mapping[str, Any] | None, stderr: str
628
+ ) -> tuple[FailureKind, str, datetime | None] | None:
629
+ status = _api_error_status(payload)
630
+ message = _error_message(payload, stderr)
631
+ lowered = f"{message} {stderr}".lower()
632
+ tag_match = _ERROR_TAG_PATTERN.search(stderr)
633
+ code = tag_match.group("code").lower() if tag_match is not None else ""
634
+ terminal_reason = payload.get(_TERMINAL_REASON_FIELD) if payload is not None else None
635
+ lowered = f"{lowered} {terminal_reason or ''} {code}"
636
+ classified = _classify_status(status, message, self._reset_time)
637
+ if status == STATUS_PAYMENT_REQUIRED:
638
+ return classified
639
+ if classified is None:
640
+ classified = _classify_text(lowered, message, stderr)
641
+ if classified is None and _contains(lowered, _CONTEXT_ERROR_TEXTS):
642
+ self._context_exceeded = True
643
+ classified = FailureKind.OTHER, message or stderr.strip(), None
644
+ if classified is None and (status is not None or message or stderr):
645
+ classified = FailureKind.UNKNOWN, message or stderr.strip(), None
646
+ if classified is not None and classified[0] is FailureKind.QUOTA:
647
+ kind, failure_message, resets_at = classified
648
+ return kind, failure_message, resets_at or self._reset_time.parse(message)
649
+ return classified
650
+
651
+ def _compaction_problem(self) -> str | None:
652
+ limit = self._compact_limit_tokens
653
+ if limit is None:
654
+ return None
655
+ if not self._did_compact:
656
+ return _INVALID_COMPACTION_MESSAGE
657
+ if self._malformed_compaction_metadata:
658
+ return _MALFORMED_COMPACTION_MESSAGE
659
+ post_tokens = self._post_compaction_tokens
660
+ if post_tokens is None or post_tokens >= limit:
661
+ return _INEFFECTIVE_COMPACTION_MESSAGE.format(post_tokens=post_tokens, limit=limit)
662
+ return None
663
+
664
+
665
+ class ClaudeTurnSequence(TurnProcessSequence):
666
+ """Build one turn's prompt, compaction and continuation processes."""
667
+
668
+ def __init__(
669
+ self,
670
+ spec: TurnSpec,
671
+ *,
672
+ clock: Clock,
673
+ reset_time: ResetTimeParser,
674
+ compact_at_pct: float,
675
+ args: Sequence[str],
676
+ environment: Callable[[TurnSpec], Mapping[str, str]],
677
+ session_id: str | None = None,
678
+ ) -> None:
679
+ self._clock = clock
680
+ self._reset_time = reset_time
681
+ self._limit = compact_limit_tokens(spec.rung.model, compact_at_pct)
682
+ self._args = tuple(args)
683
+ self._environment = environment
684
+ self._session_id = spec.session_id or session_id or str(uuid.uuid4())
685
+ self._session_exists = spec.session_id is not None
686
+ self._usage = spec.usage
687
+ self._marker = spec.turn_dir / f"{spec.role}{COMPACT_MARKER_SUFFIX}"
688
+ self._state = spec.turn_dir / f"{spec.role}{COMPACT_STATE_SUFFIX}"
689
+ self._then: str | None = None
690
+ self._recovered = False
691
+ self._parser: ClaudeTurnParser | None = None
692
+
693
+ @property
694
+ def session_id(self) -> str:
695
+ return self._session_id
696
+
697
+ def start(self, spec: TurnSpec) -> TurnProcess:
698
+ self._marker.unlink(missing_ok=True)
699
+ if self._session_exists and self._transcript_tokens(spec) >= self._limit:
700
+ return self._compact(spec, then=spec.prompt)
701
+ return self._process(spec, spec.prompt)
702
+
703
+ def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
704
+ self._remember_usage(previous.signals)
705
+ if self._then is not None:
706
+ prompt, self._then = self._then, None
707
+ compacted = isinstance(_terminal(previous.signals), TurnEnded)
708
+ return self._process(spec, prompt) if compacted else None
709
+ if self._marker.exists():
710
+ self._marker.unlink()
711
+ return self._compact(spec, then=CONTINUE_PROMPT)
712
+ if self._parser is not None and self._parser.context_exceeded and not self._recovered:
713
+ self._recovered = True
714
+ return self._compact(spec, then=spec.prompt)
715
+ return None
716
+
717
+ def _compact(self, spec: TurnSpec, *, then: str) -> TurnProcess:
718
+ self._then = then
719
+ return self._process(spec, _COMPACT_PROMPT, compact=True)
720
+
721
+ def _process(self, spec: TurnSpec, prompt: str, *, compact: bool = False) -> TurnProcess:
722
+ session_flag = RESUME_FLAG if self._session_exists else SESSION_ID_FLAG
723
+ self._session_exists = True
724
+ argv: list[str] = [CLAUDE_COMMAND, PRINT_FLAG, prompt, session_flag, self._session_id]
725
+ argv.extend([OUTPUT_FORMAT_FLAG, _STREAM_JSON, _VERBOSE_FLAG, MODEL_FLAG, spec.rung.model])
726
+ if spec.rung.effort is not None:
727
+ argv.extend([EFFORT_FLAG, spec.rung.effort])
728
+ for directory in _unique_paths((spec.run_dir, *spec.add_dirs)):
729
+ argv.extend([ADD_DIR_FLAG, str(directory)])
730
+ argv.extend([SETTINGS_FLAG, self._hook_settings()])
731
+ argv.extend(self._args)
732
+ self._parser = ClaudeTurnParser(
733
+ session_id=self._session_id,
734
+ model=spec.rung.model,
735
+ initial_usage=self._usage,
736
+ clock=self._clock,
737
+ reset_time=self._reset_time,
738
+ compact_limit_tokens=self._limit if compact else None,
739
+ )
740
+ return TurnProcess(tuple(argv), spec.cwd, self._environment(spec), self._parser)
741
+
742
+ def _hook_settings(self) -> str:
743
+ command = module_command(HOOK_MODULE, str(self._state), str(self._marker), str(self._limit))
744
+ hook = {_HOOK_TYPE_FIELD: _COMMAND_HOOK_TYPE, _HOOK_COMMAND_FIELD: command}
745
+ matcher = {_HOOK_MATCHER_FIELD: _ANY_TOOL_MATCHER, _HOOKS_FIELD: [hook]}
746
+ return json.dumps({_HOOKS_FIELD: {_POST_TOOL_USE_EVENT: [matcher]}})
747
+
748
+ def _transcript_tokens(self, spec: TurnSpec) -> int:
749
+ environment = self._environment(spec)
750
+ config_dir = environment.get(CLAUDE_CONFIG_DIR_ENV_VAR)
751
+ root = Path(config_dir) if config_dir else Path(environment[HOME_ENV_VAR]) / CLAUDE_DIRNAME
752
+ transcripts = sorted(
753
+ (root / PROJECTS_DIRNAME).glob(f"*/{self._session_id}{TRANSCRIPT_SUFFIX}"),
754
+ key=lambda path: path.stat().st_mtime_ns,
755
+ )
756
+ if not transcripts:
757
+ return 0
758
+ return transcript_context_tokens(read_transcript(transcripts[-1]))
759
+
760
+ def _remember_usage(self, signals: Sequence[AgentSignal]) -> None:
761
+ usages = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
762
+ if usages:
763
+ self._usage = usages[-1]
764
+
765
+
766
+ def _empty_usage() -> Usage:
767
+ return Usage(0, 0, 0, 0, None, "unknown", None)
768
+
769
+
770
+ def _mapping(value: object) -> Mapping[str, Any] | None:
771
+ return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
772
+
773
+
774
+ def _optional_nonnegative_integer(payload: Mapping[str, Any] | None, key: str) -> int | None:
775
+ if payload is None:
776
+ return None
777
+ value = payload.get(key)
778
+ return value if type(value) is int and value >= 0 else None
779
+
780
+
781
+ def _tool_detail(value: object) -> str:
782
+ if isinstance(value, str):
783
+ detail = value
784
+ elif isinstance(value, Mapping):
785
+ detail = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
786
+ else:
787
+ detail = ""
788
+ return detail[:_TOOL_DETAIL_MAX_CHARS]
789
+
790
+
791
+ def _is_error_result(payload: Mapping[str, Any]) -> bool:
792
+ if payload.get(_IS_ERROR_FIELD) is True:
793
+ return True
794
+ if _api_error_status(payload) is not None:
795
+ return True
796
+ terminal_reason = payload.get(_TERMINAL_REASON_FIELD)
797
+ if isinstance(terminal_reason, str) and terminal_reason.lower() in _ERROR_TERMINAL_REASONS:
798
+ return True
799
+ return payload.get(_SUBTYPE_FIELD) not in (None, _SUCCESS_SUBTYPE) and isinstance(
800
+ payload.get(_SUBTYPE_FIELD), str
801
+ )
802
+
803
+
804
+ def _is_no_work_task_notification_result(payload: Mapping[str, Any], model: str) -> bool:
805
+ origin = _mapping(payload.get(_ORIGIN_FIELD))
806
+ usage = _mapping(payload.get(_USAGE_FIELD))
807
+ if (
808
+ origin is None
809
+ or origin.get(_ORIGIN_KIND_FIELD) != _TASK_NOTIFICATION_ORIGIN_KIND
810
+ or payload.get(_RESULT_FIELD) != _EMPTY_RESULT
811
+ or type(payload.get(_RESULT_INDEX_FIELD)) is not int
812
+ or payload.get(_RESULT_INDEX_FIELD) != 0
813
+ or payload.get(_STOP_REASON_FIELD) is not None
814
+ or payload.get(_TERMINAL_REASON_FIELD) is not None
815
+ or usage is None
816
+ ):
817
+ return False
818
+ zero_usage = all(
819
+ key in usage
820
+ and isinstance(usage[key], int | float)
821
+ and not isinstance(usage[key], bool)
822
+ and usage[key] == 0
823
+ for key in (
824
+ _INPUT_TOKENS_FIELD,
825
+ _OUTPUT_TOKENS_FIELD,
826
+ _CACHE_READ_TOKENS_FIELD,
827
+ _CACHE_WRITE_TOKENS_FIELD,
828
+ )
829
+ )
830
+ if not zero_usage:
831
+ return False
832
+ delta = _mapping(payload.get(_MODEL_USAGE_DELTA_FIELD))
833
+ return delta is None or not _has_model_usage_delta(delta, model)
834
+
835
+
836
+ def _has_model_usage_delta(delta: Mapping[str, Any], model: str) -> bool:
837
+ selected = _select_model_usage(delta, model)
838
+ if selected is None:
839
+ return False
840
+ return any(
841
+ isinstance(selected.get(field), int | float)
842
+ and not isinstance(selected.get(field), bool)
843
+ and selected[field] != 0
844
+ for field in (
845
+ _INPUT_TOKENS_DELTA_FIELD,
846
+ _OUTPUT_TOKENS_DELTA_FIELD,
847
+ _CACHE_READ_TOKENS_DELTA_FIELD,
848
+ _CACHE_WRITE_TOKENS_DELTA_FIELD,
849
+ _COST_USD_FIELD,
850
+ )
851
+ )
852
+
853
+
854
+ def _result_usage_delta(payload: Mapping[str, Any], model: str) -> Mapping[str, Any] | None:
855
+ raw_delta = _mapping(payload.get(_MODEL_USAGE_DELTA_FIELD))
856
+ selected_delta = _select_model_usage(raw_delta, model)
857
+ if selected_delta is not None:
858
+ translated = {
859
+ _INPUT_TOKENS_FIELD: selected_delta.get(_INPUT_TOKENS_DELTA_FIELD),
860
+ _OUTPUT_TOKENS_FIELD: selected_delta.get(_OUTPUT_TOKENS_DELTA_FIELD),
861
+ _CACHE_READ_TOKENS_FIELD: selected_delta.get(_CACHE_READ_TOKENS_DELTA_FIELD),
862
+ _CACHE_WRITE_TOKENS_FIELD: selected_delta.get(_CACHE_WRITE_TOKENS_DELTA_FIELD),
863
+ }
864
+ if any(value is not None for value in translated.values()):
865
+ return translated
866
+ usage = _mapping(payload.get(_USAGE_FIELD))
867
+ token_fields = (
868
+ _INPUT_TOKENS_FIELD,
869
+ _OUTPUT_TOKENS_FIELD,
870
+ _CACHE_READ_TOKENS_FIELD,
871
+ _CACHE_WRITE_TOKENS_FIELD,
872
+ )
873
+ if usage is None or not any(field in usage for field in token_fields):
874
+ return None
875
+ return usage
876
+
877
+
878
+ def _api_error_status(payload: Mapping[str, Any] | None) -> int | None:
879
+ if payload is None:
880
+ return None
881
+ value = payload.get(_API_ERROR_STATUS_FIELD)
882
+ if (isinstance(value, int) and value in _ERROR_STATUSES) or (
883
+ isinstance(value, int) and _UNAVAILABLE_STATUS_MIN <= value <= _UNAVAILABLE_STATUS_MAX
884
+ ):
885
+ return value
886
+ error = _mapping(payload.get(_ERROR_FIELD))
887
+ nested = error.get(_API_ERROR_STATUS_FIELD, error.get(_STATUS_FIELD)) if error else None
888
+ return nested if isinstance(nested, int) else None
889
+
890
+
891
+ def _error_message(payload: Mapping[str, Any] | None, stderr: str) -> str:
892
+ if payload is None:
893
+ tag = _ERROR_TAG_PATTERN.search(stderr)
894
+ return tag.group("code") if tag is not None else stderr.strip()
895
+ parts: list[str] = []
896
+ for key in (_RESULT_FIELD, _ERROR_FIELD):
897
+ value = payload.get(key)
898
+ if isinstance(value, str):
899
+ parts.append(value)
900
+ elif isinstance(value, Mapping):
901
+ typed_value = cast("Mapping[str, object]", value)
902
+ nested_message = typed_value.get(_MESSAGE_TEXT_FIELD)
903
+ if isinstance(nested_message, str):
904
+ parts.append(nested_message)
905
+ errors = payload.get(_ERRORS_FIELD)
906
+ if isinstance(errors, list):
907
+ parts.extend(
908
+ error
909
+ for item in cast("list[object]", errors)
910
+ if isinstance((error := _error_message_from_item(item)), str)
911
+ )
912
+ tag = _ERROR_TAG_PATTERN.search(stderr)
913
+ if tag is not None:
914
+ parts.append(tag.group("code"))
915
+ return "; ".join(part for part in parts if part)
916
+
917
+
918
+ def _error_message_from_item(value: object) -> str | None:
919
+ if isinstance(value, str):
920
+ return value
921
+ payload = _mapping(value)
922
+ if payload is None:
923
+ return None
924
+ message = payload.get(_MESSAGE_TEXT_FIELD)
925
+ return message if isinstance(message, str) else None
926
+
927
+
928
+ def _contains(value: str, fragments: Sequence[str]) -> bool:
929
+ return any(fragment in value for fragment in fragments)
930
+
931
+
932
+ def _classify_status(
933
+ status: int | None, message: str, reset_time: ResetTimeParser
934
+ ) -> tuple[FailureKind, str, datetime | None] | None:
935
+ detail = message or _STATUS_MESSAGE.format(status=status)
936
+ if status == STATUS_PAYMENT_REQUIRED:
937
+ return FailureKind.AUTH, detail, None
938
+ if status == STATUS_TOO_MANY_REQUESTS:
939
+ return FailureKind.QUOTA, detail, reset_time.parse(message)
940
+ if isinstance(status, int) and _UNAVAILABLE_STATUS_MIN <= status <= _UNAVAILABLE_STATUS_MAX:
941
+ return FailureKind.UNAVAILABLE, detail, None
942
+ if status in (STATUS_UNAUTHORIZED, STATUS_FORBIDDEN):
943
+ return FailureKind.AUTH, detail, None
944
+ return None
945
+
946
+
947
+ def _classify_text(
948
+ lowered: str, message: str, stderr: str
949
+ ) -> tuple[FailureKind, str, datetime | None] | None:
950
+ if _contains(lowered, _QUOTA_ERROR_TEXTS):
951
+ return FailureKind.QUOTA, message or stderr.strip(), None
952
+ if _contains(lowered, _UNAVAILABLE_ERROR_TEXTS):
953
+ return FailureKind.UNAVAILABLE, message or stderr.strip(), None
954
+ if _contains(lowered, _AUTH_ERROR_TEXTS):
955
+ return FailureKind.AUTH, message or stderr.strip(), None
956
+ return None
957
+
958
+
959
+ def _stderr_message(exit_code: int, stderr: str, message: str) -> str:
960
+ detail = message.strip() or stderr.strip() or EMPTY_OUTPUT
961
+ return _STDERR_MESSAGE.format(exit_code=exit_code, message=detail)
962
+
963
+
964
+ def _integer(payload: Mapping[str, Any] | None, key: str) -> int:
965
+ value = payload.get(key, 0) if payload is not None else 0
966
+ return int(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0
967
+
968
+
969
+ def _context_tokens(usage: Mapping[str, Any] | None) -> int | None:
970
+ if usage is None:
971
+ return None
972
+ token_counts = (
973
+ usage.get(_INPUT_TOKENS_FIELD),
974
+ usage.get(_CACHE_READ_TOKENS_FIELD),
975
+ usage.get(_CACHE_WRITE_TOKENS_FIELD),
976
+ )
977
+ complete_counts: list[int] = []
978
+ for token_count in token_counts:
979
+ if type(token_count) is not int or token_count < 0:
980
+ return None
981
+ complete_counts.append(token_count)
982
+ return sum(complete_counts)
983
+
984
+
985
+ def _select_model_usage(
986
+ model_usage: Mapping[str, Any] | None, model: str
987
+ ) -> Mapping[str, Any] | None:
988
+ if model_usage is None:
989
+ return None
990
+ exact = _mapping(model_usage.get(model))
991
+ if exact is not None:
992
+ return exact
993
+ if len(model_usage) == 1:
994
+ return _mapping(next(iter(model_usage.values())))
995
+ return None
996
+
997
+
998
+ def _latest_reset(windows: Sequence[QuotaWindow]) -> datetime | None:
999
+ resets = [window.resets_at for window in windows if window.resets_at is not None]
1000
+ return min(resets) if resets else None
1001
+
1002
+
1003
+ def _terminal(signals: Sequence[AgentSignal]) -> TurnEnded | TurnFailed | None:
1004
+ return next(
1005
+ (signal for signal in reversed(signals) if isinstance(signal, (TurnEnded, TurnFailed))),
1006
+ None,
1007
+ )
1008
+
1009
+
1010
+ def _unique_paths(paths: Sequence[Path]) -> tuple[Path, ...]:
1011
+ unique: list[Path] = []
1012
+ seen: set[Path] = set()
1013
+ for path in paths:
1014
+ normalized = path.resolve()
1015
+ if normalized not in seen:
1016
+ seen.add(normalized)
1017
+ unique.append(path)
1018
+ return tuple(unique)