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,106 @@
1
+ """Codex rollout token usage parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any, cast
7
+
8
+ from shikiru.agents.contracts import Usage
9
+ from shikiru.plugins.agents.codex.options import USAGE_EVENT_TYPE
10
+
11
+ _EVENT_MESSAGE_TYPE = "event_msg"
12
+ _INFO_FIELD = "info"
13
+ _TOTAL_USAGE_FIELD = "total_token_usage"
14
+ _LAST_USAGE_FIELD = "last_token_usage"
15
+ _INPUT_TOKENS_FIELD = "input_tokens"
16
+ _OUTPUT_TOKENS_FIELD = "output_tokens"
17
+ _CACHE_READ_TOKENS_FIELD = "cached_input_tokens"
18
+ _CACHE_WRITE_TOKENS_FIELD = "cache_write_input_tokens"
19
+ _CONTEXT_WINDOW_FIELD = "model_context_window"
20
+ _PERCENT = 100
21
+ _ZERO_TOKENS = 0
22
+ _UNKNOWN_COST = "unknown"
23
+
24
+
25
+ def rollout_usage(
26
+ entries: Iterable[Mapping[str, Any]],
27
+ ) -> Usage:
28
+ """Report the latest rollout token count; context from its window, no priced cost."""
29
+ latest = _latest_token_count(entries)
30
+ if latest is None:
31
+ return Usage(
32
+ _ZERO_TOKENS,
33
+ _ZERO_TOKENS,
34
+ _ZERO_TOKENS,
35
+ _ZERO_TOKENS,
36
+ None,
37
+ _UNKNOWN_COST,
38
+ None,
39
+ )
40
+ total, last, context_window = latest
41
+ input_tokens = _integer(total, _INPUT_TOKENS_FIELD)
42
+ output_tokens = _integer(total, _OUTPUT_TOKENS_FIELD)
43
+ cache_read_tokens = _integer(total, _CACHE_READ_TOKENS_FIELD)
44
+ cache_write_tokens = _integer(total, _CACHE_WRITE_TOKENS_FIELD)
45
+ context_used_pct = _context_used_pct(last, context_window)
46
+ return Usage(
47
+ input_tokens=input_tokens,
48
+ output_tokens=output_tokens,
49
+ cache_read_tokens=cache_read_tokens,
50
+ cache_write_tokens=cache_write_tokens,
51
+ cost_usd=None,
52
+ cost_basis="unknown",
53
+ context_used_pct=context_used_pct,
54
+ )
55
+
56
+
57
+ def latest_token_count(
58
+ entries: Iterable[Mapping[str, Any]],
59
+ ) -> tuple[Mapping[str, Any], Mapping[str, Any], int | None] | None:
60
+ """Return the latest total and per-turn token usage records."""
61
+ return _latest_token_count(entries)
62
+
63
+
64
+ def _latest_token_count(
65
+ entries: Iterable[Mapping[str, Any]],
66
+ ) -> tuple[Mapping[str, Any], Mapping[str, Any], int | None] | None:
67
+ latest: tuple[Mapping[str, Any], Mapping[str, Any], int | None] | None = None
68
+ for entry in entries:
69
+ payload = _payload(entry)
70
+ if payload.get("type") != USAGE_EVENT_TYPE:
71
+ continue
72
+ info = payload.get(_INFO_FIELD)
73
+ if not isinstance(info, Mapping):
74
+ continue
75
+ typed_info = cast("Mapping[str, Any]", info)
76
+ total = typed_info.get(_TOTAL_USAGE_FIELD)
77
+ last = typed_info.get(_LAST_USAGE_FIELD)
78
+ if isinstance(total, Mapping) and isinstance(last, Mapping):
79
+ context_window = typed_info.get(_CONTEXT_WINDOW_FIELD)
80
+ latest = (
81
+ cast("Mapping[str, Any]", total),
82
+ cast("Mapping[str, Any]", last),
83
+ context_window if isinstance(context_window, int) else None,
84
+ )
85
+ return latest
86
+
87
+
88
+ def _payload(entry: Mapping[str, Any]) -> Mapping[str, Any]:
89
+ if entry.get("type") != _EVENT_MESSAGE_TYPE:
90
+ return {}
91
+ payload = entry.get("payload")
92
+ return cast("Mapping[str, Any]", payload) if isinstance(payload, Mapping) else {}
93
+
94
+
95
+ def _integer(entry: Mapping[str, Any], key: str) -> int:
96
+ value = entry.get(key, 0)
97
+ if not isinstance(value, int | float):
98
+ return 0
99
+ return int(value)
100
+
101
+
102
+ def _context_used_pct(last: Mapping[str, Any], context_window: int | None) -> float | None:
103
+ if context_window is None or context_window <= 0 or _INPUT_TOKENS_FIELD not in last:
104
+ return None
105
+ tokens = _integer(last, _INPUT_TOKENS_FIELD)
106
+ return tokens / context_window * _PERCENT
@@ -0,0 +1,62 @@
1
+ """Option definitions and validation shared by the built-in agent plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.agents.contracts import BILLING_MODE_API, BILLING_MODE_SUBSCRIPTION, PERCENT
8
+ from shikiru.agents.settings import AgentOptionsError, AgentSetting, SettingKind
9
+
10
+ if TYPE_CHECKING:
11
+ from pydantic import ValidationError
12
+
13
+ COMPACT_AT_PCT_KEY = "compact_at_pct"
14
+ BILLING_MODE_KEY = "billing_mode"
15
+ ARGS_KEY = "args"
16
+
17
+
18
+ def compact_threshold_setting(default: float) -> AgentSetting:
19
+ """Return the context-compaction threshold setting with the plugin's `default`."""
20
+ return AgentSetting(
21
+ key=COMPACT_AT_PCT_KEY,
22
+ label="Compact at",
23
+ help="Context use at which the agent is asked to compact its session; 0 compacts at once.",
24
+ kind=SettingKind.NUMBER,
25
+ default=default,
26
+ unit="percent",
27
+ minimum=0,
28
+ maximum=PERCENT,
29
+ )
30
+
31
+
32
+ def billing_declaration_setting() -> AgentSetting:
33
+ """Return the billing declaration setting; unset declares nothing."""
34
+ return AgentSetting(
35
+ key=BILLING_MODE_KEY,
36
+ label="Billing mode",
37
+ help=(
38
+ "How this agent is billed, used to label cost as billed or API-equivalent; "
39
+ "leaving it blank declares nothing."
40
+ ),
41
+ kind=SettingKind.CHOICE,
42
+ default=None,
43
+ choices=(BILLING_MODE_API, BILLING_MODE_SUBSCRIPTION),
44
+ )
45
+
46
+
47
+ def cli_args_setting(default: tuple[str, ...]) -> AgentSetting:
48
+ """Return the extra CLI arguments setting with the plugin's `default`."""
49
+ return AgentSetting(
50
+ key=ARGS_KEY,
51
+ label="CLI arguments",
52
+ help="Arguments added to every agent CLI call, split like a shell command line.",
53
+ kind=SettingKind.ARGS,
54
+ default=default,
55
+ )
56
+
57
+
58
+ def options_error(agent: str, error: ValidationError) -> AgentOptionsError:
59
+ """Return the options rejection for `agent`, naming the first option pydantic refused."""
60
+ first = error.errors()[0]
61
+ key = str(first["loc"][0]) if first["loc"] else None
62
+ return AgentOptionsError(f"invalid options for the {agent} agent: {error}", key)
@@ -0,0 +1,3 @@
1
+ """Dashboard widget plugins, registered under `shikiru.widgets`."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,144 @@
1
+ """The `dialogue` widget: recent speech bubbles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar, cast
6
+
7
+ from textual.containers import Horizontal, VerticalScroll
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, DataTable, Label, Rule, Static
10
+
11
+ from shikiru.dashboard.contract import WidgetPlacement
12
+ from shikiru.ui import SpeechBubble
13
+ from shikiru.ui.popup import (
14
+ POPUP_BODY_CLASS,
15
+ POPUP_CLOSE_CLASS,
16
+ POPUP_CSS,
17
+ POPUP_FRAME_CLASS,
18
+ POPUP_HEADER_CLASS,
19
+ POPUP_RULE_CLASS,
20
+ POPUP_TITLE_CLASS,
21
+ PopupFrame,
22
+ popup_close_label,
23
+ prepare_popup,
24
+ )
25
+ from shikiru.ui.screen import widget_is_attached
26
+
27
+ if TYPE_CHECKING:
28
+ from textual.app import App, ComposeResult
29
+ from textual.widget import Widget
30
+
31
+ from shikiru.dashboard.contract import DialogueBubbleView, WidgetContext
32
+
33
+ _DIALOGUE_TEXT_CLOSE_BUTTON = "dialogue-text-close"
34
+
35
+
36
+ class DialogueTextScreen(ModalScreen[None]):
37
+ """Show the full text of one dialogue bubble."""
38
+
39
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("escape", "close", "Close")]
40
+ DEFAULT_CSS = (
41
+ POPUP_CSS
42
+ + """
43
+ #dialogue-text-scroll { max-height: 1fr; min-height: 0; }
44
+ #dialogue-full-text {
45
+ background: $surface;
46
+ color: $foreground;
47
+ }
48
+ """
49
+ )
50
+
51
+ def __init__(self, text: str) -> None:
52
+ """Hold the full event text to display."""
53
+ super().__init__()
54
+ prepare_popup(self)
55
+ self._text = text
56
+
57
+ def compose(self) -> ComposeResult:
58
+ """Render the complete bubble text in a scrollable modal."""
59
+ with PopupFrame(widget_id="dialogue-text-frame", classes=POPUP_FRAME_CLASS):
60
+ with Horizontal(id="dialogue-text-header", classes=POPUP_HEADER_CLASS):
61
+ yield Label("Dialogue", id="dialogue-text-title", classes=POPUP_TITLE_CLASS)
62
+ yield Button(
63
+ popup_close_label(),
64
+ id=_DIALOGUE_TEXT_CLOSE_BUTTON,
65
+ classes=POPUP_CLOSE_CLASS,
66
+ )
67
+ yield Rule(classes=POPUP_RULE_CLASS)
68
+ with VerticalScroll(id="dialogue-text-scroll", classes=POPUP_BODY_CLASS):
69
+ yield Static(self._text, markup=False, id="dialogue-full-text")
70
+
71
+ def on_mount(self) -> None:
72
+ self.set_focus(self.query_one("#dialogue-text-scroll", VerticalScroll))
73
+ self.call_after_refresh(self.query_one("#dialogue-text-frame", PopupFrame).content_ready)
74
+
75
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
76
+ if event.button.id == _DIALOGUE_TEXT_CLOSE_BUTTON:
77
+ self.dismiss()
78
+
79
+ async def action_close(self) -> None:
80
+ """Close the full-text modal."""
81
+ self.dismiss()
82
+
83
+
84
+ class DialoguePanel(Static):
85
+ """List the latest twenty bubbles and open a selected bubble's full text on Enter."""
86
+
87
+ DEFAULT_CSS = """
88
+ DialoguePanel { height: auto; }
89
+ #dialogue-table { height: auto; }
90
+ """
91
+
92
+ def __init__(self, context: WidgetContext) -> None:
93
+ """Hold `context`; the event list is read on mount and on state changes."""
94
+ super().__init__(id="dialogue")
95
+ self._widget_context = context
96
+ self._bubbles: list[DialogueBubbleView] = []
97
+
98
+ def compose(self) -> ComposeResult:
99
+ """Reserve a table with one row per recent speech bubble."""
100
+ yield DataTable(show_header=False, cursor_type="row", id="dialogue-table")
101
+
102
+ async def on_mount(self) -> None:
103
+ """Render recent events and refresh after each state change."""
104
+ await self._refresh()
105
+ self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
106
+
107
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
108
+ """Show the complete text of the selected event."""
109
+ if self._bubbles:
110
+ bubble = self._bubbles[event.cursor_row]
111
+ cast("App[None]", self.app).push_screen(DialogueTextScreen(bubble.text))
112
+
113
+ async def _refresh(self) -> None:
114
+ self._bubbles = self._widget_context.views.dialogue(self._widget_context.focus_task)
115
+ table = cast("DataTable[object]", self.query_one("#dialogue-table", DataTable))
116
+ table.clear(columns=True)
117
+ table.add_column("dialogue")
118
+ if not self._bubbles:
119
+ table.add_row("no dialogue yet")
120
+ return
121
+ for bubble in self._bubbles:
122
+ table.add_row(
123
+ SpeechBubble(
124
+ role=bubble.role,
125
+ text=bubble.text,
126
+ task_id=bubble.task_id,
127
+ age=bubble.age,
128
+ ).render()
129
+ )
130
+
131
+ async def _refresh_if_attached(self) -> None:
132
+ if widget_is_attached(self):
133
+ await self._refresh()
134
+
135
+
136
+ class DialogueWidget:
137
+ """The `dialogue` dashboard widget plugin."""
138
+
139
+ name = "dialogue"
140
+ placement = WidgetPlacement.ACTIVITY
141
+
142
+ def create(self, context: WidgetContext) -> Widget:
143
+ """Build the dialogue panel for `context`."""
144
+ return DialoguePanel(context)
@@ -0,0 +1,123 @@
1
+ """The `disk` widget: free space of the project filesystem and the `.shikiru` size."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.cells import cell_len
9
+ from rich.text import Text
10
+
11
+ from shikiru.dashboard.contract import DiagnosticSampleState, WidgetPlacement
12
+ from shikiru.ui.compact import free_space_text
13
+ from shikiru.ui.gated_static import GatedStatic
14
+ from shikiru.ui.live_interval import LiveInterval
15
+ from shikiru.ui.screen import is_first_child, widget_is_attached
16
+ from shikiru.ui.section import (
17
+ FAILED_WORD,
18
+ GROUP_SEPARATOR,
19
+ UNAVAILABLE_WORD,
20
+ UNKNOWN_AGE,
21
+ Row,
22
+ Section,
23
+ grey,
24
+ labelled_rows,
25
+ number,
26
+ observation,
27
+ state_line,
28
+ )
29
+ from shikiru.ui.size import format_size
30
+ from shikiru.ui.tokens import ALERT_COLOR, AMBER, SECONDARY_TEXT_COLOR
31
+
32
+ if TYPE_CHECKING:
33
+ from rich.console import Console, ConsoleOptions, RenderResult
34
+ from textual.widget import Widget
35
+
36
+ from shikiru.dashboard.contract import DiskView, WidgetContext
37
+
38
+ SECTION_TITLE = "Disk"
39
+ LABEL_FREE = "Free"
40
+ LABEL_SHIKIRU = ".shikiru"
41
+ MEASURING_WORD = "measuring…"
42
+ LABEL_REFRESH = "Refresh"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _DiskBody:
47
+ view: DiskView
48
+ refresh_error: str | None
49
+
50
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
51
+ del console
52
+ view = self.view
53
+ rows: list[Row] = []
54
+ if view.free_bytes is not None and view.total_bytes is not None:
55
+ free = free_space_text(view.free_bytes, view.total_bytes, view.free_level)
56
+ rows.append((grey(LABEL_FREE), (free,)))
57
+ if view.shikiru_bytes is not None:
58
+ rows.append((grey(LABEL_SHIKIRU), (number(format_size(view.shikiru_bytes)),)))
59
+ elif self.refresh_error is None:
60
+ rows.append((grey(LABEL_SHIKIRU), (grey(MEASURING_WORD),)))
61
+ if self.refresh_error is not None:
62
+ failure = Text(f"{FAILED_WORD}{GROUP_SEPARATOR}{self.refresh_error}", style=AMBER)
63
+ rows.append((grey(LABEL_REFRESH), (failure,)))
64
+ label_width = max((cell_len(label.plain) for label, _ in rows), default=0)
65
+ yield labelled_rows(rows, options.max_width, label_width)
66
+
67
+
68
+ def disk_section(view: DiskView) -> Section:
69
+ """Render `view` as the Disk section in every state."""
70
+ ago = view.sampled_ago or UNKNOWN_AGE
71
+ if view.state is DiagnosticSampleState.UNAVAILABLE:
72
+ return Section(
73
+ SECTION_TITLE, state_line(UNAVAILABLE_WORD, SECONDARY_TEXT_COLOR, view.error)
74
+ )
75
+ if view.state is DiagnosticSampleState.FAILED:
76
+ return Section(SECTION_TITLE, state_line(FAILED_WORD, ALERT_COLOR, view.error))
77
+ stale = view.state is DiagnosticSampleState.STALE
78
+ return Section(
79
+ SECTION_TITLE, _DiskBody(view, view.error if stale else None), observation(ago, stale=stale)
80
+ )
81
+
82
+
83
+ class DiskPanel(GatedStatic):
84
+ """Show the Disk section and re-render only when its content changed."""
85
+
86
+ def __init__(self, context: WidgetContext) -> None:
87
+ """Hold `context`; disk sampling starts on mount."""
88
+ super().__init__(id="disk")
89
+ self._widget_context = context
90
+ self._interval = LiveInterval(
91
+ self,
92
+ lambda: context.settings.current().dashboard.disk_refresh_seconds,
93
+ self._refresh,
94
+ )
95
+
96
+ async def on_mount(self) -> None:
97
+ """Render and refresh disk state at the configured cadence."""
98
+ await self._refresh()
99
+ self._interval.apply()
100
+ self.run_worker(self._widget_context.on_change(self._interval.reapply), exclusive=True)
101
+
102
+ async def _refresh(self) -> None:
103
+ if not widget_is_attached(self):
104
+ return
105
+ view = await self._widget_context.views.disk()
106
+ if not widget_is_attached(self):
107
+ return
108
+ if view.shikiru_bytes is None and view.state is DiagnosticSampleState.AVAILABLE:
109
+ resources = self._widget_context.settings.current().orchestration.resources
110
+ self.set_timer(resources.sample_seconds, self._refresh)
111
+ ruled = not is_first_child(self)
112
+ self.update_if_changed(disk_section(view).with_ruled(ruled=ruled), key=(view, ruled))
113
+
114
+
115
+ class DiskWidget:
116
+ """The `disk` dashboard widget plugin."""
117
+
118
+ name = "disk"
119
+ placement = WidgetPlacement.DIAGNOSTICS
120
+
121
+ def create(self, context: WidgetContext) -> Widget:
122
+ """Build the Disk panel for `context`."""
123
+ return DiskPanel(context)
@@ -0,0 +1,225 @@
1
+ """The `docker` widget: container counts, problems and the top five by CPU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.cells import cell_len
9
+ from rich.console import Group, RenderableType
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from shikiru.dashboard.contract import DiagnosticSampleState, WidgetPlacement
14
+ from shikiru.ui.compact import format_cpu_pct, format_memory
15
+ from shikiru.ui.gated_static import GatedStatic
16
+ from shikiru.ui.live_interval import LiveInterval
17
+ from shikiru.ui.screen import is_first_child, widget_is_attached
18
+ from shikiru.ui.section import (
19
+ BOLD_PRIMARY,
20
+ FAILED_WORD,
21
+ GROUP_SEPARATOR,
22
+ UNAVAILABLE_WORD,
23
+ UNKNOWN_AGE,
24
+ Row,
25
+ Section,
26
+ grey,
27
+ labelled_rows,
28
+ number,
29
+ observation,
30
+ state_line,
31
+ )
32
+ from shikiru.ui.tokens import (
33
+ ALERT_COLOR,
34
+ AMBER,
35
+ SECONDARY_TEXT_COLOR,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from rich.console import Console, ConsoleOptions, RenderResult
40
+ from textual.widget import Widget
41
+
42
+ from shikiru.dashboard.contract import (
43
+ ContainerProblemView,
44
+ ContainerView,
45
+ DockerSummaryView,
46
+ DockerView,
47
+ WidgetContext,
48
+ )
49
+
50
+ SECTION_TITLE = "Docker"
51
+ LABEL_RUNNING = "Running"
52
+ LABEL_PROBLEMS = "Problems"
53
+ LABEL_ELSEWHERE = "Elsewhere"
54
+ LABEL_REFRESH = "Refresh"
55
+ TOP_HEADING = "Top 5 by CPU"
56
+ TOP_INDENT = 2
57
+ TOP_COLUMN_GAP = 1
58
+ TOP_GAP_COUNT = 2
59
+ RIGHT = "right"
60
+ ELLIPSIS = "ellipsis"
61
+ CPU_WORD = "CPU"
62
+ RAM_WORD = "RAM"
63
+ OF_WORD = "of"
64
+ RUNNING_WORD = "running"
65
+ USAGE_UNAVAILABLE = "usage unavailable"
66
+ NO_CONTAINERS = "No containers"
67
+ PROBLEM_UNHEALTHY = "unhealthy"
68
+ PROBLEM_RESTARTING = "restarting"
69
+ _WARNING_REASONS = frozenset({PROBLEM_UNHEALTHY, PROBLEM_RESTARTING})
70
+
71
+
72
+ def _measure(label: str, value: str) -> Text:
73
+ return Text.assemble(grey(f"{label} "), number(value))
74
+
75
+
76
+ def _usage_groups(cpu_pct: float | None, memory_bytes: int | None) -> list[Text]:
77
+ groups: list[Text] = []
78
+ if cpu_pct is not None:
79
+ groups.append(_measure(CPU_WORD, format_cpu_pct(cpu_pct)))
80
+ if memory_bytes is not None:
81
+ groups.append(_measure(RAM_WORD, format_memory(memory_bytes)))
82
+ return groups
83
+
84
+
85
+ def _running_groups(summary: DockerSummaryView) -> list[Text]:
86
+ counts = Text.assemble(
87
+ number(str(summary.running)), grey(f" {OF_WORD} "), number(str(summary.total))
88
+ )
89
+ if not summary.usage_available:
90
+ return [counts, Text(USAGE_UNAVAILABLE, style=AMBER)]
91
+ return [counts, *_usage_groups(summary.cpu_pct, summary.memory_bytes)]
92
+
93
+
94
+ def _problem_group(problem: ContainerProblemView) -> Text:
95
+ colour = AMBER if problem.reason in _WARNING_REASONS else ALERT_COLOR
96
+ reason = (
97
+ problem.reason if problem.exit_code is None else f"{problem.reason} ({problem.exit_code})"
98
+ )
99
+ return Text.assemble((problem.name, BOLD_PRIMARY), " ", (reason, colour))
100
+
101
+
102
+ def _elsewhere_groups(summary: DockerSummaryView) -> list[Text]:
103
+ elsewhere = summary.elsewhere
104
+ count = Text.assemble(number(str(elsewhere.running)), grey(f" {RUNNING_WORD}"))
105
+ return [count, *_usage_groups(elsewhere.cpu_pct, elsewhere.memory_bytes)]
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class _TopContainers:
110
+ containers: tuple[ContainerView, ...]
111
+
112
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
113
+ del console
114
+ cpus = [format_cpu_pct(container.cpu_pct) for container in self.containers]
115
+ memories = [format_memory(container.memory_bytes) for container in self.containers]
116
+ cpu_width = max(map(len, cpus))
117
+ memory_width = max(map(len, memories))
118
+ name_width = max(
119
+ 1,
120
+ options.max_width
121
+ - TOP_INDENT
122
+ - cpu_width
123
+ - memory_width
124
+ - TOP_GAP_COUNT * TOP_COLUMN_GAP,
125
+ )
126
+ grid = Table.grid(padding=(0, TOP_COLUMN_GAP, 0, 0))
127
+ grid.add_column(width=TOP_INDENT - TOP_COLUMN_GAP)
128
+ grid.add_column(width=min(name_width, max(len(c.name) for c in self.containers)))
129
+ grid.add_column(justify=RIGHT, width=cpu_width)
130
+ grid.add_column(justify=RIGHT, width=memory_width)
131
+ for container, cpu, memory in zip(self.containers, cpus, memories, strict=True):
132
+ name = Text(container.name, style=BOLD_PRIMARY, no_wrap=True)
133
+ name.truncate(name_width, overflow=ELLIPSIS)
134
+ grid.add_row("", name, number(cpu), number(memory))
135
+ yield grid
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class _SummaryBody:
140
+ summary: DockerSummaryView
141
+ refresh_error: str | None
142
+
143
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
144
+ del console
145
+ summary = self.summary
146
+ head: list[Row] = [(grey(LABEL_RUNNING), tuple(_running_groups(summary)))]
147
+ if summary.problems:
148
+ problems = tuple(_problem_group(problem) for problem in summary.problems)
149
+ head.append((grey(LABEL_PROBLEMS), problems))
150
+ if self.refresh_error is not None:
151
+ failure = Text(f"{FAILED_WORD}{GROUP_SEPARATOR}{self.refresh_error}", style=AMBER)
152
+ head.append((grey(LABEL_REFRESH), (failure,)))
153
+ tail: list[Row] = []
154
+ if summary.elsewhere.running:
155
+ tail.append((grey(LABEL_ELSEWHERE), tuple(_elsewhere_groups(summary))))
156
+ label_width = max(cell_len(label.plain) for label, _ in [*head, *tail])
157
+ yield labelled_rows(head, options.max_width, label_width)
158
+ if summary.usage_available and summary.top:
159
+ yield grey(TOP_HEADING)
160
+ yield _TopContainers(summary.top)
161
+ if tail:
162
+ yield labelled_rows(tail, options.max_width, label_width)
163
+
164
+
165
+ def docker_section(snapshot: DockerView) -> Section:
166
+ """Render `snapshot` as the Docker section in every state."""
167
+ state = snapshot.state or (
168
+ DiagnosticSampleState.AVAILABLE if snapshot.available else DiagnosticSampleState.UNAVAILABLE
169
+ )
170
+ ago = snapshot.sampled_ago or UNKNOWN_AGE
171
+ if state is DiagnosticSampleState.UNAVAILABLE:
172
+ return Section(
173
+ SECTION_TITLE, state_line(UNAVAILABLE_WORD, SECONDARY_TEXT_COLOR, snapshot.error)
174
+ )
175
+ if state is DiagnosticSampleState.FAILED:
176
+ return Section(SECTION_TITLE, state_line(FAILED_WORD, ALERT_COLOR, snapshot.error))
177
+ if state is DiagnosticSampleState.EMPTY:
178
+ return Section(SECTION_TITLE, grey(NO_CONTAINERS), observation(ago, stale=False))
179
+ stale = state is DiagnosticSampleState.STALE
180
+ body: RenderableType = Group()
181
+ if snapshot.summary is not None:
182
+ body = _SummaryBody(snapshot.summary, snapshot.error if stale else None)
183
+ return Section(SECTION_TITLE, body, observation(ago, stale=stale))
184
+
185
+
186
+ class DockerPanel(GatedStatic):
187
+ """Show the Docker section and re-render only when its content changed."""
188
+
189
+ def __init__(self, context: WidgetContext) -> None:
190
+ """Hold `context`; Docker sampling starts on mount."""
191
+ super().__init__(id="docker")
192
+ self._widget_context = context
193
+ self._interval = LiveInterval(
194
+ self,
195
+ lambda: context.settings.current().dashboard.docker_refresh_seconds,
196
+ self._refresh,
197
+ )
198
+
199
+ async def on_mount(self) -> None:
200
+ """Render and refresh Docker state at the configured cadence."""
201
+ await self._refresh()
202
+ self._interval.apply()
203
+ self.run_worker(self._widget_context.on_change(self._interval.reapply), exclusive=True)
204
+
205
+ async def _refresh(self) -> None:
206
+ if not widget_is_attached(self):
207
+ return
208
+ snapshot = await self._widget_context.views.containers()
209
+ if not widget_is_attached(self):
210
+ return
211
+ ruled = not is_first_child(self)
212
+ self.update_if_changed(
213
+ docker_section(snapshot).with_ruled(ruled=ruled), key=(snapshot, ruled)
214
+ )
215
+
216
+
217
+ class DockerWidget:
218
+ """The `docker` dashboard widget plugin."""
219
+
220
+ name = "docker"
221
+ placement = WidgetPlacement.DIAGNOSTICS
222
+
223
+ def create(self, context: WidgetContext) -> Widget:
224
+ """Build the Docker panel for `context`."""
225
+ return DockerPanel(context)