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/ui/compact.py ADDED
@@ -0,0 +1,1209 @@
1
+ """Compact shared readouts for usage, resources and task activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Protocol
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.agents.contracts import (
14
+ BILLING_MODE_API,
15
+ BILLING_MODE_SUBSCRIPTION,
16
+ COST_BASIS_API_EQUIVALENT,
17
+ COST_BASIS_BILLED,
18
+ COST_BASIS_UNKNOWN,
19
+ )
20
+ from shikiru.domain.escalation import escalation_reason_label
21
+ from shikiru.domain.levels import UsageLevel
22
+ from shikiru.domain.tasks import EscalationReason, Role
23
+ from shikiru.ui.gauge import Gauge
24
+ from shikiru.ui.glyphs import GLYPH_CURRENT_STAGE, GLYPH_ESCALATED, GLYPH_STOPPED
25
+ from shikiru.ui.keys import append_key_hint
26
+ from shikiru.ui.section import Row, grey, labelled_rows, observed_text, wrap_groups
27
+ from shikiru.ui.size import format_size
28
+ from shikiru.ui.tokens import (
29
+ ALERT_COLOR,
30
+ AMBER,
31
+ PRIMARY_TEXT_COLOR,
32
+ SAGE,
33
+ SECONDARY_TEXT_COLOR,
34
+ STEEL_BLUE,
35
+ STYLE_BOLD,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Sequence
40
+ from datetime import datetime, timedelta
41
+
42
+ from rich.console import Console, ConsoleOptions, RenderResult
43
+
44
+
45
+ _BOLD_PRIMARY = f"{STYLE_BOLD} {PRIMARY_TEXT_COLOR}"
46
+ _BOLD_STEEL = f"{STYLE_BOLD} {STEEL_BLUE}"
47
+ _BOLD_SAGE = f"{STYLE_BOLD} {SAGE}"
48
+ _BOLD_AMBER = f"{STYLE_BOLD} {AMBER}"
49
+ _BOLD_ALERT = f"{STYLE_BOLD} {ALERT_COLOR}"
50
+ _FREE_SPACE_STYLES = {UsageLevel.WARN: _BOLD_AMBER, UsageLevel.ALERT: _BOLD_ALERT}
51
+ GIB_FORMAT = ".1f"
52
+ CPU_DECIMALS_BELOW_PCT = 10
53
+ CPU_DECIMAL_FORMAT = ".1f"
54
+ CPU_INTEGER_FORMAT = ".0f"
55
+ BYTES_PER_GIB = 1024**3
56
+ BYTES_PER_MIB = 1024**2
57
+ MIB_FORMAT = ".0f"
58
+ SESSION_COST_FORMAT = ".2f"
59
+ COUNT_SHORT_SCALE = 1_000
60
+ COUNT_SHORT_MILLION = COUNT_SHORT_SCALE * COUNT_SHORT_SCALE
61
+ COUNT_SHORT_PRECISION = 1
62
+ SECONDS_PER_MINUTE = 60
63
+ MINUTES_PER_HOUR = 60
64
+ SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR
65
+ HOURS_PER_DAY = 24
66
+ FOCUS_ATTENTION_MAX_HEIGHT = 2
67
+ ACTION_HINT = "Ctrl+P actions"
68
+ USAGE_TOTALS_LABEL = "Totals"
69
+ USAGE_TOKENS_LABEL = "Tokens"
70
+ USAGE_COVERAGE_LABEL = "Coverage"
71
+ USAGE_BILLING_LABEL = "Billing"
72
+ USAGE_LABEL_WIDTH = len(USAGE_COVERAGE_LABEL)
73
+ USAGE_BY_AGENT_LABEL = "By agent"
74
+ USAGE_LIMITS_LABEL = "Limits"
75
+ USAGE_API_TOTAL_WORD = "API"
76
+ USAGE_BILLED_WORD = "billed"
77
+ USAGE_API_BILLED_WORD = "API billed"
78
+ USAGE_API_UNREPORTED_WORD = "API unreported"
79
+ USAGE_API_EQUIVALENT_WORD = "API-equivalent"
80
+ USAGE_ESTIMATE_PREFIX = "est. "
81
+ USAGE_SUBSCRIPTION_WORD = "subscription"
82
+ USAGE_UNKNOWN_WORD = "unknown"
83
+ USAGE_REPORTED_WORD = "reported"
84
+ USAGE_LEGACY_WORD = "legacy"
85
+ USAGE_WITHOUT_TOKENS_WORD = "without tokens"
86
+ USAGE_TOKENS_WORD = "tokens"
87
+ USAGE_TOKENS_IN_LABEL = "in"
88
+ USAGE_TOKENS_OUT_LABEL = "out"
89
+ USAGE_CACHE_READ_LABEL = "cache r"
90
+ USAGE_CACHE_WRITE_LABEL = "w"
91
+ USAGE_TOKENS_UNKNOWN = "tokens unknown"
92
+ USAGE_COST_UNKNOWN = "cost unknown"
93
+ USAGE_RESETS_WORD = "resets"
94
+ RESOURCE_FAILED = "failed"
95
+ RESOURCE_UNAVAILABLE = "unavailable"
96
+ RESOURCE_STALE = "stale"
97
+ RESOURCE_EMPTY = "empty"
98
+ RESOURCE_AVAILABLE = "available"
99
+ RESOURCE_STATE_UNKNOWN = "sample state unknown"
100
+ STATUS_ALIGNMENT_MIN_GAP = 1
101
+ ROLE_LABELS = {
102
+ Role.LEADER.value: "Leader",
103
+ Role.IMPLEMENTER.value: "Implementer",
104
+ Role.HELPER.value: "Helper",
105
+ }
106
+ MILESTONE_DONE = "done"
107
+ MILESTONE_CURRENT = "current"
108
+ MILESTONE_LATER = "later"
109
+ MILESTONE_SKIPPED = "skipped"
110
+ MILESTONE_UNKNOWN = "unknown"
111
+ MILESTONE_REWORK = "rework"
112
+ MILESTONE_STOPPED = "stopped"
113
+ MILESTONE_GAP = " "
114
+ MILESTONE_GLYPH_GAP = " "
115
+
116
+
117
+ class _RecordedUsage(Protocol):
118
+ @property
119
+ def session_count(self) -> int: ...
120
+
121
+ @property
122
+ def input_tokens(self) -> int: ...
123
+
124
+ @property
125
+ def output_tokens(self) -> int: ...
126
+
127
+ @property
128
+ def cache_read_tokens(self) -> int: ...
129
+
130
+ @property
131
+ def cache_write_tokens(self) -> int: ...
132
+
133
+ @property
134
+ def reported_sessions(self) -> int: ...
135
+
136
+ @property
137
+ def legacy_token_sessions(self) -> int: ...
138
+
139
+ @property
140
+ def unknown_token_sessions(self) -> int: ...
141
+
142
+ @property
143
+ def billed_cost_usd(self) -> float | None: ...
144
+
145
+ @property
146
+ def billed_cost_sessions(self) -> int: ...
147
+
148
+ @property
149
+ def api_billed_cost_usd(self) -> float | None: ...
150
+
151
+ @property
152
+ def api_billed_cost_sessions(self) -> int: ...
153
+
154
+ @property
155
+ def api_cost_unreported_sessions(self) -> int: ...
156
+
157
+ @property
158
+ def api_equivalent_cost_usd(self) -> float | None: ...
159
+
160
+ @property
161
+ def api_equivalent_cost_sessions(self) -> int: ...
162
+
163
+ @property
164
+ def unknown_billing_sessions(self) -> int: ...
165
+
166
+ @property
167
+ def subscription_sessions(self) -> int: ...
168
+
169
+ @property
170
+ def subscription_agents(self) -> tuple[str, ...]: ...
171
+
172
+
173
+ class _AgentUsageSummary(Protocol):
174
+ @property
175
+ def agent(self) -> str: ...
176
+
177
+ @property
178
+ def input_tokens(self) -> int: ...
179
+
180
+ @property
181
+ def output_tokens(self) -> int: ...
182
+
183
+ @property
184
+ def cache_read_tokens(self) -> int: ...
185
+
186
+ @property
187
+ def cache_write_tokens(self) -> int: ...
188
+
189
+ @property
190
+ def run_count(self) -> int: ...
191
+
192
+ @property
193
+ def usage_reported_runs(self) -> int: ...
194
+
195
+ @property
196
+ def legacy_token_runs(self) -> int: ...
197
+
198
+ @property
199
+ def billed_cost_usd(self) -> float | None: ...
200
+
201
+ @property
202
+ def billed_cost_runs(self) -> int: ...
203
+
204
+ @property
205
+ def api_equivalent_cost_usd(self) -> float | None: ...
206
+
207
+ @property
208
+ def api_equivalent_cost_runs(self) -> int: ...
209
+
210
+ @property
211
+ def unknown_cost_runs(self) -> int: ...
212
+
213
+
214
+ class _QuotaWindow(Protocol):
215
+ @property
216
+ def name(self) -> str: ...
217
+
218
+ @property
219
+ def used_pct(self) -> float: ...
220
+
221
+ @property
222
+ def resets_in(self) -> str: ...
223
+
224
+ @property
225
+ def observed_ago(self) -> str: ...
226
+
227
+
228
+ class _AgentQuotas(Protocol):
229
+ @property
230
+ def agent(self) -> str: ...
231
+
232
+ @property
233
+ def quotas(self) -> Sequence[_QuotaWindow]: ...
234
+
235
+
236
+ class _ResourceSample(Protocol):
237
+ @property
238
+ def state(self) -> str | None: ...
239
+
240
+ @property
241
+ def host_cpu_pct(self) -> float: ...
242
+
243
+ @property
244
+ def host_ram_used_pct(self) -> float: ...
245
+
246
+ @property
247
+ def free_ram_gb(self) -> float: ...
248
+
249
+ @property
250
+ def host_ram_used_bytes(self) -> int | None: ...
251
+
252
+ @property
253
+ def host_ram_total_bytes(self) -> int | None: ...
254
+
255
+ @property
256
+ def instance_cpu_pct(self) -> float | None: ...
257
+
258
+ @property
259
+ def instance_rss_gb(self) -> float | None: ...
260
+
261
+ @property
262
+ def shikiru_cpu_pct(self) -> float | None: ...
263
+
264
+ @property
265
+ def shikiru_rss_gb(self) -> float | None: ...
266
+
267
+ @property
268
+ def agents_cpu_pct(self) -> float | None: ...
269
+
270
+ @property
271
+ def agents_rss_gb(self) -> float | None: ...
272
+
273
+ @property
274
+ def shikiru_process_tree_empty(self) -> bool | None: ...
275
+
276
+ @property
277
+ def agents_process_tree_empty(self) -> bool | None: ...
278
+
279
+ @property
280
+ def sampled_ago(self) -> str | None: ...
281
+
282
+ @property
283
+ def error(self) -> str | None: ...
284
+
285
+
286
+ class _AttentionDecision(Protocol):
287
+ @property
288
+ def escalation_id(self) -> str: ...
289
+
290
+ @property
291
+ def headline(self) -> str: ...
292
+
293
+ @property
294
+ def reason(self) -> EscalationReason | None: ...
295
+
296
+
297
+ class _AttentionGroup(Protocol):
298
+ @property
299
+ def task_id(self) -> str | None: ...
300
+
301
+ @property
302
+ def reasons(self) -> tuple[str, ...]: ...
303
+
304
+ @property
305
+ def escalations(self) -> tuple[_AttentionDecision, ...]: ...
306
+
307
+
308
+ class _HeldTaskState(Protocol):
309
+ @property
310
+ def task_id(self) -> str: ...
311
+
312
+ @property
313
+ def holds(self) -> tuple[str, ...]: ...
314
+
315
+ @property
316
+ def operator_owned_roles(self) -> tuple[str, ...]: ...
317
+
318
+
319
+ class _AttentionSummary(Protocol):
320
+ @property
321
+ def engine_up(self) -> bool | None: ...
322
+
323
+ @property
324
+ def held_tasks(self) -> int | None: ...
325
+
326
+ @property
327
+ def holds(self) -> tuple[str, ...] | None: ...
328
+
329
+ @property
330
+ def operator_owned(self) -> tuple[str, ...] | None: ...
331
+
332
+ @property
333
+ def held_task_states(self) -> tuple[_HeldTaskState, ...] | None: ...
334
+
335
+
336
+ class _Attention(Protocol):
337
+ @property
338
+ def groups(self) -> tuple[_AttentionGroup, ...]: ...
339
+
340
+ @property
341
+ def decisions(self) -> int: ...
342
+
343
+
344
+ class _TaskSession(Protocol):
345
+ @property
346
+ def role(self) -> str: ...
347
+
348
+ @property
349
+ def agent(self) -> str: ...
350
+
351
+ @property
352
+ def model(self) -> str: ...
353
+
354
+ @property
355
+ def effort(self) -> str | None: ...
356
+
357
+ @property
358
+ def current_lineage(self) -> bool: ...
359
+
360
+ @property
361
+ def input_tokens(self) -> int: ...
362
+
363
+ @property
364
+ def output_tokens(self) -> int: ...
365
+
366
+ @property
367
+ def cache_read_tokens(self) -> int: ...
368
+
369
+ @property
370
+ def cache_write_tokens(self) -> int: ...
371
+
372
+ @property
373
+ def usage_reported(self) -> bool | None: ...
374
+
375
+ @property
376
+ def legacy_token_totals(self) -> bool: ...
377
+
378
+ @property
379
+ def billing_mode(self) -> str: ...
380
+
381
+ @property
382
+ def cost_usd(self) -> float | None: ...
383
+
384
+ @property
385
+ def cost_basis(self) -> str: ...
386
+
387
+ @property
388
+ def legacy_cost_recorded(self) -> bool: ...
389
+
390
+
391
+ class _TaskDetail(Protocol):
392
+ @property
393
+ def milestones(self) -> tuple[_TaskMilestone, ...]: ...
394
+
395
+
396
+ class _TaskMilestone(Protocol):
397
+ @property
398
+ def stage(self) -> str: ...
399
+
400
+ @property
401
+ def state(self) -> str: ...
402
+
403
+
404
+ @dataclass(frozen=True)
405
+ class ProjectUsageMetric:
406
+ """Render the Home token bar."""
407
+
408
+ usage: _RecordedUsage
409
+
410
+ def render_home(self, width: int | None = None) -> Text:
411
+ """Render the Home token bar, wrapping only between its groups within `width`."""
412
+ return _home_project_usage_text(self.usage, width)
413
+
414
+
415
+ def _known_token_sessions(usage: _RecordedUsage) -> int:
416
+ return usage.reported_sessions + usage.legacy_token_sessions
417
+
418
+
419
+ def _home_project_usage_text(usage: _RecordedUsage, width: int | None) -> Text:
420
+ groups = [*_headline_groups(usage), *_home_coverage_texts(usage)]
421
+ return wrap_groups(tuple(groups), width)
422
+
423
+
424
+ def _headline_groups(usage: _RecordedUsage) -> list[Text]:
425
+ groups = [
426
+ _count_text(usage.session_count, "session", _BOLD_PRIMARY),
427
+ _home_token_text(usage),
428
+ ]
429
+ if usage.api_billed_cost_sessions and usage.api_billed_cost_usd is not None:
430
+ groups.append(_billed_total(usage.api_billed_cost_usd, USAGE_API_TOTAL_WORD))
431
+ return groups
432
+
433
+
434
+ def _billed_total(amount: float, word: str) -> Text:
435
+ return Text.assemble(
436
+ (_cost_text(amount), _BOLD_SAGE),
437
+ (f" {word}", SECONDARY_TEXT_COLOR),
438
+ )
439
+
440
+
441
+ def _cost_text(amount: float) -> str:
442
+ return f"${amount:{SESSION_COST_FORMAT}}"
443
+
444
+
445
+ def _home_token_text(usage: _RecordedUsage) -> Text:
446
+ if not _known_token_sessions(usage):
447
+ return Text("tokens unknown", style=SECONDARY_TEXT_COLOR)
448
+ total = compact_count(usage.input_tokens + usage.output_tokens)
449
+ return Text.assemble((total, _BOLD_STEEL), (" tokens", SECONDARY_TEXT_COLOR))
450
+
451
+
452
+ def _home_coverage_texts(usage: _RecordedUsage) -> list[Text]:
453
+ labels: list[str] = []
454
+ if _known_token_sessions(usage):
455
+ if usage.legacy_token_sessions:
456
+ labels.append(_count_label(usage.legacy_token_sessions, "legacy session"))
457
+ if usage.unknown_token_sessions:
458
+ labels.append(f"{_count_label(usage.unknown_token_sessions, 'session')} without tokens")
459
+ if usage.api_cost_unreported_sessions:
460
+ labels.append(f"{_count_label(usage.api_cost_unreported_sessions, 'cost')} unreported")
461
+ if usage.unknown_billing_sessions:
462
+ labels.append(f"{_count_label(usage.unknown_billing_sessions, 'billing mode')} unknown")
463
+ return [Text(label, style=SECONDARY_TEXT_COLOR) for label in labels]
464
+
465
+
466
+ @dataclass(frozen=True)
467
+ class ProjectUsageTotals:
468
+ """Render recorded usage as labelled Totals, Tokens, Coverage and Billing rows."""
469
+
470
+ usage: _RecordedUsage
471
+
472
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
473
+ """Wrap each row at its group separators within the current width."""
474
+ del console
475
+ yield labelled_rows(_totals_rows(self.usage), options.max_width, USAGE_LABEL_WIDTH)
476
+
477
+
478
+ def _totals_rows(usage: _RecordedUsage) -> list[Row]:
479
+ rows = [
480
+ (USAGE_TOTALS_LABEL, (*_headline_groups(usage), *_all_mode_billed_groups(usage))),
481
+ (USAGE_TOKENS_LABEL, tuple(_token_detail_groups(usage))),
482
+ (USAGE_COVERAGE_LABEL, tuple(_coverage_groups(usage))),
483
+ ]
484
+ billing_lines = [tuple(_billing_groups(usage)), tuple(_estimate_groups(usage))]
485
+ populated = [groups for groups in billing_lines if groups]
486
+ rows.extend(
487
+ (USAGE_BILLING_LABEL if index == 0 else "", groups)
488
+ for index, groups in enumerate(populated)
489
+ )
490
+ return [(grey(label), groups) for label, groups in rows if groups]
491
+
492
+
493
+ def _all_mode_billed_groups(usage: _RecordedUsage) -> list[Text]:
494
+ amount = usage.billed_cost_usd
495
+ differs = (
496
+ usage.billed_cost_sessions != usage.api_billed_cost_sessions
497
+ or amount != usage.api_billed_cost_usd
498
+ )
499
+ if differs and usage.billed_cost_sessions and amount:
500
+ return [_billed_total(amount, USAGE_BILLED_WORD)]
501
+ return []
502
+
503
+
504
+ def _token_detail_groups(usage: _RecordedUsage) -> list[Text]:
505
+ if not _known_token_sessions(usage):
506
+ return []
507
+ groups = [
508
+ _token_amount(USAGE_TOKENS_IN_LABEL, usage.input_tokens),
509
+ _token_amount(USAGE_TOKENS_OUT_LABEL, usage.output_tokens),
510
+ ]
511
+ if usage.cache_read_tokens or usage.cache_write_tokens:
512
+ groups.append(_token_amount(USAGE_CACHE_READ_LABEL, usage.cache_read_tokens))
513
+ groups.append(_token_amount(USAGE_CACHE_WRITE_LABEL, usage.cache_write_tokens))
514
+ return groups
515
+
516
+
517
+ def _token_amount(label: str, count: int) -> Text:
518
+ return Text.assemble((f"{label} ", SECONDARY_TEXT_COLOR), (compact_count(count), _BOLD_STEEL))
519
+
520
+
521
+ def _coverage_groups(usage: _RecordedUsage) -> list[Text]:
522
+ partial = usage.legacy_token_sessions or usage.unknown_token_sessions
523
+ if not _known_token_sessions(usage) or not partial:
524
+ return []
525
+ return _counted_groups(
526
+ (usage.reported_sessions, USAGE_REPORTED_WORD),
527
+ (usage.legacy_token_sessions, USAGE_LEGACY_WORD),
528
+ (usage.unknown_token_sessions, USAGE_WITHOUT_TOKENS_WORD),
529
+ )
530
+
531
+
532
+ def _billing_groups(usage: _RecordedUsage) -> list[Text]:
533
+ return _counted_groups(
534
+ (usage.subscription_sessions, USAGE_SUBSCRIPTION_WORD),
535
+ (usage.api_billed_cost_sessions, USAGE_API_BILLED_WORD),
536
+ (usage.api_cost_unreported_sessions, USAGE_API_UNREPORTED_WORD),
537
+ (usage.unknown_billing_sessions, USAGE_UNKNOWN_WORD),
538
+ )
539
+
540
+
541
+ def _estimate_groups(usage: _RecordedUsage) -> list[Text]:
542
+ amount = usage.api_equivalent_cost_usd
543
+ if not usage.api_equivalent_cost_sessions or amount is None:
544
+ return []
545
+ sessions = _count_label(usage.api_equivalent_cost_sessions, "session")
546
+ return [_estimate_text(amount, f" {USAGE_API_EQUIVALENT_WORD} ({sessions})")]
547
+
548
+
549
+ def _estimate_text(amount: float, qualifier: str = "") -> Text:
550
+ return Text.assemble(
551
+ (f"{USAGE_ESTIMATE_PREFIX}{_cost_text(amount)}", AMBER),
552
+ (qualifier, SECONDARY_TEXT_COLOR),
553
+ )
554
+
555
+
556
+ def _counted_groups(*counts: tuple[int, str]) -> list[Text]:
557
+ return [_counted(count, label) for count, label in counts if count]
558
+
559
+
560
+ def _counted(count: int, label: str) -> Text:
561
+ return Text.assemble((str(count), _BOLD_PRIMARY), (f" {label}", SECONDARY_TEXT_COLOR))
562
+
563
+
564
+ def compact_count(count: int) -> str:
565
+ if count >= COUNT_SHORT_MILLION:
566
+ return f"{count / COUNT_SHORT_MILLION:.{COUNT_SHORT_PRECISION}f}M"
567
+ if count >= COUNT_SHORT_SCALE:
568
+ return f"{count / COUNT_SHORT_SCALE:.{COUNT_SHORT_PRECISION}f}k"
569
+ return f"{count:,}"
570
+
571
+
572
+ def _count_label(count: int, noun: str) -> str:
573
+ return f"{count} {_plural(count, noun)}"
574
+
575
+
576
+ def _count_text(count: int, noun: str, count_style: str) -> Text:
577
+ return Text.assemble(
578
+ (str(count), count_style),
579
+ (f" {_plural(count, noun)}", SECONDARY_TEXT_COLOR),
580
+ )
581
+
582
+
583
+ def _plural(count: int, noun: str) -> str:
584
+ return noun if count == 1 else f"{noun}s"
585
+
586
+
587
+ @dataclass(frozen=True)
588
+ class ResourceMetric:
589
+ """Render one width-wrapping host, Shikiru and agent observation."""
590
+
591
+ sample: _ResourceSample | None
592
+
593
+ def render(self, *, width: int | None = None) -> Text:
594
+ if self.sample is None:
595
+ return Text("Resources unavailable · no sample recorded")
596
+ state = self.sample.state
597
+ if state == RESOURCE_FAILED:
598
+ return _resource_state_line(RESOURCE_FAILED, self.sample)
599
+ if state == RESOURCE_UNAVAILABLE:
600
+ return _resource_state_line(RESOURCE_UNAVAILABLE, self.sample)
601
+ groups = (
602
+ _host_resource_text(self.sample),
603
+ _process_resource_text(
604
+ "Shikiru",
605
+ self.sample.shikiru_cpu_pct,
606
+ self.sample.shikiru_rss_gb,
607
+ self.sample.shikiru_process_tree_empty,
608
+ ),
609
+ _process_resource_text(
610
+ "Agents",
611
+ self.sample.agents_cpu_pct,
612
+ self.sample.agents_rss_gb,
613
+ self.sample.agents_process_tree_empty,
614
+ ),
615
+ )
616
+ metrics = wrap_groups(groups, width)
617
+ if state == RESOURCE_STALE:
618
+ age = _observed_suffix(self.sample.sampled_ago)
619
+ error = f" · {self.sample.error}" if self.sample.error else ""
620
+ return Text.assemble(
621
+ (f"Stale{age}{error}", AMBER),
622
+ "\n",
623
+ metrics,
624
+ )
625
+ if state not in {RESOURCE_AVAILABLE, RESOURCE_EMPTY}:
626
+ return Text.assemble(
627
+ (f"{RESOURCE_STATE_UNKNOWN}\n", SECONDARY_TEXT_COLOR),
628
+ metrics,
629
+ )
630
+ return metrics
631
+
632
+
633
+ def _observed_suffix(sampled_ago: str | None) -> str:
634
+ return f" · {observed_text(sampled_ago)}" if sampled_ago else ""
635
+
636
+
637
+ def _resource_state_line(state: str, sample: _ResourceSample) -> Text:
638
+ error = f" · {sample.error}" if sample.error else ""
639
+ age = _observed_suffix(sample.sampled_ago)
640
+ style = ALERT_COLOR if state == RESOURCE_FAILED else SECONDARY_TEXT_COLOR
641
+ return Text(f"Resources {state}{error}{age}", style=style)
642
+
643
+
644
+ def _process_resource_text(
645
+ label: str,
646
+ cpu_pct: float | None,
647
+ rss_gb: float | None,
648
+ process_tree_empty: bool | None,
649
+ ) -> Text:
650
+ if cpu_pct is None or rss_gb is None:
651
+ if process_tree_empty:
652
+ return Text.assemble(
653
+ (label, _BOLD_PRIMARY),
654
+ (" process tree is empty", SECONDARY_TEXT_COLOR),
655
+ )
656
+ return Text(f"{label} CPU/RSS unavailable", style=SECONDARY_TEXT_COLOR)
657
+ return _resource_group_text(
658
+ label,
659
+ cpu_pct,
660
+ Text.assemble((" RSS", PRIMARY_TEXT_COLOR), (f" {format_gib(rss_gb)} GiB", STEEL_BLUE)),
661
+ )
662
+
663
+
664
+ def _host_resource_text(sample: _ResourceSample) -> Text:
665
+ used = sample.host_ram_used_bytes
666
+ total = sample.host_ram_total_bytes
667
+ if used is None or total is None:
668
+ memory = Text(" RAM used/total unavailable", style=SECONDARY_TEXT_COLOR)
669
+ else:
670
+ memory = Text.assemble(
671
+ (" RAM", PRIMARY_TEXT_COLOR),
672
+ (
673
+ f" {format_gib(used / BYTES_PER_GIB)}/{format_gib(total / BYTES_PER_GIB)} GiB",
674
+ STEEL_BLUE,
675
+ ),
676
+ )
677
+ return _resource_group_text("Host", sample.host_cpu_pct, memory)
678
+
679
+
680
+ def format_gib(gib: float) -> str:
681
+ """Format a GiB amount with one decimal and no unit."""
682
+ return f"{gib:{GIB_FORMAT}}"
683
+
684
+
685
+ def format_memory(memory_bytes: int) -> str:
686
+ """Format bytes as GiB with one decimal from one GiB up, otherwise as whole MiB."""
687
+ if memory_bytes >= BYTES_PER_GIB:
688
+ return f"{format_gib(memory_bytes / BYTES_PER_GIB)} GiB"
689
+ return f"{memory_bytes / BYTES_PER_MIB:{MIB_FORMAT}} MiB"
690
+
691
+
692
+ def free_space_text(free_bytes: int, total_bytes: int, level: UsageLevel | None) -> Text:
693
+ """Render `free of total` as sizes, coloured by `level` and ending in `‼` at warn and alert."""
694
+ style = _FREE_SPACE_STYLES.get(level) if level else None
695
+ text = Text.assemble(
696
+ (format_size(free_bytes), style or _BOLD_STEEL),
697
+ (" of ", SECONDARY_TEXT_COLOR),
698
+ (format_size(total_bytes), _BOLD_STEEL),
699
+ )
700
+ if style:
701
+ text.append(f" {GLYPH_ESCALATED}", style=style)
702
+ return text
703
+
704
+
705
+ def format_cpu_pct(cpu_pct: float) -> str:
706
+ """Format a CPU percentage with one decimal below ten percent, otherwise as an integer."""
707
+ number_format = CPU_DECIMAL_FORMAT if cpu_pct < CPU_DECIMALS_BELOW_PCT else CPU_INTEGER_FORMAT
708
+ return f"{cpu_pct:{number_format}}%"
709
+
710
+
711
+ def _resource_group_text(label: str, cpu_pct: float, memory: Text) -> Text:
712
+ text = Text.assemble(
713
+ (label, _BOLD_PRIMARY),
714
+ (" CPU", PRIMARY_TEXT_COLOR),
715
+ (f" {format_cpu_pct(cpu_pct)}", STEEL_BLUE),
716
+ )
717
+ text.append(memory)
718
+ return text
719
+
720
+
721
+ def render_resource_metric(sample: _ResourceSample | None, *, width: int | None = None) -> Text:
722
+ """Render `sample` without starting another resource observation."""
723
+ return ResourceMetric(sample).render(width=width)
724
+
725
+
726
+ def attention_status_text(
727
+ attention: _Attention,
728
+ summary: _AttentionSummary,
729
+ *,
730
+ action_hint: str,
731
+ selected_group_index: int = 0,
732
+ selected_escalation_id: str | None = None,
733
+ not_escalated_task_id: str | None = None,
734
+ width: int | None = None,
735
+ ) -> Text:
736
+ """Render the Home status strip from current attention and observed workspace state."""
737
+ if not_escalated_task_id is not None:
738
+ status = _not_escalated_status(
739
+ not_escalated_task_id, attention.decisions, action_hint, width
740
+ )
741
+ if attention.groups:
742
+ _append_engine_state(status, summary)
743
+ _append_held_task_states(status, summary)
744
+ else:
745
+ _append_held_task_states(status, summary, inline=True)
746
+ return status
747
+ if attention.groups:
748
+ group = attention.groups[selected_group_index]
749
+ status = _attention_group_status(
750
+ group,
751
+ action_hint=action_hint,
752
+ selected_escalation_id=selected_escalation_id,
753
+ width=width,
754
+ )
755
+ _append_engine_state(status, summary)
756
+ _append_held_task_states(status, summary)
757
+ return status
758
+ status = Text("✓ No decisions needed", style=SAGE)
759
+ _append_held_task_states(status, summary, inline=True)
760
+ return status
761
+
762
+
763
+ def display_escalation_headline(
764
+ task_id: str | None,
765
+ headline: str,
766
+ reason: EscalationReason | None = None,
767
+ ) -> str:
768
+ """Drop an already-present leading task identity and name the reason in words.
769
+
770
+ The stored headline is never changed; only its exact `step · reason:` segment is shown
771
+ with the reason's Operator-facing label.
772
+ """
773
+ shown = headline if task_id is None else headline.removeprefix(f"{task_id} · ")
774
+ if reason is None:
775
+ return shown
776
+ return shown.replace(f" · {reason.value}:", f" · {escalation_reason_label(reason)}:", 1)
777
+
778
+
779
+ @dataclass(frozen=True)
780
+ class FocusAttentionStatus:
781
+ """Render a cached Home attention projection against the current terminal width."""
782
+
783
+ attention: _Attention
784
+ summary: _AttentionSummary
785
+ action_hint: str
786
+ selected_group_index: int = 0
787
+ selected_escalation_id: str | None = None
788
+ not_escalated_task_id: str | None = None
789
+
790
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
791
+ """Align the existing status and action hint during Rich's current-width render."""
792
+ del console
793
+ yield attention_status_text(
794
+ self.attention,
795
+ self.summary,
796
+ action_hint=self.action_hint,
797
+ selected_group_index=self.selected_group_index,
798
+ selected_escalation_id=self.selected_escalation_id,
799
+ not_escalated_task_id=self.not_escalated_task_id,
800
+ width=options.max_width,
801
+ )
802
+
803
+
804
+ def _attention_group_status(
805
+ group: _AttentionGroup,
806
+ *,
807
+ action_hint: str,
808
+ selected_escalation_id: str | None,
809
+ width: int | None,
810
+ ) -> Text:
811
+ if group.escalations:
812
+ decision = next(
813
+ (item for item in group.escalations if item.escalation_id == selected_escalation_id),
814
+ group.escalations[0],
815
+ )
816
+ headline = display_escalation_headline(group.task_id, decision.headline, decision.reason)
817
+ status = Text.assemble(
818
+ ("! ", AMBER),
819
+ (decision.escalation_id, f"{STYLE_BOLD} {AMBER}"),
820
+ (" · ", PRIMARY_TEXT_COLOR),
821
+ (group.task_id, _BOLD_PRIMARY) if group.task_id else ("Project", PRIMARY_TEXT_COLOR),
822
+ (f" · {headline}", PRIMARY_TEXT_COLOR),
823
+ )
824
+ _append_aligned_hint(status, action_hint, width)
825
+ else:
826
+ fault = next(
827
+ (reason for reason in group.reasons if reason.startswith("engine fault")),
828
+ None,
829
+ )
830
+ if fault is not None:
831
+ identity = f"{group.task_id or 'Project'} · {fault}"
832
+ status = Text.assemble(("! ", ALERT_COLOR), (identity, ALERT_COLOR))
833
+ else:
834
+ status = Text.assemble(("! ", AMBER))
835
+ append_key_hint(
836
+ status,
837
+ f"{group.task_id or 'Project'} · {' · '.join(group.reasons)} · {action_hint}",
838
+ AMBER,
839
+ )
840
+ return status
841
+
842
+
843
+ def _not_escalated_status(
844
+ task_id: str,
845
+ other_decisions: int,
846
+ action_hint: str,
847
+ width: int | None,
848
+ ) -> Text:
849
+ status = Text.assemble((task_id, _BOLD_PRIMARY), (" · No decision needed", PRIMARY_TEXT_COLOR))
850
+ if other_decisions:
851
+ noun = "decision" if other_decisions == 1 else "decisions"
852
+ status.append(f" · {other_decisions} {noun} on other tasks", style=SECONDARY_TEXT_COLOR)
853
+ _append_aligned_hint(status, action_hint, width)
854
+ return status
855
+
856
+
857
+ def _append_aligned_hint(status: Text, action_hint: str, width: int | None) -> None:
858
+ gap = max(
859
+ width - cell_len(status.plain) - cell_len(action_hint)
860
+ if width is not None
861
+ else STATUS_ALIGNMENT_MIN_GAP,
862
+ STATUS_ALIGNMENT_MIN_GAP,
863
+ )
864
+ status.append(" " * gap)
865
+ append_key_hint(status, action_hint, SECONDARY_TEXT_COLOR)
866
+
867
+
868
+ def _aligned_line(left: RenderableType, right: RenderableType) -> Table:
869
+ line = Table.grid(padding=(0, 0), expand=True)
870
+ line.add_column(ratio=1)
871
+ line.add_column(justify="right")
872
+ line.add_row(left, right)
873
+ return line
874
+
875
+
876
+ def _append_held_task_states(
877
+ status: Text,
878
+ summary: _AttentionSummary,
879
+ *,
880
+ inline: bool = False,
881
+ ) -> None:
882
+ held_task_states = summary.held_task_states
883
+ if held_task_states is None:
884
+ if summary.operator_owned:
885
+ _append_status_line(
886
+ status,
887
+ f"@ Operator owns {', '.join(summary.operator_owned)}",
888
+ STEEL_BLUE,
889
+ inline=inline,
890
+ )
891
+ if summary.held_tasks:
892
+ _append_status_line(
893
+ status,
894
+ f"= Hold details unavailable for {summary.held_tasks} tasks",
895
+ AMBER,
896
+ inline=inline,
897
+ )
898
+ return
899
+ if summary.held_tasks and not held_task_states:
900
+ _append_status_line(status, "= Hold details unavailable", AMBER, inline=inline)
901
+ if summary.operator_owned and not held_task_states:
902
+ _append_status_line(
903
+ status,
904
+ "@ Operator-owned task details unavailable",
905
+ STEEL_BLUE,
906
+ inline=inline,
907
+ )
908
+ for held_task in held_task_states:
909
+ _append_task_hold_state(status, held_task, inline=inline)
910
+
911
+
912
+ def _append_task_hold_state(
913
+ status: Text,
914
+ held_task: _HeldTaskState,
915
+ *,
916
+ inline: bool,
917
+ ) -> None:
918
+ owner = (
919
+ f"Operator owns {', '.join(role.title() for role in held_task.operator_owned_roles)}"
920
+ if held_task.operator_owned_roles
921
+ else "held"
922
+ )
923
+ holds = ", ".join(held_task.holds) if held_task.holds else "hold details unavailable"
924
+ _append_status_line(
925
+ status,
926
+ f"{'@' if held_task.operator_owned_roles else '='} {held_task.task_id} · {owner} · {holds}",
927
+ STEEL_BLUE if held_task.operator_owned_roles else AMBER,
928
+ inline=inline,
929
+ )
930
+
931
+
932
+ def _append_engine_state(
933
+ status: Text,
934
+ summary: _AttentionSummary,
935
+ ) -> None:
936
+ if summary.engine_up is False:
937
+ _append_status_line(status, "○ Engine stopped", SECONDARY_TEXT_COLOR)
938
+ elif summary.engine_up is None:
939
+ _append_status_line(
940
+ status,
941
+ "○ Engine status unavailable",
942
+ SECONDARY_TEXT_COLOR,
943
+ )
944
+
945
+
946
+ def _append_status_line(status: Text, line: str, style: str, *, inline: bool = False) -> None:
947
+ status.append(" · " if inline else "\n")
948
+ status.append(line, style=style)
949
+
950
+
951
+ @dataclass(frozen=True)
952
+ class AgentUsageRow:
953
+ """Render one compact role and session-usage pair."""
954
+
955
+ session: _TaskSession
956
+ role_state: str
957
+
958
+ def render(self) -> Group:
959
+ session = self.session
960
+ role = ROLE_LABELS.get(session.role, session.role.title())
961
+ identity = f"{session.agent}/{session.model}"
962
+ tokens = _session_token_summary(session)
963
+ charge, charge_style = _compact_session_charge(session)
964
+ details = Text.assemble(
965
+ (identity, STEEL_BLUE),
966
+ (f": {tokens}", SECONDARY_TEXT_COLOR),
967
+ (f" · {charge}", charge_style),
968
+ )
969
+ return Group(
970
+ _aligned_line(
971
+ Text(role, style=SECONDARY_TEXT_COLOR),
972
+ Text(self.role_state, style=SECONDARY_TEXT_COLOR),
973
+ ),
974
+ details,
975
+ )
976
+
977
+
978
+ def _session_token_summary(session: _TaskSession) -> str:
979
+ if session.usage_reported is False:
980
+ return "session tokens unknown"
981
+ total = compact_count(session.input_tokens + session.output_tokens)
982
+ if session.usage_reported is True:
983
+ return f"session-wide {total} tok"
984
+ if session.legacy_token_totals:
985
+ return f"session-wide {total} tok legacy"
986
+ return "session tokens unknown"
987
+
988
+
989
+ def _session_cost_text(session: _TaskSession) -> tuple[str, str]:
990
+ if session.cost_usd is None:
991
+ if session.billing_mode == BILLING_MODE_SUBSCRIPTION:
992
+ return "no per-task charge reported", AMBER
993
+ if session.billing_mode == BILLING_MODE_API:
994
+ return "API charge unreported", SECONDARY_TEXT_COLOR
995
+ return "cost unknown", SECONDARY_TEXT_COLOR
996
+ amount = f"${session.cost_usd:{SESSION_COST_FORMAT}}"
997
+ if session.cost_basis == COST_BASIS_BILLED:
998
+ label = f"billed {amount}"
999
+ style = SAGE
1000
+ elif session.cost_basis == COST_BASIS_API_EQUIVALENT:
1001
+ label = f"estimate {amount}"
1002
+ style = AMBER
1003
+ else:
1004
+ label = f"amount {amount} · basis unknown"
1005
+ style = SECONDARY_TEXT_COLOR
1006
+ return label, style
1007
+
1008
+
1009
+ def _compact_session_charge(session: _TaskSession) -> tuple[str, str]:
1010
+ if session.billing_mode == BILLING_MODE_SUBSCRIPTION:
1011
+ charge, style = "Subscription", SAGE
1012
+ elif session.billing_mode == BILLING_MODE_API:
1013
+ if session.cost_usd is None:
1014
+ charge, style = "API charge unreported", SECONDARY_TEXT_COLOR
1015
+ elif session.cost_basis == COST_BASIS_BILLED:
1016
+ charge, style = f"API ${session.cost_usd:{SESSION_COST_FORMAT}}", SAGE
1017
+ elif session.cost_basis == COST_BASIS_API_EQUIVALENT:
1018
+ charge, style = f"API estimate ${session.cost_usd:{SESSION_COST_FORMAT}}", AMBER
1019
+ else:
1020
+ charge, style = "API amount basis unknown", SECONDARY_TEXT_COLOR
1021
+ elif session.cost_usd is None:
1022
+ charge, style = "billing unknown", SECONDARY_TEXT_COLOR
1023
+ elif session.cost_basis == COST_BASIS_BILLED:
1024
+ charge, style = f"reported ${session.cost_usd:{SESSION_COST_FORMAT}}", SAGE
1025
+ elif session.cost_basis == COST_BASIS_API_EQUIVALENT:
1026
+ charge, style = f"API estimate ${session.cost_usd:{SESSION_COST_FORMAT}}", AMBER
1027
+ elif session.cost_basis == COST_BASIS_UNKNOWN:
1028
+ charge, style = "amount basis unknown", SECONDARY_TEXT_COLOR
1029
+ else:
1030
+ charge, style = _session_cost_text(session)
1031
+ return charge, style
1032
+
1033
+
1034
+ @dataclass(frozen=True)
1035
+ class AgentUsageRows:
1036
+ """Render one row of runs, tokens and cost per agent."""
1037
+
1038
+ usages: Sequence[_AgentUsageSummary]
1039
+
1040
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
1041
+ """Wrap each row at its group separators within the current width."""
1042
+ del console
1043
+ rows = [
1044
+ (Text(usage.agent, style=_BOLD_PRIMARY), _agent_groups(usage)) for usage in self.usages
1045
+ ]
1046
+ label_width = max(cell_len(label.plain) for label, _ in rows)
1047
+ yield labelled_rows(rows, options.max_width, label_width)
1048
+
1049
+
1050
+ def _agent_groups(usage: _AgentUsageSummary) -> tuple[Text, ...]:
1051
+ return (
1052
+ _count_text(usage.run_count, "run", _BOLD_PRIMARY),
1053
+ *_agent_token_groups(usage),
1054
+ *_agent_cost_groups(usage),
1055
+ )
1056
+
1057
+
1058
+ def _agent_token_groups(usage: _AgentUsageSummary) -> list[Text]:
1059
+ known_runs = usage.usage_reported_runs + usage.legacy_token_runs
1060
+ if not known_runs:
1061
+ return [grey(USAGE_TOKENS_UNKNOWN)]
1062
+ groups = [
1063
+ Text.assemble(
1064
+ (compact_count(usage.input_tokens + usage.output_tokens), _BOLD_STEEL),
1065
+ (f" {USAGE_TOKENS_WORD}", SECONDARY_TEXT_COLOR),
1066
+ )
1067
+ ]
1068
+ if usage.run_count > known_runs:
1069
+ groups.append(grey(f"{usage.run_count - known_runs} {USAGE_WITHOUT_TOKENS_WORD}"))
1070
+ return groups
1071
+
1072
+
1073
+ def _agent_cost_groups(usage: _AgentUsageSummary) -> list[Text]:
1074
+ groups: list[Text] = []
1075
+ if usage.billed_cost_runs and usage.billed_cost_usd is not None:
1076
+ groups.append(_billed_total(usage.billed_cost_usd, USAGE_BILLED_WORD))
1077
+ if usage.api_equivalent_cost_runs and usage.api_equivalent_cost_usd is not None:
1078
+ groups.append(_estimate_text(usage.api_equivalent_cost_usd))
1079
+ if usage.unknown_cost_runs:
1080
+ label = USAGE_COST_UNKNOWN
1081
+ groups.append(grey(f"{usage.unknown_cost_runs} {label}" if groups else label))
1082
+ return groups
1083
+
1084
+
1085
+ @dataclass(frozen=True)
1086
+ class AgentQuotaLimits:
1087
+ """Render each agent's quota windows as gauge rows with their reset countdowns."""
1088
+
1089
+ usages: Sequence[_AgentQuotas]
1090
+ warn_pct: int
1091
+ alert_pct: int
1092
+
1093
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
1094
+ """Wrap each window row within the current width and note a shared observation age."""
1095
+ del console
1096
+ windows = [(usage.agent, quota) for usage in self.usages for quota in usage.quotas]
1097
+ observed = {quota.observed_ago for _, quota in windows}
1098
+ shared = len(observed) == 1
1099
+ rows: list[Row] = []
1100
+ previous_agent: str | None = None
1101
+ for agent, quota in windows:
1102
+ label = Text(agent, style=_BOLD_PRIMARY) if agent != previous_agent else Text()
1103
+ previous_agent = agent
1104
+ gauge = Gauge(quota.name, quota.used_pct, self.warn_pct, self.alert_pct).render()
1105
+ gauge.append(f" {USAGE_RESETS_WORD} {quota.resets_in}", style=SECONDARY_TEXT_COLOR)
1106
+ if not shared:
1107
+ gauge.append(f" · {observed_text(quota.observed_ago)}", style=SECONDARY_TEXT_COLOR)
1108
+ rows.append((label, (gauge,)))
1109
+ label_width = max(cell_len(label.plain) for label, _ in rows)
1110
+ yield labelled_rows(rows, options.max_width, label_width)
1111
+ if shared:
1112
+ yield grey(observed_text(next(iter(observed))))
1113
+
1114
+
1115
+ @dataclass(frozen=True)
1116
+ class AgentUsageDetails:
1117
+ """Render session token, cost basis and source evidence below a compact row."""
1118
+
1119
+ session: _TaskSession
1120
+
1121
+ def render(self) -> Group:
1122
+ session = self.session
1123
+ if session.usage_reported is False:
1124
+ tokens = "Token values unknown"
1125
+ elif session.usage_reported is None and not session.legacy_token_totals:
1126
+ tokens = "Token provenance unknown"
1127
+ else:
1128
+ provenance = "legacy recorded · " if session.legacy_token_totals else "reported · "
1129
+ tokens = (
1130
+ f"{provenance}input {session.input_tokens:,} · output {session.output_tokens:,} · "
1131
+ f"cache read {session.cache_read_tokens:,} · "
1132
+ f"cache write {session.cache_write_tokens:,}"
1133
+ )
1134
+ cost = _session_cost_text(session)[0]
1135
+ cost_basis = f"Cost basis · {session.cost_basis} · {cost}"
1136
+ if session.legacy_cost_recorded:
1137
+ cost_basis = f"{cost_basis} · legacy record"
1138
+ lines = [
1139
+ Text(f"Tokens · {tokens}", style=SECONDARY_TEXT_COLOR),
1140
+ Text(cost_basis, style=SECONDARY_TEXT_COLOR),
1141
+ ]
1142
+ if session.effort:
1143
+ lines.append(Text(f"Effort · {session.effort}", style=SECONDARY_TEXT_COLOR))
1144
+ return Group(*lines)
1145
+
1146
+
1147
+ @dataclass(frozen=True)
1148
+ class StageActivity:
1149
+ """Render the recorded workflow milestones of one task."""
1150
+
1151
+ task: _TaskDetail
1152
+
1153
+ def render(self, *, glyphs_only: bool = False) -> Text:
1154
+ """Return the milestone row, as bare glyphs when `glyphs_only`."""
1155
+ line = Text()
1156
+ for index, milestone in enumerate(self.task.milestones):
1157
+ if index:
1158
+ line.append(MILESTONE_GLYPH_GAP if glyphs_only else MILESTONE_GAP)
1159
+ marker, style = _milestone_marker(milestone.state)
1160
+ line.append(marker if glyphs_only else f"{marker} {milestone.stage}", style=style)
1161
+ if not glyphs_only and milestone.state in {
1162
+ MILESTONE_SKIPPED,
1163
+ MILESTONE_REWORK,
1164
+ MILESTONE_UNKNOWN,
1165
+ MILESTONE_STOPPED,
1166
+ }:
1167
+ line.append(f" {milestone.state}", style=style)
1168
+ return line
1169
+
1170
+
1171
+ _MILESTONE_MARKERS = {
1172
+ MILESTONE_DONE: ("✓", SAGE),
1173
+ MILESTONE_CURRENT: (GLYPH_CURRENT_STAGE, STEEL_BLUE),
1174
+ MILESTONE_LATER: ("·", SECONDARY_TEXT_COLOR),
1175
+ MILESTONE_SKIPPED: ("—", SECONDARY_TEXT_COLOR),
1176
+ MILESTONE_REWORK: ("!", AMBER),
1177
+ MILESTONE_STOPPED: (GLYPH_STOPPED, ALERT_COLOR),
1178
+ }
1179
+
1180
+
1181
+ def _milestone_marker(state: str) -> tuple[str, str]:
1182
+ return _MILESTONE_MARKERS.get(state, ("?", AMBER))
1183
+
1184
+
1185
+ def duration_text(duration: timedelta) -> str:
1186
+ elapsed = duration.total_seconds()
1187
+ if elapsed < 0:
1188
+ return "future timestamp"
1189
+ total_seconds = int(elapsed)
1190
+ if total_seconds < SECONDS_PER_MINUTE:
1191
+ return f"{total_seconds}s"
1192
+ if total_seconds < SECONDS_PER_HOUR:
1193
+ return f"{total_seconds // SECONDS_PER_MINUTE}m"
1194
+ hours = total_seconds // SECONDS_PER_HOUR
1195
+ minutes = (total_seconds % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE
1196
+ return f"{hours}h {minutes}m" if minutes else f"{hours}h"
1197
+
1198
+
1199
+ def recorded_age(timestamp: datetime, now: datetime) -> str:
1200
+ elapsed = (now - timestamp).total_seconds()
1201
+ if elapsed < 0:
1202
+ return "future timestamp"
1203
+ seconds = int(elapsed)
1204
+ if seconds < SECONDS_PER_MINUTE:
1205
+ return f"{seconds}s ago"
1206
+ if seconds < SECONDS_PER_HOUR:
1207
+ return f"{seconds // SECONDS_PER_MINUTE}m ago"
1208
+ hours = seconds // SECONDS_PER_HOUR
1209
+ return f"{hours}h ago" if hours < HOURS_PER_DAY else f"{hours // HOURS_PER_DAY}d ago"