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
shikiru/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shikiru: an engine that runs coding-agent tasks in tmux panes."""
shikiru/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python -m shikiru` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shikiru.cli.app import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1,3 @@
1
+ """Agent plugin contract and supporting packages."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,590 @@
1
+ """The headless agent plugin contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from difflib import get_close_matches
9
+ from enum import StrEnum, auto
10
+ from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Protocol, cast
11
+
12
+ from pydantic import ConfigDict
13
+
14
+ from shikiru.errors import ConfigError
15
+ from shikiru.process import ignore_started
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
19
+ from datetime import datetime, timedelta
20
+ from pathlib import Path
21
+
22
+ from shikiru.clock import Clock
23
+ from shikiru.process import SubprocessRunner
24
+
25
+ _RUNG_SEPARATOR = ":"
26
+ _VALID_RUNG_PART_COUNTS = (2, 3)
27
+
28
+ RUN_DIR_ENV_VAR = "SHIKIRU_RUN_DIR"
29
+ ROLE_ENV_VAR = "SHIKIRU_ROLE"
30
+ PROJECT_ENV_VAR = "SHIKIRU_PROJECT"
31
+ TASK_ENV_VAR = "SHIKIRU_TASK"
32
+ SLOT_ENV_VAR = "SHIKIRU_SLOT"
33
+ SHIKIRU_MODULE = "shikiru"
34
+ DOCTOR_TIMEOUT_SECONDS = 60.0
35
+ PERCENT = 100
36
+ MODEL_SUGGESTION_CUTOFF = 0.6
37
+ COST_BASIS_UNKNOWN = "unknown"
38
+ COST_BASIS_BILLED = "billed"
39
+ COST_BASIS_API_EQUIVALENT = "api_equivalent"
40
+ _SAFE_PATH_FLAG = "-P"
41
+ _MODULE_FLAG = "-m"
42
+
43
+
44
+ def module_argv(module: str, *arguments: str) -> tuple[str, ...]:
45
+ """Return the argv running `module` with this Shikiru installation's interpreter."""
46
+ return (sys.executable, _SAFE_PATH_FLAG, _MODULE_FLAG, module, *arguments)
47
+
48
+
49
+ def module_command(module: str, *arguments: str) -> str:
50
+ """Return a shell command running `module` with this installation's interpreter."""
51
+ return shlex.join(module_argv(module, *arguments))
52
+
53
+
54
+ def shikiru_argv(*arguments: str) -> tuple[str, ...]:
55
+ """Return the argv running this Shikiru installation, independent of PATH."""
56
+ return module_argv(SHIKIRU_MODULE, *arguments)
57
+
58
+
59
+ def shikiru_command(*arguments: str) -> str:
60
+ """Return a shell command running this Shikiru installation, independent of PATH."""
61
+ return module_command(SHIKIRU_MODULE, *arguments)
62
+
63
+
64
+ def role_dir(run_dir: Path, role: str) -> Path:
65
+ """Return `role`'s directory under `run_dir`, where its per-role files live."""
66
+ return run_dir / role
67
+
68
+
69
+ CostBasis = Literal["billed", "api_equivalent", "unknown"]
70
+ BillingMode = Literal["api", "subscription", "unknown"]
71
+ BillingSource = Literal["provider_reported", "operator_config", "unknown"]
72
+
73
+ BILLING_MODE_API: Final = "api"
74
+ BILLING_MODE_SUBSCRIPTION: Final = "subscription"
75
+ BILLING_MODE_UNKNOWN: Final = "unknown"
76
+ BILLING_SOURCE_PROVIDER_REPORTED: Final = "provider_reported"
77
+ BILLING_SOURCE_OPERATOR_CONFIG: Final = "operator_config"
78
+ BILLING_SOURCE_UNKNOWN: Final = "unknown"
79
+ BILLING_MODES: Final = (BILLING_MODE_API, BILLING_MODE_SUBSCRIPTION, BILLING_MODE_UNKNOWN)
80
+ BILLING_SOURCES: Final = (
81
+ BILLING_SOURCE_PROVIDER_REPORTED,
82
+ BILLING_SOURCE_OPERATOR_CONFIG,
83
+ BILLING_SOURCE_UNKNOWN,
84
+ )
85
+ _MISSING_BILLING_DECLARATION = object()
86
+
87
+
88
+ class FailureKind(StrEnum):
89
+ """Why a turn failed; `AUTH` includes provider access refusals needing operator action."""
90
+
91
+ QUOTA = auto()
92
+ UNAVAILABLE = auto()
93
+ AUTH = auto()
94
+ OTHER = auto()
95
+ UNKNOWN = auto()
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Rung:
100
+ """One `{agent, model, effort}` entry of a role's ladder."""
101
+
102
+ __pydantic_config__: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
103
+
104
+ agent: str
105
+ model: str
106
+ effort: str | None = None
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Usage:
111
+ """A session's cumulative token and priced cost usage, as reported by its agent."""
112
+
113
+ input_tokens: int
114
+ output_tokens: int
115
+ cache_read_tokens: int
116
+ cache_write_tokens: int
117
+ cost_usd: float | None
118
+ cost_basis: CostBasis
119
+ context_used_pct: float | None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class BillingDeclaration:
124
+ """A plugin's normalized billing mode and the source that established it."""
125
+
126
+ mode: BillingMode
127
+ source: BillingSource
128
+
129
+
130
+ UNKNOWN_BILLING_DECLARATION = BillingDeclaration(
131
+ mode=BILLING_MODE_UNKNOWN,
132
+ source=BILLING_SOURCE_UNKNOWN,
133
+ )
134
+
135
+
136
+ QUOTA_WINDOW_FIVE_HOUR = "5h"
137
+ QUOTA_WINDOW_SEVEN_DAY = "7d"
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class QuotaWindow:
142
+ """One quota window an agent plugin reports, with `used_pct` in percent (0-100)."""
143
+
144
+ name: str
145
+ used_pct: float
146
+ resets_at: datetime | None
147
+ observed_at: datetime
148
+
149
+
150
+ QUOTA_EXHAUSTED_PCT = 100.0
151
+
152
+
153
+ def is_exhausted(window: QuotaWindow) -> bool:
154
+ """Return whether `window` reports no remaining quota."""
155
+ return window.used_pct >= QUOTA_EXHAUSTED_PCT
156
+
157
+
158
+ def exhausted_reset(windows: Sequence[QuotaWindow], now: datetime) -> datetime | None:
159
+ """Return when every exhausted, not yet reset window is available again, or `None`.
160
+
161
+ Windows that are not exhausted or whose reset is unknown or past say nothing about when
162
+ quota returns, so a reading without such evidence leaves the reset unknown.
163
+ """
164
+ pending = [
165
+ window.resets_at
166
+ for window in windows
167
+ if is_exhausted(window) and window.resets_at is not None and window.resets_at > now
168
+ ]
169
+ return max(pending) if pending else None
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class QuotaReading:
174
+ """The latest usable quota sample for the account and bucket that gate an agent's dispatch.
175
+
176
+ A plugin returns only readings it can attribute to its own provider, account and quota
177
+ bucket; samples of other buckets, null windows or older evidence are never included.
178
+ """
179
+
180
+ windows: tuple[QuotaWindow, ...]
181
+ observed_at: datetime
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class CheckResult:
186
+ """One named check from `Agent.doctor()`, such as binary, version or login."""
187
+
188
+ name: str
189
+ ok: bool
190
+ detail: str
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class ModelChoice:
195
+ """One model and its authoritative explicit effort choices."""
196
+
197
+ model: str
198
+ efforts: tuple[str, ...] | None
199
+ offered: bool = True
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ModelCatalog:
204
+ """Authoritative agent model choices, or the reason discovery is unavailable."""
205
+
206
+ models: tuple[ModelChoice, ...] = ()
207
+ error: str | None = None
208
+ complete: bool = False
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class ModelChoiceAssessment:
213
+ """One configured choice; `supported` is `None` when catalog evidence is incomplete."""
214
+
215
+ supported: bool | None
216
+ detail: str
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class SessionStarted:
221
+ """A new or resumed agent session started."""
222
+
223
+ session_id: str
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class Message:
228
+ """Text emitted by the agent while it works."""
229
+
230
+ text: str
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class ToolStarted:
235
+ """A named agent tool started with a short description."""
236
+
237
+ name: str
238
+ detail: str
239
+
240
+
241
+ class ToolOutcome(StrEnum):
242
+ """A tool result the agent can reliably classify as successful or failed."""
243
+
244
+ SUCCESS = auto()
245
+ FAILURE = auto()
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class ToolFinished:
250
+ """A named agent tool finished."""
251
+
252
+ name: str
253
+ outcome: ToolOutcome | None = None
254
+ exit_code: int | None = None
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class UsageReported:
259
+ """The agent's cumulative usage for this session."""
260
+
261
+ usage: Usage
262
+
263
+
264
+ @dataclass(frozen=True)
265
+ class QuotaReported:
266
+ """The quota windows reported by the agent."""
267
+
268
+ windows: tuple[QuotaWindow, ...]
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class Compacted:
273
+ """The agent compacted its context, with token counts when reported."""
274
+
275
+ pre_tokens: int | None = None
276
+ post_tokens: int | None = None
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class TurnEnded:
281
+ """A turn finished; `last_message` is the agent's final message for it."""
282
+
283
+ last_message: str
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class TurnFailed:
288
+ """A turn failed; `resets_at` is set only for a known quota reset."""
289
+
290
+ kind: FailureKind
291
+ message: str
292
+ resets_at: datetime | None
293
+
294
+
295
+ type AgentSignal = (
296
+ SessionStarted
297
+ | Message
298
+ | ToolStarted
299
+ | ToolFinished
300
+ | UsageReported
301
+ | QuotaReported
302
+ | Compacted
303
+ | TurnEnded
304
+ | TurnFailed
305
+ )
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class TurnSpec:
310
+ """Everything an agent needs to run one headless turn.
311
+
312
+ `on_process` receives the id of every process the turn spawns, a context retry included,
313
+ before that process starts running (`SubprocessRunner.spawn(on_started=...)`).
314
+ """
315
+
316
+ rung: Rung
317
+ role: str
318
+ prompt: str
319
+ cwd: Path
320
+ run_dir: Path
321
+ turn_dir: Path
322
+ add_dirs: tuple[Path, ...]
323
+ env: Mapping[str, str]
324
+ session_id: str | None
325
+ usage: Usage | None
326
+ on_process: Callable[[int], None] = ignore_started
327
+
328
+
329
+ class Turn(Protocol):
330
+ """One headless agent turn, with one consumer for its normalized signal stream."""
331
+
332
+ @property
333
+ def pid(self) -> int:
334
+ """Return the operating system id for the process running now."""
335
+ ...
336
+
337
+ def signals(self) -> AsyncIterator[AgentSignal]:
338
+ """Yield this turn's normalized signals to its single reader."""
339
+ ...
340
+
341
+ async def stop(self) -> None:
342
+ """Terminate the process while leaving the session resumable."""
343
+ ...
344
+
345
+
346
+ class HeadlessAgent(Protocol):
347
+ """One-shot, schema-constrained calls without a session."""
348
+
349
+ async def ask(
350
+ self,
351
+ prompt: str,
352
+ schema: Mapping[str, Any],
353
+ model: str,
354
+ effort: str | None,
355
+ cwd: Path,
356
+ timeout: timedelta,
357
+ ) -> Mapping[str, Any]:
358
+ """Run one schema-constrained prompt and return its structured output."""
359
+ ...
360
+
361
+
362
+ class Agent(Protocol):
363
+ """A headless agent CLI plugin."""
364
+
365
+ name: str
366
+
367
+ async def start_turn(self, spec: TurnSpec) -> Turn:
368
+ """Start one new or resumed agent turn."""
369
+ ...
370
+
371
+ def interactive_command(self, session_id: str, run_dir: Path, rung: Rung) -> Sequence[str]:
372
+ """Return the command that resumes `session_id` on `rung` for an operator take-over.
373
+
374
+ `rung.agent` owns `session_id`; `run_dir` is the task's run directory, and the command
375
+ runs in the task root.
376
+ """
377
+ ...
378
+
379
+ async def doctor(self, models: Sequence[str]) -> list[CheckResult]:
380
+ """Run this agent's binary, version and login checks, plus checks for `models`.
381
+
382
+ `models` are the models the configured ladders select for this agent; empty when
383
+ the agent is only being detected.
384
+ """
385
+ ...
386
+
387
+ def headless(self) -> HeadlessAgent | None:
388
+ """Return this agent's one-shot, schema-constrained caller when available."""
389
+ ...
390
+
391
+
392
+ class BillingDeclarationProvider(Protocol):
393
+ """Optional agent capability that supplies an explicit billing declaration."""
394
+
395
+ @property
396
+ def billing_declaration(self) -> BillingDeclaration: ...
397
+
398
+
399
+ class QuotaProbe(Protocol):
400
+ """Optional agent capability that reads fresh quota evidence without running a turn."""
401
+
402
+ def read_quota(self, since: datetime) -> QuotaReading | None:
403
+ """Return the latest applicable reading observed after `since`, or `None` if none exists.
404
+
405
+ It may block: the engine calls it on a thread of its own, one at a time per agent, and
406
+ never interrupts it. An unreadable source raises `ShikiruError`.
407
+ """
408
+ ...
409
+
410
+
411
+ class ModelCatalogProvider(Protocol):
412
+ """Optional agent capability for authoritative model and per-model effort choices."""
413
+
414
+ async def model_catalog(self) -> ModelCatalog: ...
415
+
416
+
417
+ class _ModelCatalogMethod(Protocol):
418
+ async def __call__(self) -> object: ...
419
+
420
+
421
+ def billing_declaration_for(agent: Agent) -> BillingDeclaration:
422
+ """Return an agent's valid optional billing declaration, or unknown for old plugins."""
423
+ declaration = getattr(agent, "billing_declaration", _MISSING_BILLING_DECLARATION)
424
+ if declaration is _MISSING_BILLING_DECLARATION:
425
+ return UNKNOWN_BILLING_DECLARATION
426
+ if not isinstance(declaration, BillingDeclaration):
427
+ message = f"agent {agent.name!r} returned an invalid billing declaration"
428
+ raise ConfigError(message)
429
+ if declaration.mode not in BILLING_MODES or declaration.source not in BILLING_SOURCES:
430
+ message = f"agent {agent.name!r} returned an unsupported billing declaration"
431
+ raise ConfigError(message)
432
+ if declaration.mode != BILLING_MODE_UNKNOWN and declaration.source == BILLING_SOURCE_UNKNOWN:
433
+ message = f"agent {agent.name!r} declared a billing mode without its source"
434
+ raise ConfigError(message)
435
+ return declaration
436
+
437
+
438
+ def quota_probe_for(agent: Agent) -> QuotaProbe | None:
439
+ """Return an agent's optional quota probe capability, or `None` when it has none."""
440
+ probe: object = getattr(agent, "read_quota", None)
441
+ return cast("QuotaProbe", agent) if callable(probe) else None
442
+
443
+
444
+ async def model_catalog_for(agent: Agent) -> ModelCatalog | None:
445
+ """Return an optional agent catalog, rejecting malformed plugin capability results."""
446
+ provider: object = getattr(agent, "model_catalog", None)
447
+ if provider is None:
448
+ return None
449
+ if not callable(provider):
450
+ message = f"agent {agent.name!r} returned an invalid model catalog capability"
451
+ raise ConfigError(message)
452
+ catalog_value: object = await cast("_ModelCatalogMethod", provider)()
453
+ if not isinstance(catalog_value, ModelCatalog):
454
+ message = f"agent {agent.name!r} returned an invalid model catalog"
455
+ raise ConfigError(message)
456
+ catalog = catalog_value
457
+ if catalog.error is not None and (
458
+ not catalog.error.strip() or catalog.models or catalog.complete
459
+ ):
460
+ message = f"agent {agent.name!r} returned an inconsistent model catalog"
461
+ raise ConfigError(message)
462
+ model_names: set[str] = set()
463
+ for choice in catalog.models:
464
+ if not choice.model:
465
+ message = f"agent {agent.name!r} returned an invalid model choice"
466
+ raise ConfigError(message)
467
+ if choice.model in model_names:
468
+ message = f"agent {agent.name!r} returned duplicate model choices"
469
+ raise ConfigError(message)
470
+ model_names.add(choice.model)
471
+ if choice.efforts is not None and (
472
+ any(not effort for effort in choice.efforts)
473
+ or len(set(choice.efforts)) != len(choice.efforts)
474
+ ):
475
+ message = f"agent {agent.name!r} returned invalid effort choices"
476
+ raise ConfigError(message)
477
+ return catalog
478
+
479
+
480
+ def assess_model_choice(
481
+ model: str, effort: str | None, catalog: ModelCatalog
482
+ ) -> ModelChoiceAssessment:
483
+ """Classify a configured choice using only its agent's catalog evidence."""
484
+ if catalog.error is not None:
485
+ supported = None
486
+ detail = f"model catalog unavailable: {catalog.error}"
487
+ else:
488
+ model_choice = next((choice for choice in catalog.models if choice.model == model), None)
489
+ if model_choice is None:
490
+ if not catalog.complete:
491
+ supported = None
492
+ detail = f"model {model!r} could not be verified because the catalog is incomplete"
493
+ else:
494
+ names = tuple(choice.model for choice in catalog.models)
495
+ suggestion = _choice_suggestion(model, names)
496
+ supported = False
497
+ if suggestion is not None:
498
+ detail = f"unsupported model {model!r}; use {suggestion!r}"
499
+ else:
500
+ detail = (
501
+ f"unsupported model {model!r}; available model identifiers: "
502
+ f"{', '.join(names) or 'none'}"
503
+ )
504
+ elif effort is None:
505
+ supported = True
506
+ detail = f"model {model_choice.model!r} is listed; native effort default is selected"
507
+ elif model_choice.efforts is None:
508
+ supported = None
509
+ detail = (
510
+ f"effort {effort!r} for model {model_choice.model!r} could not be verified because "
511
+ "the catalog does not report per-model effort choices; leave effort unset"
512
+ )
513
+ elif not model_choice.efforts:
514
+ supported = False
515
+ detail = (
516
+ f"model {model_choice.model!r} has no explicit effort choices; leave effort unset"
517
+ )
518
+ else:
519
+ issue = _unsupported_effort_issue(effort, model_choice.model, model_choice.efforts)
520
+ supported = issue is None
521
+ detail = issue or f"model {model_choice.model!r} supports effort {effort!r}"
522
+ return ModelChoiceAssessment(supported, detail)
523
+
524
+
525
+ def _unsupported_effort_issue(
526
+ effort: str, model: str, supported_efforts: Sequence[str]
527
+ ) -> str | None:
528
+ if effort in supported_efforts:
529
+ return None
530
+ suggestion = _choice_suggestion(effort, supported_efforts)
531
+ detail = (
532
+ f"unsupported effort {effort!r} for model {model!r}; "
533
+ f"supported efforts: {', '.join(supported_efforts)}"
534
+ )
535
+ if suggestion is not None:
536
+ detail = f"{detail}; use {suggestion!r}"
537
+ return detail
538
+
539
+
540
+ def _choice_suggestion(value: str, choices: Sequence[str]) -> str | None:
541
+ folded = {choice.casefold(): choice for choice in choices}
542
+ exact_casefold = folded.get(value.casefold())
543
+ if exact_casefold is not None:
544
+ return exact_casefold
545
+ matches = get_close_matches(
546
+ value.casefold(),
547
+ [choice.casefold() for choice in choices],
548
+ n=1,
549
+ cutoff=MODEL_SUGGESTION_CUTOFF,
550
+ )
551
+ if not matches:
552
+ return None
553
+ return folded[matches[0]]
554
+
555
+
556
+ @dataclass(frozen=True)
557
+ class AgentContext:
558
+ """What an agent factory needs to validate options and build its plugin."""
559
+
560
+ options: Mapping[str, Any]
561
+ runner: SubprocessRunner
562
+ clock: Clock
563
+
564
+
565
+ type AgentFactory = Callable[[AgentContext], Agent]
566
+
567
+
568
+ def parse_rung(text: str) -> Rung:
569
+ """Parse `agent:model` or `agent:model:effort`, raising `ConfigError` if malformed."""
570
+ parts = text.split(_RUNG_SEPARATOR)
571
+ if len(parts) not in _VALID_RUNG_PART_COUNTS or not all(parts):
572
+ message = f"{text!r} is not a valid rung; expected agent:model or agent:model:effort"
573
+ if len(parts) > max(_VALID_RUNG_PART_COUNTS):
574
+ message = f"{message}; a model or effort containing ':' must use the table form"
575
+ raise ConfigError(message)
576
+ agent, model, *effort = parts
577
+ return Rung(agent=agent, model=model, effort=effort[0] if effort else None)
578
+
579
+
580
+ def short_rung_text(rung: Rung) -> str | None:
581
+ """Return `rung` as `agent:model[:effort]` when parsing that text gives back `rung`."""
582
+ parts = [rung.agent, rung.model]
583
+ if rung.effort is not None:
584
+ parts.append(rung.effort)
585
+ text = _RUNG_SEPARATOR.join(parts)
586
+ try:
587
+ parsed = parse_rung(text)
588
+ except ConfigError:
589
+ return None
590
+ return text if parsed == rung else None
@@ -0,0 +1,87 @@
1
+ """Resolves agent names to plugins, by entry point or direct registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import entry_points
6
+ from typing import TYPE_CHECKING, cast
7
+
8
+ from shikiru.errors import ConfigError
9
+
10
+ if TYPE_CHECKING:
11
+ from importlib.metadata import EntryPoint
12
+
13
+ from shikiru.agents.contracts import Agent, AgentContext, AgentFactory
14
+
15
+ AGENT_ENTRY_POINT_GROUP = "shikiru.agents"
16
+ PLUGIN_REINSTALL_HINT = (
17
+ "a typo in the name gives this error too; if the name is right, reinstall from the checkout: "
18
+ "uv tool install . --reinstall (editable: uv tool install --editable . --reinstall)"
19
+ )
20
+
21
+
22
+ class AgentPluginError(ConfigError):
23
+ """A named agent plugin is missing, failed to load, or rejected its options."""
24
+
25
+
26
+ def _distribution_name(entry_point: EntryPoint) -> str:
27
+ return entry_point.dist.name if entry_point.dist is not None else "unknown"
28
+
29
+
30
+ def load_unique_entry_point(
31
+ group: str, name: str, error_type: type[ConfigError], kind: str
32
+ ) -> object:
33
+ """Load the sole `name` entry point of `group`, raising `error_type` under `kind`'s label.
34
+
35
+ Raises when no entry point named `name` exists, when more than one distribution
36
+ registers it (naming every distribution instead of silently picking the first), or
37
+ when loading it raises.
38
+ """
39
+ matches = [entry_point for entry_point in entry_points(group=group) if entry_point.name == name]
40
+ if not matches:
41
+ message = f"no {kind} named {name!r} is registered or installed; {PLUGIN_REINSTALL_HINT}"
42
+ raise error_type(message)
43
+ if len(matches) > 1:
44
+ distributions = ", ".join(_distribution_name(match) for match in matches)
45
+ message = f"{kind} {name!r} is registered by more than one distribution: {distributions}"
46
+ raise error_type(message)
47
+ try:
48
+ return matches[0].load()
49
+ except Exception as error:
50
+ message = f"{kind} {name!r} failed to load: {error}"
51
+ raise error_type(message) from error
52
+
53
+
54
+ class AgentRegistry:
55
+ """Builds a headless `Agent` by name, from a registered factory or entry point."""
56
+
57
+ def __init__(self) -> None:
58
+ """Start with no directly registered factories; entry points are resolved lazily."""
59
+ self._factories: dict[str, AgentFactory] = {}
60
+
61
+ def register(self, name: str, factory: AgentFactory) -> None:
62
+ """Register `factory` under `name`, taking precedence over any entry point of that name."""
63
+ self._factories[name] = factory
64
+
65
+ def names(self) -> tuple[str, ...]:
66
+ """Return every installed or directly registered agent name in stable order."""
67
+ installed = (
68
+ entry_point.name for entry_point in entry_points(group=AGENT_ENTRY_POINT_GROUP)
69
+ )
70
+ return tuple(sorted(set(installed) | self._factories.keys()))
71
+
72
+ def create(self, name: str, context: AgentContext) -> Agent:
73
+ """Build the `name` agent from `context`, naming it in any `AgentPluginError` raised."""
74
+ factory = self._factories.get(name)
75
+ if factory is None:
76
+ factory = self._load_entry_point(name)
77
+ try:
78
+ return factory(context)
79
+ except Exception as error:
80
+ message = f"agent plugin {name!r} failed: {error}"
81
+ raise AgentPluginError(message) from error
82
+
83
+ def _load_entry_point(self, name: str) -> AgentFactory:
84
+ loaded = load_unique_entry_point(
85
+ AGENT_ENTRY_POINT_GROUP, name, AgentPluginError, "agent plugin"
86
+ )
87
+ return cast("AgentFactory", loaded)