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,251 @@
1
+ """Claude Code headless turn plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from tempfile import TemporaryDirectory
10
+ from typing import TYPE_CHECKING, cast
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from shikiru.agents.contracts import (
15
+ BILLING_SOURCE_OPERATOR_CONFIG,
16
+ DOCTOR_TIMEOUT_SECONDS,
17
+ UNKNOWN_BILLING_DECLARATION,
18
+ BillingDeclaration,
19
+ CheckResult,
20
+ ModelCatalog,
21
+ )
22
+ from shikiru.agents.sdk.reset_time import ResetTimeParser
23
+ from shikiru.agents.sdk.turn import StreamingTurn
24
+ from shikiru.errors import CommandFailed, ShikiruError
25
+ from shikiru.plugins.agents.claude.headless import ClaudeHeadless
26
+ from shikiru.plugins.agents.claude.model_catalog import parse_model_catalog
27
+ from shikiru.plugins.agents.claude.options import (
28
+ ADD_DIR_FLAG,
29
+ CLAUDE_COMMAND,
30
+ CLAUDE_SETTINGS,
31
+ EFFORT_FLAG,
32
+ EMPTY_MCP_CONFIG,
33
+ EMPTY_TOOLS,
34
+ INPUT_FORMAT_FLAG,
35
+ MCP_CONFIG_FLAG,
36
+ MODEL_CATALOG_REQUEST_ID,
37
+ MODEL_FLAG,
38
+ NO_SESSION_PERSISTENCE_FLAG,
39
+ OUTPUT_FORMAT_FLAG,
40
+ PRINT_FLAG,
41
+ RESUME_FLAG,
42
+ SAFE_MODE_FLAG,
43
+ STREAM_JSON_FORMAT,
44
+ STRICT_MCP_CONFIG_FLAG,
45
+ TOOLS_FLAG,
46
+ VERBOSE_FLAG,
47
+ ClaudeOptions,
48
+ )
49
+ from shikiru.plugins.agents.claude.turn import ClaudeTurnSequence
50
+ from shikiru.plugins.agents.shared_settings import options_error
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Sequence
54
+
55
+ from shikiru.agents.contracts import AgentContext, HeadlessAgent, Rung, Turn, TurnSpec
56
+ from shikiru.agents.settings import AgentSetting
57
+ from shikiru.clock import Clock
58
+ from shikiru.process import SubprocessRunner
59
+
60
+ CLAUDE_VERSION_ARGV = (CLAUDE_COMMAND, "--version")
61
+ AUTH_STATUS_ARGV = (CLAUDE_COMMAND, "auth", "status", "--json")
62
+ _AUTH_STATUS_LOGGED_IN_FIELD = "loggedIn"
63
+ _INVALID_AUTH_STATUS_MESSAGE = "Claude auth status did not return valid JSON"
64
+ _MODEL_CATALOG_TIMEOUT_SECONDS = 20.0
65
+ _CONTROL_REQUEST_TYPE = "control_request"
66
+ _INITIALIZE_SUBTYPE = "initialize"
67
+ _MODEL_CATALOG_REQUEST = json.dumps(
68
+ {
69
+ "type": _CONTROL_REQUEST_TYPE,
70
+ "request_id": MODEL_CATALOG_REQUEST_ID,
71
+ "request": {
72
+ "subtype": _INITIALIZE_SUBTYPE,
73
+ "hooks": {},
74
+ },
75
+ },
76
+ separators=(",", ":"),
77
+ )
78
+
79
+
80
+ class ClaudeAgent:
81
+ """Build Claude Code turn processes and health checks."""
82
+
83
+ name = "claude"
84
+
85
+ def __init__(self, runner: SubprocessRunner, clock: Clock, options: ClaudeOptions) -> None:
86
+ self._runner = runner
87
+ self._clock = clock
88
+ self._options = options
89
+ self._reset_time = ResetTimeParser(clock)
90
+ self._billing_declaration = (
91
+ BillingDeclaration(options.billing_mode, BILLING_SOURCE_OPERATOR_CONFIG)
92
+ if options.billing_mode is not None
93
+ else UNKNOWN_BILLING_DECLARATION
94
+ )
95
+
96
+ @property
97
+ def billing_declaration(self) -> BillingDeclaration:
98
+ """Return the explicit plugin option or the unknown declaration."""
99
+ return self._billing_declaration
100
+
101
+ async def model_catalog(self) -> ModelCatalog:
102
+ """Read model and per-model effort choices from Claude Code's initialize response."""
103
+ argv = (
104
+ CLAUDE_COMMAND,
105
+ PRINT_FLAG,
106
+ INPUT_FORMAT_FLAG,
107
+ STREAM_JSON_FORMAT,
108
+ OUTPUT_FORMAT_FLAG,
109
+ STREAM_JSON_FORMAT,
110
+ VERBOSE_FLAG,
111
+ NO_SESSION_PERSISTENCE_FLAG,
112
+ SAFE_MODE_FLAG,
113
+ STRICT_MCP_CONFIG_FLAG,
114
+ MCP_CONFIG_FLAG,
115
+ EMPTY_MCP_CONFIG,
116
+ TOOLS_FLAG,
117
+ EMPTY_TOOLS,
118
+ )
119
+ try:
120
+ with TemporaryDirectory() as isolated_directory:
121
+ result = await self._runner.run(
122
+ argv,
123
+ timeout=_MODEL_CATALOG_TIMEOUT_SECONDS,
124
+ cwd=Path(isolated_directory),
125
+ env=self._subprocess_environment(),
126
+ input_text=f"{_MODEL_CATALOG_REQUEST}\n",
127
+ check=False,
128
+ )
129
+ except (FileNotFoundError, OSError, ShikiruError, TimeoutError) as error:
130
+ detail = str(error) or type(error).__name__
131
+ return ModelCatalog(error=f"Claude Code model discovery command failed: {detail}")
132
+ if result.returncode != 0:
133
+ detail = result.stderr.strip() or "no diagnostic output"
134
+ return ModelCatalog(
135
+ error=f"Claude Code model discovery exited with code {result.returncode}: {detail}"
136
+ )
137
+ try:
138
+ return parse_model_catalog(result.stdout, MODEL_CATALOG_REQUEST_ID)
139
+ except TypeError as error:
140
+ return ModelCatalog(error=f"could not parse Claude Code model catalog: {error}")
141
+
142
+ async def start_turn(self, spec: TurnSpec) -> Turn:
143
+ """Start one process-per-turn Claude Code exchange."""
144
+ return await StreamingTurn.start(spec, self._runner, self.process_sequence(spec))
145
+
146
+ def process_sequence(self, spec: TurnSpec) -> ClaudeTurnSequence:
147
+ """Build the compact, prompt and retry process sequence for `spec`."""
148
+ return ClaudeTurnSequence(
149
+ spec,
150
+ clock=self._clock,
151
+ reset_time=self._reset_time,
152
+ compact_at_pct=self._options.compact_at_pct,
153
+ args=self._options.args,
154
+ environment=self._turn_environment,
155
+ )
156
+
157
+ def interactive_command(self, session_id: str, run_dir: Path, rung: Rung) -> Sequence[str]:
158
+ """Return Claude's interactive resume command for a headless session and rung."""
159
+ argv = [CLAUDE_COMMAND, RESUME_FLAG, session_id, MODEL_FLAG, rung.model]
160
+ if rung.effort is not None:
161
+ argv.extend((EFFORT_FLAG, rung.effort))
162
+ argv.extend((ADD_DIR_FLAG, str(run_dir), *self._options.args))
163
+ return tuple(argv)
164
+
165
+ async def doctor(self, models: Sequence[str]) -> list[CheckResult]:
166
+ """Check Claude's binary, version and authentication."""
167
+ del models
168
+ binary = await self._check_binary()
169
+ if not binary.ok:
170
+ unavailable = "binary unavailable"
171
+ return [
172
+ binary,
173
+ CheckResult("version", False, unavailable),
174
+ CheckResult("login", False, unavailable),
175
+ ]
176
+ return [binary, await self._check_version(), await self._check_login()]
177
+
178
+ def settings(self) -> tuple[AgentSetting, ...]:
179
+ """Return the options worth writing to the project config."""
180
+ return CLAUDE_SETTINGS
181
+
182
+ def headless(self) -> HeadlessAgent | None:
183
+ """Return Claude's schema-constrained headless client."""
184
+ return ClaudeHeadless(self._runner, self._subprocess_environment())
185
+
186
+ def _subprocess_environment(self) -> dict[str, str]:
187
+ return dict(os.environ)
188
+
189
+ def _turn_environment(self, spec: TurnSpec) -> dict[str, str]:
190
+ environment = dict(os.environ)
191
+ environment.update(spec.env)
192
+ return environment
193
+
194
+ async def _check_binary(self) -> CheckResult:
195
+ try:
196
+ result = await self._runner.run(
197
+ CLAUDE_VERSION_ARGV,
198
+ timeout=DOCTOR_TIMEOUT_SECONDS,
199
+ env=self._subprocess_environment(),
200
+ check=False,
201
+ )
202
+ except (FileNotFoundError, CommandFailed, ShikiruError) as error:
203
+ return CheckResult("binary", False, str(error))
204
+ if result.returncode != 0:
205
+ return CheckResult("binary", False, result.stderr.strip())
206
+ return CheckResult("binary", True, CLAUDE_COMMAND)
207
+
208
+ async def _check_version(self) -> CheckResult:
209
+ try:
210
+ result = await self._runner.run(
211
+ CLAUDE_VERSION_ARGV,
212
+ timeout=DOCTOR_TIMEOUT_SECONDS,
213
+ env=self._subprocess_environment(),
214
+ check=False,
215
+ )
216
+ except (FileNotFoundError, ShikiruError) as error:
217
+ return CheckResult("version", False, str(error))
218
+ detail = result.stdout.strip() or result.stderr.strip()
219
+ return CheckResult("version", result.returncode == 0, detail)
220
+
221
+ async def _check_login(self) -> CheckResult:
222
+ try:
223
+ result = await self._runner.run(
224
+ AUTH_STATUS_ARGV,
225
+ timeout=DOCTOR_TIMEOUT_SECONDS,
226
+ env=self._subprocess_environment(),
227
+ check=False,
228
+ )
229
+ except (FileNotFoundError, ShikiruError) as error:
230
+ return CheckResult("login", False, str(error))
231
+ try:
232
+ payload = json.loads(result.stdout)
233
+ except json.JSONDecodeError:
234
+ return CheckResult("login", False, _INVALID_AUTH_STATUS_MESSAGE)
235
+ if not isinstance(payload, Mapping):
236
+ return CheckResult("login", False, _INVALID_AUTH_STATUS_MESSAGE)
237
+ typed_payload = cast("Mapping[str, object]", payload)
238
+ authenticated = (
239
+ result.returncode == 0 and typed_payload.get(_AUTH_STATUS_LOGGED_IN_FIELD) is True
240
+ )
241
+ detail = "authenticated" if authenticated else "not authenticated"
242
+ return CheckResult("login", authenticated, detail)
243
+
244
+
245
+ def create(context: AgentContext) -> ClaudeAgent:
246
+ """Build the Claude Code plugin from validated `AgentContext.options`."""
247
+ try:
248
+ options = ClaudeOptions.model_validate(context.options)
249
+ except ValidationError as error:
250
+ raise options_error(CLAUDE_COMMAND, error) from error
251
+ return ClaudeAgent(context.runner, context.clock, options)
@@ -0,0 +1,116 @@
1
+ """Claude's context window and next-request token estimate used for compaction decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import TYPE_CHECKING, Any, cast
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+ from pathlib import Path
12
+
13
+ CONTEXT_WINDOW_TOKENS = 200_000
14
+ EXTENDED_CONTEXT_WINDOW_TOKENS = 1_000_000
15
+ EXTENDED_CONTEXT_MODEL_SUFFIX = "[1m]"
16
+ CHARS_PER_TOKEN = 3
17
+ _PERCENT = 100
18
+ _TYPE_FIELD = "type"
19
+ _ASSISTANT_TYPE = "assistant"
20
+ _USER_TYPE = "user"
21
+ _TOOL_RESULT_TYPE = "tool_result"
22
+ _MESSAGE_FIELD = "message"
23
+ _ID_FIELD = "id"
24
+ _USAGE_FIELD = "usage"
25
+ _CONTENT_FIELD = "content"
26
+ _CONTEXT_USAGE_FIELDS = (
27
+ "input_tokens",
28
+ "cache_read_input_tokens",
29
+ "cache_creation_input_tokens",
30
+ "output_tokens",
31
+ )
32
+
33
+
34
+ def compact_limit_tokens(model: str, compact_at_pct: float) -> int:
35
+ """Return the tokens at which a request on `model` must be preceded by compaction."""
36
+ window = (
37
+ EXTENDED_CONTEXT_WINDOW_TOKENS
38
+ if model.endswith(EXTENDED_CONTEXT_MODEL_SUFFIX)
39
+ else CONTEXT_WINDOW_TOKENS
40
+ )
41
+ return int(window * compact_at_pct / _PERCENT)
42
+
43
+
44
+ def pending_tokens(value: object) -> int:
45
+ """Estimate the tokens a tool result adds to the next request."""
46
+ return len(json.dumps(value)) // CHARS_PER_TOKEN
47
+
48
+
49
+ def read_transcript(path: Path) -> list[Mapping[str, Any]]:
50
+ """Return the JSON object entries of a Claude session transcript."""
51
+ entries: list[Mapping[str, Any]] = []
52
+ for line in path.read_text(encoding="utf-8").splitlines():
53
+ raw = json.loads(line)
54
+ if isinstance(raw, Mapping):
55
+ entries.append(cast("Mapping[str, Any]", raw))
56
+ return entries
57
+
58
+
59
+ def last_assistant_context(entries: Iterable[Mapping[str, Any]]) -> tuple[str | None, int]:
60
+ """Return the id of the last assistant message and the context its request used."""
61
+ message_id: str | None = None
62
+ tokens = 0
63
+ for entry in entries:
64
+ message = _assistant_message(entry)
65
+ if message is not None:
66
+ raw_id = message.get(_ID_FIELD)
67
+ message_id = raw_id if isinstance(raw_id, str) else None
68
+ tokens = _usage_tokens(message.get(_USAGE_FIELD))
69
+ return message_id, tokens
70
+
71
+
72
+ def transcript_context_tokens(entries: Iterable[Mapping[str, Any]]) -> int:
73
+ """Estimate the next request: the last assistant context plus the tool results since."""
74
+ tokens = 0
75
+ pending = 0
76
+ for entry in entries:
77
+ message = _assistant_message(entry)
78
+ if message is not None:
79
+ tokens = _usage_tokens(message.get(_USAGE_FIELD))
80
+ pending = 0
81
+ else:
82
+ pending += sum(pending_tokens(result) for result in _tool_results(entry))
83
+ return tokens + pending
84
+
85
+
86
+ def _assistant_message(entry: Mapping[str, Any]) -> Mapping[str, Any] | None:
87
+ if entry.get(_TYPE_FIELD) != _ASSISTANT_TYPE:
88
+ return None
89
+ return _mapping(entry.get(_MESSAGE_FIELD))
90
+
91
+
92
+ def _tool_results(entry: Mapping[str, Any]) -> list[object]:
93
+ message = _mapping(entry.get(_MESSAGE_FIELD)) if entry.get(_TYPE_FIELD) == _USER_TYPE else None
94
+ blocks = message.get(_CONTENT_FIELD) if message is not None else None
95
+ if not isinstance(blocks, list):
96
+ return []
97
+ return [
98
+ block.get(_CONTENT_FIELD)
99
+ for item in cast("list[object]", blocks)
100
+ if (block := _mapping(item)) is not None and block.get(_TYPE_FIELD) == _TOOL_RESULT_TYPE
101
+ ]
102
+
103
+
104
+ def _usage_tokens(value: object) -> int:
105
+ usage = _mapping(value)
106
+ if usage is None:
107
+ return 0
108
+ return sum(
109
+ int(amount)
110
+ for field in _CONTEXT_USAGE_FIELDS
111
+ if isinstance(amount := usage.get(field), int | float)
112
+ )
113
+
114
+
115
+ def _mapping(value: object) -> Mapping[str, Any] | None:
116
+ return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
@@ -0,0 +1,92 @@
1
+ """Claude Code schema-constrained one-shot calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import TYPE_CHECKING, Any, cast
8
+
9
+ from shikiru.errors import HeadlessOutputError
10
+ from shikiru.plugins.agents.claude.options import (
11
+ CLAUDE_COMMAND,
12
+ EFFORT_FLAG,
13
+ EMPTY_TOOLS,
14
+ MODEL_FLAG,
15
+ NO_SESSION_PERSISTENCE_FLAG,
16
+ OUTPUT_FORMAT_FLAG,
17
+ PRINT_FLAG,
18
+ SAFE_MODE_FLAG,
19
+ TOOLS_FLAG,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from datetime import timedelta
24
+ from pathlib import Path
25
+
26
+ from shikiru.process import SubprocessRunner
27
+
28
+ _OUTPUT_FORMAT_JSON = "json"
29
+ _JSON_SCHEMA_FLAG = "--json-schema"
30
+ _STRUCTURED_OUTPUT_FIELD = "structured_output"
31
+ _INVALID_JSON_MESSAGE = "Claude headless output was not valid JSON"
32
+ _INVALID_OBJECT_MESSAGE = "Claude headless output was not an object"
33
+ _MISSING_STRUCTURED_OUTPUT_MESSAGE = "Claude headless output had no structured_output"
34
+
35
+
36
+ class ClaudeHeadless:
37
+ """Runs Claude Code's JSON structured-output mode."""
38
+
39
+ def __init__(self, runner: SubprocessRunner, environment: Mapping[str, str]) -> None:
40
+ """Use `runner` and the operator's inherited Claude environment."""
41
+ self._runner = runner
42
+ self._environment = dict(environment)
43
+
44
+ async def ask(
45
+ self,
46
+ prompt: str,
47
+ schema: Mapping[str, Any],
48
+ model: str,
49
+ effort: str | None,
50
+ cwd: Path,
51
+ timeout: timedelta,
52
+ ) -> Mapping[str, Any]:
53
+ """Return Claude's schema-constrained `structured_output` value."""
54
+ argv = [
55
+ CLAUDE_COMMAND,
56
+ PRINT_FLAG,
57
+ OUTPUT_FORMAT_FLAG,
58
+ _OUTPUT_FORMAT_JSON,
59
+ _JSON_SCHEMA_FLAG,
60
+ json.dumps(schema, separators=(",", ":")),
61
+ MODEL_FLAG,
62
+ model,
63
+ ]
64
+ if effort is not None:
65
+ argv.extend([EFFORT_FLAG, effort])
66
+ argv.extend(
67
+ [
68
+ SAFE_MODE_FLAG,
69
+ TOOLS_FLAG,
70
+ EMPTY_TOOLS,
71
+ NO_SESSION_PERSISTENCE_FLAG,
72
+ ]
73
+ )
74
+ result = await self._runner.run(
75
+ argv,
76
+ timeout=timeout.total_seconds(),
77
+ cwd=cwd,
78
+ env=self._environment,
79
+ input_text=prompt,
80
+ check=True,
81
+ )
82
+ try:
83
+ payload = json.loads(result.stdout)
84
+ except json.JSONDecodeError as error:
85
+ raise HeadlessOutputError(_INVALID_JSON_MESSAGE) from error
86
+ if not isinstance(payload, Mapping):
87
+ raise HeadlessOutputError(_INVALID_OBJECT_MESSAGE)
88
+ typed_payload = cast("Mapping[str, Any]", payload)
89
+ structured = typed_payload.get(_STRUCTURED_OUTPUT_FIELD)
90
+ if not isinstance(structured, Mapping):
91
+ raise HeadlessOutputError(_MISSING_STRUCTURED_OUTPUT_MESSAGE)
92
+ return cast("Mapping[str, Any]", structured)
@@ -0,0 +1,57 @@
1
+ """Claude `PostToolUse` hook: stop the turn before a request would cross the limit.
2
+
3
+ Run as `<python> -P -m shikiru.plugins.agents.claude.hook <state> <marker> <limit_tokens>`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from shikiru.plugins.agents.claude.context import (
13
+ last_assistant_context,
14
+ pending_tokens,
15
+ read_transcript,
16
+ )
17
+
18
+ TRANSCRIPT_PATH_FIELD = "transcript_path"
19
+ TOOL_RESPONSE_FIELD = "tool_response"
20
+ STOP_REASON = "Shikiru: context threshold reached; compacting before the next request"
21
+ _CONTINUE_FIELD = "continue"
22
+ _STOP_REASON_FIELD = "stopReason"
23
+ _STATE_ID_FIELD = "id"
24
+ _STATE_TOKENS_FIELD = "tokens"
25
+
26
+
27
+ def main() -> None:
28
+ """Record this tool result and stop the turn once the next request reaches the limit."""
29
+ _, state_argument, marker_argument, limit_argument = sys.argv
30
+ state_path, marker_path, limit = (
31
+ Path(state_argument),
32
+ Path(marker_argument),
33
+ int(limit_argument),
34
+ )
35
+ event = json.load(sys.stdin)
36
+ message_id, context = last_assistant_context(
37
+ read_transcript(Path(event[TRANSCRIPT_PATH_FIELD]))
38
+ )
39
+ pending = _record_pending(
40
+ state_path, message_id, pending_tokens(event.get(TOOL_RESPONSE_FIELD))
41
+ )
42
+ if context + pending >= limit:
43
+ marker_path.touch()
44
+ json.dump({_CONTINUE_FIELD: False, _STOP_REASON_FIELD: STOP_REASON}, sys.stdout)
45
+
46
+
47
+ def _record_pending(state_path: Path, message_id: str | None, tokens: int) -> int:
48
+ with state_path.open("a", encoding="utf-8") as handle:
49
+ handle.write(json.dumps({_STATE_ID_FIELD: message_id, _STATE_TOKENS_FIELD: tokens}) + "\n")
50
+ records = [json.loads(line) for line in state_path.read_text(encoding="utf-8").splitlines()]
51
+ return sum(
52
+ record[_STATE_TOKENS_FIELD] for record in records if record[_STATE_ID_FIELD] == message_id
53
+ )
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,105 @@
1
+ """Claude Code initialize response model catalog parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import Any, cast
8
+
9
+ from shikiru.agents.contracts import ModelCatalog, ModelChoice
10
+
11
+ _CONTROL_RESPONSE_TYPE = "control_response"
12
+ _SUCCESS_SUBTYPE = "success"
13
+ _INVALID_RESPONSE = "Claude Code initialize response has an invalid model catalog"
14
+ _INVALID_JSON = "Claude Code initialize output is not valid stream JSON"
15
+
16
+
17
+ def parse_model_catalog(output: str, request_id: str) -> ModelCatalog:
18
+ """Parse only model choices from the matching Claude Code initialize response."""
19
+ response = _matching_initialize_response(output, request_id)
20
+ raw_models = response.get("models")
21
+ if not isinstance(raw_models, list) or not raw_models:
22
+ raise TypeError(_INVALID_RESPONSE)
23
+ return ModelCatalog(models=_catalog_choices(cast("list[object]", raw_models)))
24
+
25
+
26
+ def _matching_initialize_response(output: str, request_id: str) -> Mapping[str, Any]:
27
+ response: Mapping[str, Any] | None = None
28
+ for line in output.splitlines():
29
+ if not line.strip():
30
+ continue
31
+ try:
32
+ raw_message = json.loads(line)
33
+ except json.JSONDecodeError as error:
34
+ raise TypeError(_INVALID_JSON) from error
35
+ if not isinstance(raw_message, Mapping):
36
+ raise TypeError(_INVALID_RESPONSE)
37
+ message = cast("Mapping[str, Any]", raw_message)
38
+ control_response = message.get("response")
39
+ if message.get("type") != _CONTROL_RESPONSE_TYPE or not isinstance(
40
+ control_response, Mapping
41
+ ):
42
+ continue
43
+ typed_response = cast("Mapping[str, Any]", control_response)
44
+ if typed_response.get("request_id") != request_id:
45
+ continue
46
+ if typed_response.get("subtype") != _SUCCESS_SUBTYPE:
47
+ raise TypeError(_INVALID_RESPONSE)
48
+ initialize_response = typed_response.get("response")
49
+ if not isinstance(initialize_response, Mapping):
50
+ raise TypeError(_INVALID_RESPONSE)
51
+ response = cast("Mapping[str, Any]", initialize_response)
52
+ break
53
+ if response is None:
54
+ raise TypeError(_INVALID_RESPONSE)
55
+ return response
56
+
57
+
58
+ def _catalog_choices(raw_models: list[object]) -> tuple[ModelChoice, ...]:
59
+ choices: dict[str, ModelChoice] = {}
60
+ for raw_model in raw_models:
61
+ for choice in _model_choices(raw_model):
62
+ existing = choices.get(choice.model)
63
+ if existing is not None and existing.efforts != choice.efforts:
64
+ raise TypeError(_INVALID_RESPONSE)
65
+ choices[choice.model] = choice
66
+ return tuple(choices.values())
67
+
68
+
69
+ def _model_choices(raw_model: object) -> tuple[ModelChoice, ...]:
70
+ if not isinstance(raw_model, Mapping):
71
+ raise TypeError(_INVALID_RESPONSE)
72
+ model = cast("Mapping[str, Any]", raw_model)
73
+ value = model.get("value")
74
+ if not isinstance(value, str) or not value:
75
+ raise TypeError(_INVALID_RESPONSE)
76
+ efforts = _efforts(model)
77
+ identifiers = [value]
78
+ resolved = model.get("resolvedModel")
79
+ if resolved is not None:
80
+ if not isinstance(resolved, str) or not resolved:
81
+ raise TypeError(_INVALID_RESPONSE)
82
+ identifiers.append(resolved)
83
+ unique_identifiers = tuple(dict.fromkeys(identifiers))
84
+ return tuple(
85
+ ModelChoice(model=identifier, efforts=efforts) for identifier in unique_identifiers
86
+ )
87
+
88
+
89
+ def _efforts(model: Mapping[str, Any]) -> tuple[str, ...] | None:
90
+ supported = model.get("supportsEffort")
91
+ if supported is not None and not isinstance(supported, bool):
92
+ raise TypeError(_INVALID_RESPONSE)
93
+ if supported is False:
94
+ return ()
95
+ raw_efforts = model.get("supportedEffortLevels")
96
+ if raw_efforts is None:
97
+ return None
98
+ if not isinstance(raw_efforts, list):
99
+ raise TypeError(_INVALID_RESPONSE)
100
+ efforts: list[str] = []
101
+ for effort in cast("list[object]", raw_efforts):
102
+ if not isinstance(effort, str) or not effort or effort in efforts:
103
+ raise TypeError(_INVALID_RESPONSE)
104
+ efforts.append(effort)
105
+ return tuple(efforts)
@@ -0,0 +1,62 @@
1
+ """Claude Code plugin options and protocol constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from shikiru.agents.contracts import PERCENT, BillingMode
10
+ from shikiru.plugins.agents.shared_settings import (
11
+ billing_declaration_setting,
12
+ cli_args_setting,
13
+ compact_threshold_setting,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from shikiru.agents.settings import AgentSetting
18
+
19
+ CLAUDE_COMMAND = "claude"
20
+ PRINT_FLAG = "-p"
21
+ INPUT_FORMAT_FLAG = "--input-format"
22
+ OUTPUT_FORMAT_FLAG = "--output-format"
23
+ STREAM_JSON_FORMAT = "stream-json"
24
+ VERBOSE_FLAG = "--verbose"
25
+ NO_SESSION_PERSISTENCE_FLAG = "--no-session-persistence"
26
+ SAFE_MODE_FLAG = "--safe-mode"
27
+ STRICT_MCP_CONFIG_FLAG = "--strict-mcp-config"
28
+ MCP_CONFIG_FLAG = "--mcp-config"
29
+ EMPTY_MCP_CONFIG = '{"mcpServers":{}}'
30
+ TOOLS_FLAG = "--tools"
31
+ EMPTY_TOOLS = ""
32
+ MODEL_CATALOG_REQUEST_ID = "shikiru-model-catalog"
33
+ DEFAULT_COMPACT_AT_PCT = 80.0
34
+ STATUS_PAYMENT_REQUIRED = 402
35
+ STATUS_TOO_MANY_REQUESTS = 429
36
+ STATUS_UNAUTHORIZED = 401
37
+ STATUS_FORBIDDEN = 403
38
+ PERMISSION_MODE_FLAG = "--permission-mode"
39
+ DEFAULT_ARGS = (PERMISSION_MODE_FLAG, "bypassPermissions")
40
+ SESSION_ID_FLAG = "--session-id"
41
+ RESUME_FLAG = "--resume"
42
+ MODEL_FLAG = "--model"
43
+ EFFORT_FLAG = "--effort"
44
+ ADD_DIR_FLAG = "--add-dir"
45
+ SETTINGS_FLAG = "--settings"
46
+
47
+
48
+ class ClaudeOptions(BaseModel):
49
+ """Options accepted by the Claude Code agent factory."""
50
+
51
+ model_config = ConfigDict(extra="forbid", frozen=True)
52
+
53
+ args: list[str] = Field(default_factory=lambda: list(DEFAULT_ARGS))
54
+ compact_at_pct: float = Field(default=DEFAULT_COMPACT_AT_PCT, ge=0, le=PERCENT)
55
+ billing_mode: BillingMode | None = None
56
+
57
+
58
+ CLAUDE_SETTINGS: tuple[AgentSetting, ...] = (
59
+ compact_threshold_setting(DEFAULT_COMPACT_AT_PCT),
60
+ billing_declaration_setting(),
61
+ cli_args_setting(DEFAULT_ARGS),
62
+ )