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,665 @@
1
+ """Real `WidgetServices` implementations, wired by `bootstrap`.
2
+
3
+ `GitRepoStatusService` is the `header` widget's only source of git data and caches it for its
4
+ configured interval so a dashboard refresh never spawns a git process on every tick.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import shlex
12
+ from collections import defaultdict
13
+ from dataclasses import dataclass, replace
14
+ from typing import TYPE_CHECKING
15
+
16
+ from shikiru.dashboard.contract import (
17
+ ContainerProblemView,
18
+ ContainerView,
19
+ DiagnosticSampleState,
20
+ DiskView,
21
+ DockerSummaryView,
22
+ DockerView,
23
+ ElsewhereView,
24
+ RepoStatus,
25
+ ResourceView,
26
+ )
27
+ from shikiru.domain.runs import RunStatus
28
+ from shikiru.domain.tasks import Role, Status
29
+ from shikiru.engine.turn_processes import read_turn_pid, turn_pid_path
30
+ from shikiru.errors import ShikiruError
31
+ from shikiru.operator.lifecycle import task_dashboard_command
32
+ from shikiru.paths import run_dir
33
+ from shikiru.process import ProcessIdentity, identify_pid
34
+ from shikiru.telemetry.resources import ResourceProcessRoots
35
+ from shikiru.tmux.client import (
36
+ ENV_VARIABLE_ASSIGNMENT_SEPARATOR,
37
+ FORMAT_PANE_ID,
38
+ FORMAT_PANE_PID,
39
+ FORMAT_PANE_START_COMMAND,
40
+ FORMAT_WINDOW_ID,
41
+ HOME_PANE_OPTION,
42
+ HOME_PANE_OPTION_VALUE,
43
+ LAYOUT_PANE_OPTION,
44
+ SYSTEM_LAYOUT_PANE,
45
+ TMUX_ENV_COMMAND,
46
+ TMUX_ENV_UNSET_OPTION,
47
+ TMUX_ENV_UNSET_OPTION_ARGUMENT_COUNT,
48
+ )
49
+ from shikiru.tmux.session import home_window_target
50
+ from shikiru.ui.age import format_age, format_short_age
51
+
52
+ if TYPE_CHECKING:
53
+ from collections.abc import Callable, Iterable
54
+ from datetime import datetime
55
+ from pathlib import Path
56
+
57
+ from shikiru.clock import Clock
58
+ from shikiru.config.settings import RepoConfig
59
+ from shikiru.domain.docker import DockerSummary
60
+ from shikiru.domain.results import LiveWork
61
+ from shikiru.domain.runs import Run
62
+ from shikiru.domain.tasks import Task
63
+ from shikiru.operator.queries import OperatorQueries
64
+ from shikiru.store.reader import StateReader
65
+ from shikiru.telemetry.disk import DiskSampler, DiskSnapshot
66
+ from shikiru.telemetry.docker import DockerSampler
67
+ from shikiru.telemetry.resources import ResourceSampler, ResourceSnapshot
68
+ from shikiru.tmux.client import Tmux
69
+ from shikiru.workspace.facts import TaskWorkObserver
70
+ from shikiru.workspace.git import Git
71
+
72
+ _PANE_RESOURCE_FIELD_SEPARATOR = "\t"
73
+ _PANE_RESOURCE_FORMAT = _PANE_RESOURCE_FIELD_SEPARATOR.join(
74
+ (
75
+ FORMAT_PANE_ID,
76
+ FORMAT_PANE_PID,
77
+ FORMAT_WINDOW_ID,
78
+ f"#{{{HOME_PANE_OPTION}}}",
79
+ f"#{{{LAYOUT_PANE_OPTION}}}",
80
+ FORMAT_PANE_START_COMMAND,
81
+ )
82
+ )
83
+ _PANE_RESOURCE_FIELD_COUNT = 6
84
+ _PANE_RESOURCE_MAX_SPLITS = _PANE_RESOURCE_FIELD_COUNT - 1
85
+ TASK_MISSING = "task is missing"
86
+ TASK_WINDOW_UNRECORDED = "task window is not recorded"
87
+ TASK_WINDOW_MISSING = "task window is unavailable"
88
+ TASK_ROLE_PANE_UNRECORDED = "role pane is not recorded"
89
+ TASK_ROLE_PANE_MISSING = "role pane is unavailable"
90
+ TASK_ROLE_PANE_WINDOW_MISMATCH = "role pane is outside the recorded task window"
91
+ HOME_WINDOW_MISSING = "home window is unavailable"
92
+ TERMINAL_TASK_TARGET = "task is terminal; its workflow window is unavailable"
93
+ TASK_TARGET_OPENED = "opened existing task window"
94
+ ROLE_TARGET_OPENED = "opened existing role pane"
95
+ HOME_TARGET_OPENED = "opened home workspace"
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class _CachedRepoStatus:
100
+ checked_at: datetime
101
+ sampled_at: datetime | None
102
+ status: RepoStatus
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class _PaneResource:
107
+ pane_id: str
108
+ pane_pid: int
109
+ window_id: str
110
+ home_marker: str
111
+ layout_marker: str
112
+ start_command: str
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class _PaneOwnership:
117
+ project_root: Path
118
+ pane_owners: dict[str, tuple[str, Role, str]]
119
+ task_windows: dict[str, str]
120
+ taken_over_runs: set[tuple[str, Role]]
121
+
122
+
123
+ class GitRepoStatusService:
124
+ """Report `header`'s repo row, refreshing at most once per configured interval."""
125
+
126
+ def __init__(
127
+ self,
128
+ git: Git,
129
+ repo: RepoConfig,
130
+ project_dir: Path,
131
+ clock: Clock,
132
+ *,
133
+ cache_ttl_seconds: float,
134
+ ) -> None:
135
+ """Read `project_dir`'s checkout, configured by `repo`, through `git` when asked."""
136
+ self._git = git
137
+ self._repo = repo
138
+ self._project_dir = project_dir.resolve()
139
+ self._clock = clock
140
+ self._cache_ttl_seconds = cache_ttl_seconds
141
+ self._cache: _CachedRepoStatus | None = None
142
+
143
+ async def repo_status(self) -> RepoStatus:
144
+ """Return the cached checkout identity, tracked line changes and observation state."""
145
+ now = self._clock.now()
146
+ if self._cache is not None:
147
+ age = (now - self._cache.checked_at).total_seconds()
148
+ if age < self._cache_ttl_seconds:
149
+ return self._with_sample_age(self._cache)
150
+ try:
151
+ status = await self._status()
152
+ except (OSError, ShikiruError) as error:
153
+ if self._cache is None or self._cache.sampled_at is None:
154
+ status = RepoStatus(
155
+ branch=None,
156
+ ahead=None,
157
+ behind=None,
158
+ dirty=None,
159
+ root=str(self._project_dir),
160
+ state=DiagnosticSampleState.FAILED,
161
+ error=str(error),
162
+ base=self._repo.base,
163
+ )
164
+ self._cache = _CachedRepoStatus(checked_at=now, sampled_at=None, status=status)
165
+ else:
166
+ status = replace(
167
+ self._cache.status,
168
+ state=DiagnosticSampleState.STALE,
169
+ error=str(error),
170
+ )
171
+ self._cache = _CachedRepoStatus(
172
+ checked_at=now,
173
+ sampled_at=self._cache.sampled_at,
174
+ status=status,
175
+ )
176
+ return self._with_sample_age(self._cache)
177
+ self._cache = _CachedRepoStatus(checked_at=now, sampled_at=now, status=status)
178
+ return self._with_sample_age(self._cache)
179
+
180
+ async def _status(self) -> RepoStatus:
181
+ repo = self._repo
182
+ repo_dir = self._project_dir
183
+ branch = await self._git.current_branch(repo_dir)
184
+ line_changes = await self._git.working_tree_line_changes(repo_dir)
185
+ dirty = len(await self._git.status_porcelain(repo_dir))
186
+ ahead, behind = 0, 0
187
+ if repo.base is not None and repo.base != branch:
188
+ ahead = len(await self._git.log_oneline(repo_dir, f"{repo.base}..{branch}"))
189
+ behind = len(await self._git.log_oneline(repo_dir, f"{branch}..{repo.base}"))
190
+ base = branch if repo.base is None else repo.base
191
+ remote_configured = await self._git.remote_configured(repo_dir, repo.remote)
192
+ unpushed, unpulled = (
193
+ await self._publication_counts(repo_dir, base) if remote_configured else (None, None)
194
+ )
195
+ return RepoStatus(
196
+ branch=branch,
197
+ ahead=ahead,
198
+ behind=behind,
199
+ dirty=dirty,
200
+ root=str(repo_dir),
201
+ added_lines=line_changes.added_lines,
202
+ deleted_lines=line_changes.deleted_lines,
203
+ binary_entries=line_changes.binary_entries,
204
+ untracked_paths=line_changes.untracked_paths,
205
+ base=base,
206
+ unpushed=unpushed,
207
+ unpulled=unpulled,
208
+ remote_configured=remote_configured,
209
+ )
210
+
211
+ async def _publication_counts(self, repo_dir: Path, base: str) -> tuple[int | None, int | None]:
212
+ remote = self._repo.remote
213
+ if await self._git.remote_branch_sha(repo_dir, remote, base) is None:
214
+ return None, None
215
+ remote_ref = f"refs/remotes/{remote}/{base}"
216
+ unpushed = await self._git.log_oneline(repo_dir, f"{remote_ref}..{base}")
217
+ unpulled = await self._git.log_oneline(repo_dir, f"{base}..{remote_ref}")
218
+ return len(unpushed), len(unpulled)
219
+
220
+ def _with_sample_age(self, cached: _CachedRepoStatus) -> RepoStatus:
221
+ if cached.sampled_at is None:
222
+ return cached.status
223
+ age = (self._clock.now() - cached.sampled_at).total_seconds()
224
+ return replace(cached.status, sampled_ago=format_age(age))
225
+
226
+
227
+ TASK_WORKTREE_MISSING = "task worktree is missing"
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class TaskWorkSample:
232
+ """A task's live work, or the error that stopped it being read."""
233
+
234
+ work: LiveWork | None
235
+ error: str | None
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class _CachedTaskWork:
240
+ checked_at: datetime
241
+ sample: TaskWorkSample
242
+
243
+
244
+ class TaskWorkService:
245
+ """Report a task's live work, reading its worktree at most once per configured interval."""
246
+
247
+ def __init__(
248
+ self,
249
+ observer: TaskWorkObserver,
250
+ clock: Clock,
251
+ *,
252
+ cache_ttl_seconds: float,
253
+ ) -> None:
254
+ """Read worktrees through `observer`, and time the cache with `clock`."""
255
+ self._observer = observer
256
+ self._clock = clock
257
+ self._cache_ttl_seconds = cache_ttl_seconds
258
+ self._cache: dict[str, _CachedTaskWork] = {}
259
+ self._reads: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
260
+
261
+ async def sample(self, task_id: str, task_root: Path, base_ref: str) -> TaskWorkSample:
262
+ """Return the live work of `task_id`, reading `task_root` unless a fresh sample exists.
263
+
264
+ Concurrent callers for one task share a read: the first one reads, and the rest wait for
265
+ it and take its sample while it is still fresh.
266
+ """
267
+ async with self._reads[task_id]:
268
+ now = self._clock.now()
269
+ cached = self._cache.get(task_id)
270
+ if cached is not None:
271
+ age = (now - cached.checked_at).total_seconds()
272
+ if age < self._cache_ttl_seconds:
273
+ return cached.sample
274
+ sample = await self._read(task_root, base_ref)
275
+ self._cache[task_id] = _CachedTaskWork(checked_at=now, sample=sample)
276
+ return sample
277
+
278
+ async def _read(self, task_root: Path, base_ref: str) -> TaskWorkSample:
279
+ if not await asyncio.to_thread(task_root.is_dir):
280
+ return TaskWorkSample(work=None, error=f"{TASK_WORKTREE_MISSING}: {task_root}")
281
+ try:
282
+ work = await self._observer.observe(task_root, base_ref)
283
+ except (OSError, ShikiruError) as error:
284
+ return TaskWorkSample(work=None, error=str(error))
285
+ return TaskWorkSample(work=work, error=None)
286
+
287
+
288
+ class DashboardServices:
289
+ """The `WidgetServices` bootstrap wires into every `dash` process."""
290
+
291
+ def __init__(
292
+ self,
293
+ repo_status_service: GitRepoStatusService,
294
+ *,
295
+ clock: Clock,
296
+ engine_up: Callable[[], bool],
297
+ engine_identity: Callable[[], ProcessIdentity | None],
298
+ project_root: Path,
299
+ state: StateReader,
300
+ tmux: Tmux,
301
+ session_target: str,
302
+ resources: ResourceSampler,
303
+ docker: DockerSampler,
304
+ disk: DiskSampler,
305
+ ) -> None:
306
+ """Delegate source reads and sampling through injected process boundaries."""
307
+ self._repo_status_service = repo_status_service
308
+ self._clock = clock
309
+ self._engine_up = engine_up
310
+ self._engine_identity = engine_identity
311
+ self._project_root = project_root
312
+ self._state = state
313
+ self._tmux = tmux
314
+ self._session_target = session_target
315
+ self._resources = resources
316
+ self._docker = docker
317
+ self._disk = disk
318
+ self._resource_failure: tuple[datetime, str] | None = None
319
+
320
+ async def repo_status(self) -> RepoStatus:
321
+ """See `GitRepoStatusService.repo_status`."""
322
+ return await self._repo_status_service.repo_status()
323
+
324
+ def engine_up(self) -> bool:
325
+ """Return whether the engine holds its liveness lock."""
326
+ return self._engine_up()
327
+
328
+ def engine_identity(self) -> ProcessIdentity | None:
329
+ """Return the verified identity of the process holding the engine lock."""
330
+ return self._engine_identity()
331
+
332
+ async def resources(self) -> ResourceView:
333
+ """Sample host usage and classified process trees belonging to this project."""
334
+ try:
335
+ previous = self._resources.latest()
336
+ snapshot = previous
337
+ if not self._resources.process_sample_is_fresh():
338
+ pane_rows: list[str] = []
339
+ if await self._tmux.target_exists(self._session_target):
340
+ pane_rows = await self._tmux.list_session_panes(
341
+ self._session_target, _PANE_RESOURCE_FORMAT
342
+ )
343
+ roots = self._resource_roots(pane_rows)
344
+ snapshot = self._resources.sample_process_roots(roots)
345
+ except (OSError, RuntimeError, ShikiruError, ValueError) as error:
346
+ failed_at = self._clock.now()
347
+ self._resource_failure = (failed_at, str(error))
348
+ latest = self._resources.latest()
349
+ state = (
350
+ DiagnosticSampleState.STALE
351
+ if latest.sampled_at is not None
352
+ else DiagnosticSampleState.FAILED
353
+ )
354
+ return _resource_view(latest, self._clock, state=state, error=str(error))
355
+ if snapshot is not previous:
356
+ self._resource_failure = None
357
+ elif self._resource_failure is not None:
358
+ latest = self._resources.latest()
359
+ state = (
360
+ DiagnosticSampleState.STALE
361
+ if latest.sampled_at is not None
362
+ else DiagnosticSampleState.FAILED
363
+ )
364
+ return _resource_view(
365
+ latest,
366
+ self._clock,
367
+ state=state,
368
+ error=self._resource_failure[1],
369
+ )
370
+ return _resource_view(snapshot, self._clock, state=DiagnosticSampleState.AVAILABLE)
371
+
372
+ def _resource_roots(self, pane_rows: list[str]) -> ResourceProcessRoots:
373
+ tasks = self._state.list_tasks()
374
+ runs = self._state.list_all_runs()
375
+ shikiru = _process_root_map((self._engine_identity(), identify_pid(os.getpid())))
376
+ agents = _live_agent_process_roots(self._project_root, runs)
377
+ pane_shikiru, pane_agents = _pane_process_roots(pane_rows, tasks, runs, self._project_root)
378
+ shikiru.update(pane_shikiru)
379
+ agents.update(pane_agents)
380
+ return ResourceProcessRoots(tuple(shikiru.values()), tuple(agents.values()))
381
+
382
+ def resource_sample(self) -> ResourceView | None:
383
+ """Return the latest dashboard resource reading without sampling again."""
384
+ snapshot = self._resources.latest()
385
+ failure = self._resource_failure
386
+ if failure is not None and (
387
+ snapshot.sampled_at is None or failure[0] >= snapshot.sampled_at
388
+ ):
389
+ state = (
390
+ DiagnosticSampleState.STALE
391
+ if snapshot.sampled_at is not None
392
+ else DiagnosticSampleState.FAILED
393
+ )
394
+ return _resource_view(snapshot, self._clock, state=state, error=failure[1])
395
+ if snapshot.sampled_at is None:
396
+ return None
397
+ return _resource_view(snapshot, self._clock, state=DiagnosticSampleState.AVAILABLE)
398
+
399
+ async def containers(self) -> DockerView:
400
+ """Return Docker stats in the stable dashboard contract shape."""
401
+ snapshot = await self._docker.sample_if_due()
402
+ if snapshot.error is not None:
403
+ state = (
404
+ DiagnosticSampleState.STALE
405
+ if snapshot.sampled_at is not None
406
+ else DiagnosticSampleState.FAILED
407
+ if snapshot.failed
408
+ else DiagnosticSampleState.UNAVAILABLE
409
+ )
410
+ elif not snapshot.available:
411
+ state = DiagnosticSampleState.UNAVAILABLE
412
+ elif snapshot.summary is not None and snapshot.summary.is_empty:
413
+ state = DiagnosticSampleState.EMPTY
414
+ else:
415
+ state = DiagnosticSampleState.AVAILABLE
416
+ return DockerView(
417
+ available=snapshot.available,
418
+ state=state,
419
+ sampled_ago=_sampled_ago(snapshot.sampled_at, self._clock),
420
+ error=snapshot.error,
421
+ summary=_docker_summary_view(snapshot.summary),
422
+ )
423
+
424
+ async def disk(self) -> DiskView:
425
+ """Return the disk sample in the stable dashboard contract shape."""
426
+ snapshot = await self._disk.sample_if_due()
427
+ return DiskView(
428
+ state=_disk_state(snapshot),
429
+ free_bytes=snapshot.free_bytes,
430
+ total_bytes=snapshot.total_bytes,
431
+ shikiru_bytes=snapshot.shikiru_bytes,
432
+ sampled_ago=_sampled_ago(snapshot.sampled_at, self._clock),
433
+ error=snapshot.error,
434
+ )
435
+
436
+
437
+ def _disk_state(snapshot: DiskSnapshot) -> DiagnosticSampleState:
438
+ if snapshot.error is None:
439
+ return DiagnosticSampleState.AVAILABLE
440
+ if snapshot.sampled_at is not None:
441
+ return DiagnosticSampleState.STALE
442
+ return DiagnosticSampleState.FAILED if snapshot.failed else DiagnosticSampleState.UNAVAILABLE
443
+
444
+
445
+ def _docker_summary_view(summary: DockerSummary | None) -> DockerSummaryView | None:
446
+ if summary is None:
447
+ return None
448
+ return DockerSummaryView(
449
+ running=summary.running,
450
+ total=summary.total,
451
+ cpu_pct=summary.cpu_pct,
452
+ memory_bytes=summary.memory_bytes,
453
+ problems=tuple(
454
+ ContainerProblemView(problem.name, problem.kind.value, problem.exit_code)
455
+ for problem in summary.problems
456
+ ),
457
+ top=tuple(
458
+ ContainerView(usage.name, usage.cpu_pct, usage.memory_bytes) for usage in summary.top
459
+ ),
460
+ elsewhere=ElsewhereView(
461
+ summary.elsewhere.running, summary.elsewhere.cpu_pct, summary.elsewhere.memory_bytes
462
+ ),
463
+ usage_available=summary.usage_available,
464
+ )
465
+
466
+
467
+ class TaskNavigation:
468
+ """Select recorded task, role-pane or home targets without creating resources."""
469
+
470
+ def __init__(
471
+ self,
472
+ tmux: Tmux,
473
+ queries: OperatorQueries,
474
+ session_target: str,
475
+ ) -> None:
476
+ """Use the existing tmux client, current task queries and home session target."""
477
+ self._tmux = tmux
478
+ self._queries = queries
479
+ self._session_target = session_target
480
+
481
+ async def task_window(self, task_id: str) -> str:
482
+ """Select the task's current window or return a readable availability result."""
483
+ task = self._queries.get_task_state(task_id)
484
+ if task is None:
485
+ return f"{task_id}: {TASK_MISSING}"
486
+ if task.status in (Status.DONE, Status.CANCELLED):
487
+ return f"{task_id}: {TERMINAL_TASK_TARGET}"
488
+ if task.window_id is None:
489
+ return f"{task_id}: {TASK_WINDOW_UNRECORDED}"
490
+ if not await self._tmux.target_exists(task.window_id):
491
+ return f"{task_id}: {TASK_WINDOW_MISSING}"
492
+ try:
493
+ await self._tmux.select_window(task.window_id)
494
+ except ShikiruError as error:
495
+ return f"{task_id}: {TASK_WINDOW_MISSING}: {error}"
496
+ return f"{task_id}: {TASK_TARGET_OPENED}"
497
+
498
+ async def home(self) -> str:
499
+ """Select the existing home window or report that it is unavailable."""
500
+ target = await home_window_target(self._tmux, self._session_target)
501
+ if target is None:
502
+ return HOME_WINDOW_MISSING
503
+ try:
504
+ await self._tmux.select_window(target[0])
505
+ except ShikiruError as error:
506
+ return f"{HOME_WINDOW_MISSING}: {error}"
507
+ return HOME_TARGET_OPENED
508
+
509
+
510
+ def _resource_view(
511
+ snapshot: ResourceSnapshot,
512
+ clock: Clock,
513
+ *,
514
+ state: DiagnosticSampleState,
515
+ error: str | None = None,
516
+ ) -> ResourceView:
517
+ return ResourceView(
518
+ host_cpu_pct=snapshot.host_cpu_pct,
519
+ host_ram_used_pct=snapshot.host_ram_used_pct,
520
+ free_ram_gb=snapshot.free_ram_gb,
521
+ instance_cpu_pct=snapshot.instance_cpu_pct,
522
+ instance_rss_gb=snapshot.instance_rss_gb,
523
+ sampled_at=snapshot.sampled_at,
524
+ state=state,
525
+ sampled_ago=_sampled_ago(snapshot.sampled_at, clock),
526
+ error=error,
527
+ host_ram_used_bytes=snapshot.host_ram_used_bytes,
528
+ host_ram_total_bytes=snapshot.host_ram_total_bytes,
529
+ shikiru_cpu_pct=snapshot.shikiru_cpu_pct,
530
+ shikiru_rss_gb=snapshot.shikiru_rss_gb,
531
+ agents_cpu_pct=snapshot.agents_cpu_pct,
532
+ agents_rss_gb=snapshot.agents_rss_gb,
533
+ shikiru_process_tree_empty=snapshot.shikiru_process_tree_empty,
534
+ agents_process_tree_empty=snapshot.agents_process_tree_empty,
535
+ )
536
+
537
+
538
+ def _sampled_ago(sampled_at: datetime | None, clock: Clock) -> str | None:
539
+ if sampled_at is None:
540
+ return None
541
+ age = clock.now() - sampled_at
542
+ return format_short_age(age) if age.total_seconds() >= 0 else None
543
+
544
+
545
+ def _pane_resource(line: str) -> _PaneResource:
546
+ fields = line.split(_PANE_RESOURCE_FIELD_SEPARATOR, _PANE_RESOURCE_MAX_SPLITS)
547
+ if len(fields) != _PANE_RESOURCE_FIELD_COUNT:
548
+ message = f"tmux pane resource row is invalid: {line!r}"
549
+ raise ValueError(message)
550
+ pane_id, pane_pid_text, window_id, home_marker, layout_marker, start_command = fields
551
+ return _PaneResource(
552
+ pane_id,
553
+ int(pane_pid_text),
554
+ window_id,
555
+ home_marker,
556
+ layout_marker,
557
+ start_command,
558
+ )
559
+
560
+
561
+ def _process_root_map(
562
+ identities: Iterable[ProcessIdentity | None],
563
+ ) -> dict[tuple[int, float | None], ProcessIdentity]:
564
+ return {
565
+ (identity.pid, identity.started_at): identity
566
+ for identity in identities
567
+ if identity is not None
568
+ }
569
+
570
+
571
+ def _live_agent_process_roots(
572
+ project_root: Path,
573
+ runs: list[Run],
574
+ ) -> dict[tuple[int, float | None], ProcessIdentity]:
575
+ roots: dict[tuple[int, float | None], ProcessIdentity] = {}
576
+ for run in runs:
577
+ if run.status is not RunStatus.LIVE:
578
+ continue
579
+ path = turn_pid_path(run_dir(project_root, run.task_id), run.role)
580
+ try:
581
+ identity = read_turn_pid(path)
582
+ except FileNotFoundError:
583
+ continue
584
+ roots[(identity.pid, identity.started_at)] = identity
585
+ return roots
586
+
587
+
588
+ def _pane_process_roots(
589
+ pane_rows: list[str],
590
+ tasks: list[Task],
591
+ runs: list[Run],
592
+ project_root: Path,
593
+ ) -> tuple[
594
+ dict[tuple[int, float | None], ProcessIdentity],
595
+ dict[tuple[int, float | None], ProcessIdentity],
596
+ ]:
597
+ taken_over_runs = {
598
+ (run.task_id, run.role) for run in runs if run.status is RunStatus.TAKEN_OVER
599
+ }
600
+ pane_owners = {
601
+ pane_id: (task.id, role, task.window_id)
602
+ for task in tasks
603
+ if task.window_id is not None
604
+ for role, pane_id in task.panes.items()
605
+ }
606
+ task_windows = {task.window_id: task.id for task in tasks if task.window_id is not None}
607
+ ownership = _PaneOwnership(project_root, pane_owners, task_windows, taken_over_runs)
608
+ shikiru: dict[tuple[int, float | None], ProcessIdentity] = {}
609
+ agents: dict[tuple[int, float | None], ProcessIdentity] = {}
610
+ for line in pane_rows:
611
+ _add_pane_process_root(line, ownership, shikiru, agents)
612
+ return shikiru, agents
613
+
614
+
615
+ def _add_pane_process_root(
616
+ line: str,
617
+ ownership: _PaneOwnership,
618
+ shikiru: dict[tuple[int, float | None], ProcessIdentity],
619
+ agents: dict[tuple[int, float | None], ProcessIdentity],
620
+ ) -> None:
621
+ pane = _pane_resource(line)
622
+ identity = identify_pid(pane.pane_pid)
623
+ if identity is None:
624
+ return
625
+ identity_key = (identity.pid, identity.started_at)
626
+ if pane.home_marker == HOME_PANE_OPTION_VALUE:
627
+ shikiru[identity_key] = identity
628
+ owner = ownership.pane_owners.get(pane.pane_id)
629
+ if owner is None:
630
+ owner_task_id = ownership.task_windows.get(pane.window_id)
631
+ if (
632
+ owner_task_id is not None
633
+ and pane.layout_marker == SYSTEM_LAYOUT_PANE
634
+ and _is_task_dashboard(pane.start_command, ownership.project_root, owner_task_id)
635
+ ):
636
+ shikiru[identity_key] = identity
637
+ return
638
+ owner_task_id, owner_role, owner_window_id = owner
639
+ if owner_window_id == pane.window_id:
640
+ target = agents if (owner_task_id, owner_role) in ownership.taken_over_runs else shikiru
641
+ target[identity_key] = identity
642
+
643
+
644
+ def _is_task_dashboard(start_command: str, project_root: Path, task_id: str) -> bool:
645
+ try:
646
+ command = tuple(shlex.split(start_command))
647
+ except ValueError:
648
+ return False
649
+ expected = task_dashboard_command(project_root, task_id)
650
+ if command[:1] != (TMUX_ENV_COMMAND,):
651
+ return command == expected
652
+ index = 1
653
+ while index < len(command):
654
+ value = command[index]
655
+ if value == TMUX_ENV_UNSET_OPTION:
656
+ if index + 1 == len(command):
657
+ return False
658
+ index += TMUX_ENV_UNSET_OPTION_ARGUMENT_COUNT
659
+ continue
660
+ if ENV_VARIABLE_ASSIGNMENT_SEPARATOR not in value or value.startswith(
661
+ TMUX_ENV_UNSET_OPTION[0]
662
+ ):
663
+ return command[index:] == expected
664
+ index += 1
665
+ return False