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,128 @@
1
+ """Codex CLI 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 ModelChoice
10
+
11
+ _MODELS_FIELD = "models"
12
+ _MODEL_SLUG_FIELD = "slug"
13
+ _MODEL_CONTEXT_WINDOW_FIELD = "context_window"
14
+ _MODEL_VISIBILITY_FIELD = "visibility"
15
+ _MODEL_SUPPORTED_IN_API_FIELD = "supported_in_api"
16
+ _MODEL_REASONING_LEVELS_FIELD = "supported_reasoning_levels"
17
+ _MODEL_EFFORT_FIELD = "effort"
18
+ _LIST_VISIBILITY = "list"
19
+ _HIDDEN_VISIBILITY = "hide"
20
+ _INVALID_CATALOG_MESSAGE = "Codex CLI model catalog has an invalid structure"
21
+ _INVALID_JSON_MESSAGE = "Codex CLI model catalog is not valid JSON"
22
+
23
+
24
+ def parse_bundled_context_windows(output: str) -> dict[str, int]:
25
+ """Parse positive model context windows from Codex CLI's bundled model catalog."""
26
+ windows: dict[str, int] = {}
27
+ for raw_model in _bundled_models(output):
28
+ if not isinstance(raw_model, Mapping):
29
+ continue
30
+ model = cast("Mapping[str, Any]", raw_model)
31
+ slug = model.get(_MODEL_SLUG_FIELD)
32
+ window = model.get(_MODEL_CONTEXT_WINDOW_FIELD)
33
+ if (
34
+ isinstance(slug, str)
35
+ and isinstance(window, int)
36
+ and not isinstance(window, bool)
37
+ and window > 0
38
+ ):
39
+ windows[slug] = window
40
+ return windows
41
+
42
+
43
+ def parse_bundled_model_choices(output: str) -> tuple[ModelChoice, ...]:
44
+ """Parse API-supported models and only their reported effort choices."""
45
+ return parse_catalog(output)[0]
46
+
47
+
48
+ def parse_catalog(output: str) -> tuple[tuple[ModelChoice, ...], dict[str, int]]:
49
+ """Parse model choices and context windows from one Codex catalog payload."""
50
+ choices: list[ModelChoice] = []
51
+ seen: set[str] = set()
52
+ context_windows: dict[str, int] = {}
53
+ for raw_model in _bundled_models(output):
54
+ if not isinstance(raw_model, Mapping):
55
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
56
+ model = cast("Mapping[str, Any]", raw_model)
57
+ slug = model.get(_MODEL_SLUG_FIELD)
58
+ window = model.get(_MODEL_CONTEXT_WINDOW_FIELD)
59
+ if (
60
+ isinstance(slug, str)
61
+ and isinstance(window, int)
62
+ and not isinstance(window, bool)
63
+ and window > 0
64
+ ):
65
+ context_windows[slug] = window
66
+ choice = _model_choice(model)
67
+ if choice is None:
68
+ continue
69
+ if choice.model in seen:
70
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
71
+ seen.add(choice.model)
72
+ choices.append(choice)
73
+ return tuple(choices), context_windows
74
+
75
+
76
+ def _bundled_models(output: str) -> list[object]:
77
+ try:
78
+ payload = json.loads(output)
79
+ except json.JSONDecodeError as error:
80
+ raise TypeError(_INVALID_JSON_MESSAGE) from error
81
+ if not isinstance(payload, Mapping):
82
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
83
+ typed_payload = cast("Mapping[str, Any]", payload)
84
+ raw_models = typed_payload.get(_MODELS_FIELD)
85
+ if not isinstance(raw_models, list):
86
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
87
+ return cast("list[object]", raw_models)
88
+
89
+
90
+ def _model_choice(raw_model: object) -> ModelChoice | None:
91
+ if not isinstance(raw_model, Mapping):
92
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
93
+ model = cast("Mapping[str, Any]", raw_model)
94
+ visibility = model.get(_MODEL_VISIBILITY_FIELD)
95
+ if not isinstance(visibility, str):
96
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
97
+ if visibility not in {_LIST_VISIBILITY, _HIDDEN_VISIBILITY}:
98
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
99
+ supported_in_api = model.get(_MODEL_SUPPORTED_IN_API_FIELD)
100
+ if not isinstance(supported_in_api, bool):
101
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
102
+ if not supported_in_api:
103
+ return None
104
+ slug = model.get(_MODEL_SLUG_FIELD)
105
+ if not isinstance(slug, str) or not slug:
106
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
107
+ return ModelChoice(
108
+ model=slug,
109
+ efforts=_effort_choices(model),
110
+ offered=visibility == _LIST_VISIBILITY,
111
+ )
112
+
113
+
114
+ def _effort_choices(model: Mapping[str, Any]) -> tuple[str, ...] | None:
115
+ raw_levels = model.get(_MODEL_REASONING_LEVELS_FIELD)
116
+ if raw_levels is None:
117
+ return None
118
+ if not isinstance(raw_levels, list):
119
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
120
+ effort_values: list[str] = []
121
+ for raw_level in cast("list[object]", raw_levels):
122
+ if not isinstance(raw_level, Mapping):
123
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
124
+ effort = cast("Mapping[str, Any]", raw_level).get(_MODEL_EFFORT_FIELD)
125
+ if not isinstance(effort, str) or not effort or effort in effort_values:
126
+ raise TypeError(_INVALID_CATALOG_MESSAGE)
127
+ effort_values.append(effort)
128
+ return tuple(effort_values)
@@ -0,0 +1,94 @@
1
+ """Codex 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
+ CODEX_HOME_ENV_VAR = "CODEX_HOME"
20
+ CODEX_COMMAND = "codex"
21
+ DEBUG_SUBCOMMAND = "debug"
22
+ MODELS_SUBCOMMAND = "models"
23
+ MODELS_BUNDLED_FLAG = "--bundled"
24
+ MODELS_CACHE_FILENAME = "models_cache.json"
25
+ CODEX_MODEL_CATALOG_ARGV = (CODEX_COMMAND, DEBUG_SUBCOMMAND, MODELS_SUBCOMMAND)
26
+ PROFILE_FLAG = "--profile"
27
+ MODEL_LONG_FLAG = "--model"
28
+ EFFORT_LONG_FLAG = "--effort"
29
+ CONFIG_LONG_FLAG = "--config"
30
+ MODEL_CONFIG_OVERRIDE = "model_provider"
31
+ MODEL_OVERRIDE = "model"
32
+ REASONING_EFFORT_CONFIG = "model_reasoning_effort"
33
+ MODEL_CATALOG_ARGS_DETAIL = (
34
+ "Codex agent args select a model, effort, or profile; its effective model catalog "
35
+ "cannot be determined safely"
36
+ )
37
+ CODEX_BUNDLED_MODELS_ARGV = (
38
+ CODEX_COMMAND,
39
+ DEBUG_SUBCOMMAND,
40
+ MODELS_SUBCOMMAND,
41
+ MODELS_BUNDLED_FLAG,
42
+ )
43
+ RESUME_SUBCOMMAND = "resume"
44
+ NO_DAEMON_FLAG = "--no-daemon"
45
+ MODEL_FLAG = "-m"
46
+ CONFIG_FLAG = "-c"
47
+ EXEC_SUBCOMMAND = "exec"
48
+ EPHEMERAL_FLAG = "--ephemeral"
49
+ JSON_FLAG = "--json"
50
+ OUTPUT_SCHEMA_FLAG = "--output-schema"
51
+ SANDBOX_FLAG = "-s"
52
+ SANDBOX_MODE_CONFIG = "sandbox_mode"
53
+ WRITABLE_ROOTS_CONFIG = "sandbox_workspace_write.writable_roots"
54
+ READ_ONLY_SANDBOX = "read-only"
55
+ WORKSPACE_WRITE_SANDBOX = "workspace-write"
56
+ SKIP_GIT_REPO_CHECK_FLAG = "--skip-git-repo-check"
57
+ AUTO_COMPACT_LIMIT_CONFIG = "model_auto_compact_token_limit"
58
+ VERSION_ARGV = (CODEX_COMMAND, "--version")
59
+ LOGIN_STATUS_ARGV = (CODEX_COMMAND, "login", "status")
60
+ DEFAULT_COMPACT_AT_PCT = 80.0
61
+ USAGE_EVENT_TYPE = "token_count"
62
+ GIT_DIRS_TIMEOUT_SECONDS = 10.0
63
+
64
+
65
+ class CodexOptions(BaseModel):
66
+ """Options accepted by the Codex agent factory."""
67
+
68
+ model_config = ConfigDict(extra="forbid", frozen=True)
69
+
70
+ args: list[str] = Field(default_factory=list)
71
+ compact_at_pct: float = Field(default=DEFAULT_COMPACT_AT_PCT, ge=0, le=PERCENT)
72
+ billing_mode: BillingMode | None = None
73
+
74
+
75
+ TESTED_CODEX_VERSION = "0.159.2"
76
+ SANDBOX_PROBE_TIMEOUT_SECONDS = 60.0
77
+ SANDBOX_PROBE_PARENT = (".cache", "shikiru")
78
+ SANDBOX_PROBE_PREFIX = "codex-sandbox-"
79
+ GIT_COMMAND = "git"
80
+ END_OF_OPTIONS_FLAG = "--"
81
+ GIT_DIRS_ARGV = (
82
+ GIT_COMMAND,
83
+ "rev-parse",
84
+ "--path-format=absolute",
85
+ "--absolute-git-dir",
86
+ "--git-common-dir",
87
+ )
88
+
89
+
90
+ CODEX_SETTINGS: tuple[AgentSetting, ...] = (
91
+ compact_threshold_setting(DEFAULT_COMPACT_AT_PCT),
92
+ billing_declaration_setting(),
93
+ cli_args_setting(()),
94
+ )
@@ -0,0 +1,195 @@
1
+ """Codex rollout quota parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ from shikiru.agents.contracts import (
13
+ QUOTA_WINDOW_FIVE_HOUR,
14
+ QUOTA_WINDOW_SEVEN_DAY,
15
+ QuotaReading,
16
+ QuotaWindow,
17
+ )
18
+ from shikiru.plugins.agents.codex.options import USAGE_EVENT_TYPE
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+ from shikiru.agents.sdk.reset_time import ResetTimeParser
24
+ from shikiru.clock import Clock
25
+
26
+ _EVENT_MESSAGE_TYPE = "event_msg"
27
+ _RATE_LIMITS_FIELD = "rate_limits"
28
+ _PRIMARY_WINDOW = "primary"
29
+ _SECONDARY_WINDOW = "secondary"
30
+ _DEFAULT_WINDOW_NAMES = {
31
+ _PRIMARY_WINDOW: QUOTA_WINDOW_FIVE_HOUR,
32
+ _SECONDARY_WINDOW: QUOTA_WINDOW_SEVEN_DAY,
33
+ }
34
+ _WINDOW_MINUTES_FIELD = "window_minutes"
35
+ _TIMESTAMP_FIELD = "timestamp"
36
+ _MINUTES_PER_HOUR = 60
37
+ _MINUTES_PER_DAY = 24 * _MINUTES_PER_HOUR
38
+ _USED_PERCENT_FIELD = "used_percent"
39
+ _RESETS_AT_FIELD = "resets_at"
40
+ _ROLLOUT_GLOB = "rollout-*.jsonl"
41
+ _LIMIT_ID_FIELD = "limit_id"
42
+ CODEX_LIMIT_ID = "codex"
43
+ _ROLLOUT_TAIL_BYTES = 1 << 20
44
+ _NEWLINE = b"\n"
45
+ _FILE_START = 0
46
+ _AFTER_SEPARATOR = -1
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class QuotaObservation:
51
+ """A Codex quota sample and its provider timestamp, when present."""
52
+
53
+ windows: tuple[QuotaWindow, ...]
54
+ timestamp: datetime | None
55
+
56
+
57
+ class CodexQuotaProbe:
58
+ """Reads the Codex-bucket quota windows from Codex rollouts."""
59
+
60
+ def __init__(self, home: Path, clock: Clock, reset_time: ResetTimeParser) -> None:
61
+ """Use `home`, `clock` and `reset_time` to inspect Codex session files."""
62
+ self._home = home
63
+ self._clock = clock
64
+ self._reset_time = reset_time
65
+
66
+ def get(self, rollout_path: Path | None = None) -> list[QuotaWindow]:
67
+ """Return the latest primary and secondary quota windows, or no windows."""
68
+ path = rollout_path or self._newest_rollout()
69
+ if path is None or not path.is_file():
70
+ return []
71
+ return self.parse(path.read_text(encoding="utf-8"), self._clock.now())
72
+
73
+ def parse(self, text: str, observed_at: datetime) -> list[QuotaWindow]:
74
+ """Parse quota windows from Codex rollout JSONL text."""
75
+ observation = self.parse_observation(text, observed_at)
76
+ return list(observation.windows) if observation is not None else []
77
+
78
+ def parse_observation(self, text: str, observed_at: datetime) -> QuotaObservation | None:
79
+ """Parse the latest quota sample and preserve its provider timestamp."""
80
+ latest: tuple[Mapping[str, Any], datetime | None] | None = None
81
+ for line in text.splitlines():
82
+ try:
83
+ raw = json.loads(line)
84
+ except json.JSONDecodeError:
85
+ continue
86
+ if not isinstance(raw, Mapping):
87
+ continue
88
+ payload = _payload(cast("Mapping[str, Any]", raw))
89
+ if payload.get("type") != USAGE_EVENT_TYPE:
90
+ continue
91
+ limits = payload.get(_RATE_LIMITS_FIELD)
92
+ if _is_codex_bucket(limits) and _has_windows(cast("Mapping[str, Any]", limits)):
93
+ latest = (
94
+ cast("Mapping[str, Any]", limits),
95
+ _event_time(cast("Mapping[str, Any]", raw)),
96
+ )
97
+ if latest is None:
98
+ return None
99
+ rate_limits, event_time = latest
100
+ window_time = event_time or observed_at
101
+ windows = tuple(
102
+ self._window(key, cast("Mapping[str, Any]", rate_limits[key]), window_time)
103
+ for key in _DEFAULT_WINDOW_NAMES
104
+ if isinstance(rate_limits.get(key), Mapping)
105
+ )
106
+ return QuotaObservation(windows, event_time)
107
+
108
+ def latest_reading(self, since: datetime) -> QuotaReading | None:
109
+ """Return the newest Codex-bucket sample stamped after `since` in any recent rollout.
110
+
111
+ Only rollouts written after `since` are read, from their tail; a sample without a
112
+ provider timestamp cannot show it is fresh and is ignored.
113
+ """
114
+ sessions = self._home / "sessions"
115
+ if not sessions.is_dir():
116
+ return None
117
+ readings = [
118
+ QuotaReading(observation.windows, observation.timestamp)
119
+ for path in sessions.rglob(_ROLLOUT_GLOB)
120
+ if datetime.fromtimestamp(path.stat().st_mtime, tz=UTC) > since
121
+ for observation in [self.parse_observation(_rollout_tail(path), self._clock.now())]
122
+ if observation is not None
123
+ and observation.timestamp is not None
124
+ and observation.timestamp > since
125
+ ]
126
+ return max(readings, key=lambda reading: reading.observed_at, default=None)
127
+
128
+ def _window(self, key: str, raw: Mapping[str, Any], observed_at: datetime) -> QuotaWindow:
129
+ value = raw.get(_USED_PERCENT_FIELD)
130
+ if not isinstance(value, int | float):
131
+ message = f"Codex quota window {key!r} has no numeric used_percent"
132
+ raise TypeError(message)
133
+ reset_value = raw.get(_RESETS_AT_FIELD)
134
+ resets_at = self._reset_time.parse(str(reset_value)) if reset_value is not None else None
135
+ return QuotaWindow(
136
+ name=_window_name(key, raw.get(_WINDOW_MINUTES_FIELD)),
137
+ used_pct=float(value),
138
+ resets_at=resets_at,
139
+ observed_at=observed_at,
140
+ )
141
+
142
+ def _newest_rollout(self) -> Path | None:
143
+ sessions = self._home / "sessions"
144
+ paths = list(sessions.rglob(_ROLLOUT_GLOB)) if sessions.is_dir() else []
145
+ return max(paths, key=lambda path: path.stat().st_mtime_ns) if paths else None
146
+
147
+
148
+ def _is_codex_bucket(limits: object) -> bool:
149
+ if not isinstance(limits, Mapping):
150
+ return False
151
+ limit_id = cast("Mapping[str, Any]", limits).get(_LIMIT_ID_FIELD)
152
+ return limit_id is None or limit_id == CODEX_LIMIT_ID
153
+
154
+
155
+ def _has_windows(limits: Mapping[str, Any]) -> bool:
156
+ return any(isinstance(limits.get(key), Mapping) for key in _DEFAULT_WINDOW_NAMES)
157
+
158
+
159
+ def _rollout_tail(path: Path) -> str:
160
+ with path.open("rb") as handle:
161
+ size = handle.seek(_FILE_START, os.SEEK_END)
162
+ handle.seek(max(size - _ROLLOUT_TAIL_BYTES, _FILE_START))
163
+ data = handle.read()
164
+ if size > _ROLLOUT_TAIL_BYTES:
165
+ data = data.partition(_NEWLINE)[_AFTER_SEPARATOR]
166
+ return data.decode("utf-8", errors="ignore")
167
+
168
+
169
+ def _payload(entry: Mapping[str, Any]) -> Mapping[str, Any]:
170
+ if entry.get("type") == _EVENT_MESSAGE_TYPE:
171
+ payload = entry.get("payload")
172
+ if isinstance(payload, Mapping):
173
+ return cast("Mapping[str, Any]", payload)
174
+ return entry
175
+
176
+
177
+ def _event_time(entry: Mapping[str, Any]) -> datetime | None:
178
+ value = entry.get(_TIMESTAMP_FIELD)
179
+ if not isinstance(value, str):
180
+ return None
181
+ try:
182
+ parsed = datetime.fromisoformat(value)
183
+ except ValueError:
184
+ return None
185
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
186
+
187
+
188
+ def _window_name(key: str, minutes: object) -> str:
189
+ if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes <= 0:
190
+ return _DEFAULT_WINDOW_NAMES[key]
191
+ if minutes % _MINUTES_PER_DAY == 0:
192
+ return f"{minutes // _MINUTES_PER_DAY}d"
193
+ if minutes % _MINUTES_PER_HOUR == 0:
194
+ return f"{minutes // _MINUTES_PER_HOUR}h"
195
+ return f"{minutes}m"
@@ -0,0 +1,31 @@
1
+ """Codex sandbox configuration shared by turns and the doctor probe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from shikiru.plugins.agents.codex.options import (
9
+ CONFIG_FLAG,
10
+ SANDBOX_MODE_CONFIG,
11
+ WORKSPACE_WRITE_SANDBOX,
12
+ WRITABLE_ROOTS_CONFIG,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+
20
+ def sandbox_config_args(roots: Sequence[Path]) -> list[str]:
21
+ """Return the `-c` arguments that make `roots` writable under `workspace-write`.
22
+
23
+ Configuration overrides are the one mechanism `codex exec` and `codex exec resume` share;
24
+ `exec resume` has no `--sandbox` or `--add-dir`.
25
+ """
26
+ return [
27
+ CONFIG_FLAG,
28
+ f'{SANDBOX_MODE_CONFIG}="{WORKSPACE_WRITE_SANDBOX}"',
29
+ CONFIG_FLAG,
30
+ f"{WRITABLE_ROOTS_CONFIG}={json.dumps([str(root) for root in roots])}",
31
+ ]
@@ -0,0 +1,97 @@
1
+ """Free Codex sandbox probe: a real `codex sandbox` run in a scratch linked worktree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shikiru.agents.contracts import CheckResult
12
+ from shikiru.errors import ShikiruError
13
+ from shikiru.plugins.agents.codex.options import (
14
+ CODEX_COMMAND,
15
+ END_OF_OPTIONS_FLAG,
16
+ GIT_COMMAND,
17
+ GIT_DIRS_ARGV,
18
+ SANDBOX_PROBE_PARENT,
19
+ SANDBOX_PROBE_PREFIX,
20
+ SANDBOX_PROBE_TIMEOUT_SECONDS,
21
+ )
22
+ from shikiru.plugins.agents.codex.sandbox import sandbox_config_args
23
+
24
+ if TYPE_CHECKING:
25
+ from shikiru.process import SubprocessRunner
26
+
27
+ SANDBOX_CHECK = "sandbox"
28
+ _SANDBOX_SUBCOMMAND = "sandbox"
29
+ _WORKTREE_DIR = "worktree"
30
+ _RUN_DIR = "run"
31
+ _MAIN_BRANCH = "main"
32
+ _PROBE_BRANCH = "probe"
33
+ _PROBE_FILE = ".shikiru-probe"
34
+ _GIT_IDENTITY = ("-c", "user.name=shikiru", "-c", "user.email=shikiru@localhost")
35
+ _TOUCH_SCRIPT = (
36
+ 'for directory; do touch "$directory/'
37
+ + _PROBE_FILE
38
+ + '" 2>/dev/null || echo "$directory"; done'
39
+ )
40
+ _SHELL_ARGV = ("sh", "-c", _TOUCH_SCRIPT, "sh")
41
+
42
+
43
+ class CodexSandboxProbe:
44
+ """Check that a linked worktree's git directory and the run directory are writable."""
45
+
46
+ def __init__(self, runner: SubprocessRunner) -> None:
47
+ self._runner = runner
48
+
49
+ async def check(self) -> CheckResult:
50
+ """Run the probe outside `$TMPDIR` and report every root the sandbox kept read-only."""
51
+ parent = Path.home().joinpath(*SANDBOX_PROBE_PARENT)
52
+ await asyncio.to_thread(parent.mkdir, parents=True, exist_ok=True)
53
+ base = Path(
54
+ await asyncio.to_thread(tempfile.mkdtemp, prefix=SANDBOX_PROBE_PREFIX, dir=parent)
55
+ )
56
+ try:
57
+ return await self._probe(base)
58
+ except (OSError, ShikiruError) as error:
59
+ return CheckResult(SANDBOX_CHECK, False, str(error))
60
+ finally:
61
+ await asyncio.to_thread(shutil.rmtree, base, ignore_errors=True)
62
+
63
+ async def _probe(self, base: Path) -> CheckResult:
64
+ repository, worktree, run_dir = base / "repo", base / _WORKTREE_DIR, base / _RUN_DIR
65
+ run_dir.mkdir()
66
+ await self._git(base, "init", "-q", "-b", _MAIN_BRANCH, str(repository))
67
+ await self._git(repository, *_GIT_IDENTITY, "commit", "-q", "--allow-empty", "-m", "probe")
68
+ await self._git(repository, "worktree", "add", "-q", "-b", _PROBE_BRANCH, str(worktree))
69
+ git_dirs = (await self._git(worktree, *GIT_DIRS_ARGV[1:])).split()
70
+ targets = (*git_dirs, str(run_dir))
71
+ result = await self._runner.run(
72
+ (
73
+ CODEX_COMMAND,
74
+ _SANDBOX_SUBCOMMAND,
75
+ *sandbox_config_args((run_dir, *map(Path, git_dirs))),
76
+ END_OF_OPTIONS_FLAG,
77
+ *_SHELL_ARGV,
78
+ *targets,
79
+ ),
80
+ timeout=SANDBOX_PROBE_TIMEOUT_SECONDS,
81
+ cwd=worktree,
82
+ check=False,
83
+ )
84
+ if result.returncode != 0:
85
+ return CheckResult(SANDBOX_CHECK, False, result.stderr.strip())
86
+ denied = result.stdout.split()
87
+ if denied:
88
+ return CheckResult(
89
+ SANDBOX_CHECK, False, f"not writable under the sandbox: {', '.join(denied)}"
90
+ )
91
+ return CheckResult(SANDBOX_CHECK, True, "worktree git directory and run directory writable")
92
+
93
+ async def _git(self, cwd: Path, *args: str) -> str:
94
+ result = await self._runner.run(
95
+ (GIT_COMMAND, *args), timeout=SANDBOX_PROBE_TIMEOUT_SECONDS, cwd=cwd, check=True
96
+ )
97
+ return result.stdout