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,36 @@
1
+ """The owned operator service bundle built for one project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Callable
9
+
10
+ from shikiru.operator.commands import OperatorCommands
11
+ from shikiru.operator.events import EventFeed
12
+ from shikiru.operator.queries import OperatorQueries
13
+ from shikiru.store.reader import StateReader
14
+
15
+
16
+ class OperatorServices:
17
+ """Own the query, command and event services and release their state connections."""
18
+
19
+ def __init__(
20
+ self,
21
+ reader: StateReader,
22
+ queries: OperatorQueries,
23
+ commands: OperatorCommands,
24
+ events: EventFeed,
25
+ close: Callable[[], None],
26
+ ) -> None:
27
+ """Store the services and their state-connection cleanup callback."""
28
+ self.reader = reader
29
+ self.queries = queries
30
+ self.commands = commands
31
+ self.events = events
32
+ self._close = close
33
+
34
+ def close(self) -> None:
35
+ """Close the bundle's writer and read-only state connections."""
36
+ self._close()
@@ -0,0 +1,378 @@
1
+ """Read Shikiru tmux sessions and prepare exact attach targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from collections.abc import Callable, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
12
+
13
+ from shikiru.domain.tasks import Status
14
+ from shikiru.errors import (
15
+ CommandFailed,
16
+ CommandTimeoutError,
17
+ ConfigError,
18
+ DatabaseError,
19
+ UsageError,
20
+ )
21
+ from shikiru.operator.attachment import admit_attachment
22
+ from shikiru.paths import project_config_path, state_db_path
23
+ from shikiru.store.reader import StateReader
24
+ from shikiru.tmux.client import exact_target
25
+ from shikiru.tmux.session import PROJECT_DIR_ENV_VAR, SESSION_PREFIX
26
+
27
+ if TYPE_CHECKING:
28
+ from shikiru.process import SubprocessRunner
29
+ from shikiru.tmux.client import Tmux
30
+
31
+ SSH_COMMAND = "ssh"
32
+ SSH_OPTION_TERMINATOR = "--"
33
+ SSH_TTY_OPTION = "-t"
34
+ SHIKIRU_COMMAND = "shikiru"
35
+ LS_COMMAND = "ls"
36
+ JSON_OPTION = "--json"
37
+ ATTACH_COMMAND = "attach"
38
+ ATTACH_OPTION_TERMINATOR = "--"
39
+ TMUX_ENV_ASSIGNMENT = "TMUX="
40
+ TMUX_PANE_ENV_ASSIGNMENT = "TMUX_PANE="
41
+ ENV_COMMAND = "env"
42
+ ENGINE_UP = "up"
43
+ ENGINE_DOWN = "down"
44
+ REMOTE_LIST_COMMAND = (SHIKIRU_COMMAND, LS_COMMAND, JSON_OPTION)
45
+ NO_SESSIONS_ERROR = "no Shikiru sessions found"
46
+ REMOTE_PARTIAL_EXIT_CODE = 1
47
+ REMOTE_ORIGIN_HOST_ENV_VAR = "SHIKIRU_REMOTE_ORIGIN_HOST"
48
+
49
+
50
+ class SessionRow(BaseModel):
51
+ """Progress counts for one live Shikiru tmux session."""
52
+
53
+ model_config = ConfigDict(extra="forbid", frozen=True)
54
+
55
+ session: str
56
+ project_dir: str
57
+ engine_state: Literal["up", "down"]
58
+ running: int
59
+ queued: int
60
+ escalated: int
61
+
62
+
63
+ _ROWS_ADAPTER: TypeAdapter[list[SessionRow]] = TypeAdapter(list[SessionRow])
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class SessionRepair:
68
+ label: str
69
+ instruction: str
70
+ command: str
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class SessionDiagnostic:
75
+ session: str
76
+ session_id: str | None
77
+ project_dir: str | None
78
+ reason: str
79
+ repairs: tuple[SessionRepair, ...]
80
+ note: str
81
+
82
+ @property
83
+ def repair_commands(self) -> tuple[str, ...]:
84
+ return tuple(repair.command for repair in self.repairs)
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class SessionListing:
89
+ rows: tuple[SessionRow, ...]
90
+ diagnostics: tuple[SessionDiagnostic, ...] = ()
91
+ remote_diagnostics: str = ""
92
+ remote_exit_code: int = 0
93
+
94
+
95
+ EngineState = Callable[[Path], bool]
96
+ SessionPrompt = Callable[[Sequence[SessionRow], str | None], SessionRow]
97
+ ReaderFactory = Callable[[Path], StateReader]
98
+
99
+
100
+ class SessionLister:
101
+ """Read local or remote Shikiru sessions without opening a writer connection."""
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ tmux: Tmux,
107
+ runner: SubprocessRunner,
108
+ engine_up: EngineState,
109
+ reader_factory: ReaderFactory,
110
+ remote_timeout_seconds: float,
111
+ ) -> None:
112
+ """Inject tmux, process, liveness, database-reader, and timeout boundaries."""
113
+ self._tmux = tmux
114
+ self._runner = runner
115
+ self._engine_up = engine_up
116
+ self._reader_factory = reader_factory
117
+ self._remote_timeout_seconds = remote_timeout_seconds
118
+
119
+ async def list_sessions(
120
+ self,
121
+ host: str | None = None,
122
+ *,
123
+ project_dir: Path | None = None,
124
+ session_name: str | None = None,
125
+ ) -> SessionListing:
126
+ """Return visible sessions and any per-project discovery failures."""
127
+ if host is not None:
128
+ return await self._list_remote(host)
129
+ return await self._list_local(project_dir=project_dir, session_name=session_name)
130
+
131
+ async def _list_local(
132
+ self, *, project_dir: Path | None, session_name: str | None
133
+ ) -> SessionListing:
134
+ try:
135
+ names = await self._tmux.list_sessions()
136
+ rows: list[SessionRow] = []
137
+ diagnostics: list[SessionDiagnostic] = []
138
+ for name in names:
139
+ if not name.startswith(SESSION_PREFIX):
140
+ continue
141
+ if session_name is not None and not _session_matches_name(name, session_name):
142
+ continue
143
+ session_project_dir: str | None = None
144
+ try:
145
+ session_project_dir = await self._tmux.show_session_environment(
146
+ exact_target(name), PROJECT_DIR_ENV_VAR
147
+ )
148
+ except ConfigError as error:
149
+ diagnostics.append(
150
+ await _session_diagnostic(self._tmux, name, session_project_dir, error)
151
+ )
152
+ continue
153
+ if session_project_dir is None:
154
+ message = f"tmux session {name!r} has no {PROJECT_DIR_ENV_VAR}"
155
+ diagnostics.append(
156
+ await _session_diagnostic(self._tmux, name, None, ConfigError(message))
157
+ )
158
+ continue
159
+ if not Path(session_project_dir).is_absolute():
160
+ message = (
161
+ "tmux session "
162
+ f"{name!r} has an invalid project directory: {session_project_dir!r}"
163
+ )
164
+ diagnostics.append(
165
+ await _session_diagnostic(
166
+ self._tmux, name, session_project_dir, ConfigError(message)
167
+ )
168
+ )
169
+ continue
170
+ try:
171
+ if project_dir is not None and not _same_project(
172
+ Path(session_project_dir), project_dir
173
+ ):
174
+ continue
175
+ rows.append(self._project_row(name, session_project_dir))
176
+ except (ConfigError, DatabaseError, OSError, ValidationError, ValueError) as error:
177
+ diagnostics.append(
178
+ await _session_diagnostic(self._tmux, name, session_project_dir, error)
179
+ )
180
+ except OSError as error:
181
+ message = f"could not inspect local tmux sessions: {error}"
182
+ raise ConfigError(message) from error
183
+ return SessionListing(tuple(rows), tuple(diagnostics))
184
+
185
+ async def _list_remote(self, host: str) -> SessionListing:
186
+ _validate_host(host)
187
+ try:
188
+ result = await self._runner.run(
189
+ (SSH_COMMAND, SSH_OPTION_TERMINATOR, host, *REMOTE_LIST_COMMAND),
190
+ timeout=self._remote_timeout_seconds,
191
+ check=False,
192
+ )
193
+ except OSError as error:
194
+ message = f"could not run ssh for host {host!r}: {error}"
195
+ raise ConfigError(message) from error
196
+ if result.returncode not in {0, REMOTE_PARTIAL_EXIT_CODE}:
197
+ raise CommandFailed(result.argv, result.returncode, result.stderr)
198
+ try:
199
+ rows = _ROWS_ADAPTER.validate_json(result.stdout)
200
+ except ValidationError as error:
201
+ message = f"host {host!r} returned invalid Shikiru session data: {error}"
202
+ raise ConfigError(message) from error
203
+ remote_diagnostics = result.stderr
204
+ if result.returncode == REMOTE_PARTIAL_EXIT_CODE and not remote_diagnostics:
205
+ remote_diagnostics = f"remote ls exited with status {result.returncode}"
206
+ return SessionListing(
207
+ tuple(rows),
208
+ remote_diagnostics=remote_diagnostics,
209
+ remote_exit_code=result.returncode,
210
+ )
211
+
212
+ def _project_row(self, session: str, project_dir: str) -> SessionRow:
213
+ root = Path(project_dir)
214
+ reader = self._reader_factory(root)
215
+ try:
216
+ tasks = reader.list_tasks()
217
+ engine_state = ENGINE_UP if self._engine_up(root) else ENGINE_DOWN
218
+ return SessionRow(
219
+ session=session,
220
+ project_dir=project_dir,
221
+ engine_state=engine_state,
222
+ running=sum(task.status in {Status.RUNNING, Status.INTEGRATING} for task in tasks),
223
+ queued=sum(task.status is Status.QUEUED for task in tasks),
224
+ escalated=sum(task.status is Status.ESCALATED for task in tasks),
225
+ )
226
+ except (ValidationError, ValueError) as error:
227
+ message = f"could not read Shikiru progress from {state_db_path(root)}: {error}"
228
+ raise DatabaseError(message) from error
229
+ except OSError as error:
230
+ message = f"could not inspect Shikiru project {root}: {error}"
231
+ raise ConfigError(message) from error
232
+ finally:
233
+ reader.close()
234
+
235
+
236
+ class SessionAttacher:
237
+ """Select a listed row and replace the CLI with its local or remote tmux client."""
238
+
239
+ def __init__(self, runner: SubprocessRunner, tmux: Tmux, prompt: SessionPrompt) -> None:
240
+ """Use `runner` for process replacement and `prompt` for interactive selection."""
241
+ self._runner = runner
242
+ self._tmux = tmux
243
+ self._prompt = prompt
244
+
245
+ async def attach(
246
+ self,
247
+ rows: Sequence[SessionRow],
248
+ *,
249
+ name: str | None,
250
+ host: str | None,
251
+ inside_tmux: bool,
252
+ current_pane: str | None = None,
253
+ ) -> None:
254
+ """Replace this process with tmux or ssh for one listed session."""
255
+ if host is not None and name is not None:
256
+ self._runner.replace(_remote_attach_argv(host, name))
257
+ return
258
+ row = self._select(rows, name, host)
259
+ if host is None:
260
+ same_client = await admit_attachment(self._tmux, row.session, current_pane)
261
+ if same_client:
262
+ return
263
+ command = self._tmux.guarded_client_argv(row.session, switch=inside_tmux)
264
+ else:
265
+ command = _remote_attach_argv(host, row.session)
266
+ self._runner.replace(command)
267
+
268
+ def _select(self, rows: Sequence[SessionRow], name: str | None, host: str | None) -> SessionRow:
269
+ if name is not None:
270
+ matches = [row for row in rows if _session_matches_name(row.session, name)]
271
+ if len(matches) == 1:
272
+ return matches[0]
273
+ message = f"no unique Shikiru session named {name!r}"
274
+ raise UsageError(message)
275
+ if len(rows) == 1:
276
+ return rows[0]
277
+ if not rows:
278
+ raise UsageError(NO_SESSIONS_ERROR)
279
+ return self._prompt(rows, host)
280
+
281
+
282
+ def _session_matches_name(session: str, name: str) -> bool:
283
+ return session == name or session.removeprefix(SESSION_PREFIX) == name
284
+
285
+
286
+ def _same_project(session_project: Path, requested_project: Path) -> bool:
287
+ return session_project.resolve() == requested_project.resolve()
288
+
289
+
290
+ def _validate_host(host: str) -> None:
291
+ if not host or host.startswith("-") or any(character.isspace() for character in host):
292
+ message = f"invalid SSH host: {host!r}"
293
+ raise UsageError(message)
294
+
295
+
296
+ def _remote_attach_argv(host: str, name: str) -> tuple[str, ...]:
297
+ _validate_host(host)
298
+ if not name:
299
+ message = "session name cannot be empty"
300
+ raise UsageError(message)
301
+ remote_command = shlex.join(
302
+ (
303
+ ENV_COMMAND,
304
+ f"{REMOTE_ORIGIN_HOST_ENV_VAR}={host}",
305
+ TMUX_ENV_ASSIGNMENT,
306
+ TMUX_PANE_ENV_ASSIGNMENT,
307
+ SHIKIRU_COMMAND,
308
+ ATTACH_COMMAND,
309
+ ATTACH_OPTION_TERMINATOR,
310
+ name,
311
+ )
312
+ )
313
+ return SSH_COMMAND, SSH_TTY_OPTION, SSH_OPTION_TERMINATOR, host, remote_command
314
+
315
+
316
+ async def _session_diagnostic(
317
+ tmux: Tmux,
318
+ session: str,
319
+ project_dir: str | None,
320
+ error: Exception,
321
+ ) -> SessionDiagnostic:
322
+ try:
323
+ session_id = await tmux.session_id(session)
324
+ except (CommandFailed, CommandTimeoutError, OSError, ValueError) as lookup_error:
325
+ session_id = None
326
+ lookup_failure = (
327
+ f"Tmux session ID lookup failed ({type(lookup_error).__name__}): {lookup_error}"
328
+ )
329
+ else:
330
+ lookup_failure = None
331
+ repairs, note = _session_repairs(session_id, project_dir)
332
+ if lookup_failure is not None:
333
+ note = f"{note} {lookup_failure}"
334
+ return SessionDiagnostic(
335
+ session=session,
336
+ session_id=session_id,
337
+ project_dir=project_dir,
338
+ reason=str(error),
339
+ repairs=repairs,
340
+ note=note,
341
+ )
342
+
343
+
344
+ def _session_repairs(
345
+ session_id: str | None,
346
+ project_dir: str | None,
347
+ ) -> tuple[tuple[SessionRepair, ...], str]:
348
+ repairs: list[SessionRepair] = []
349
+ if session_id is not None:
350
+ repairs.append(
351
+ SessionRepair(
352
+ "Session",
353
+ "Remove the retained session if it is no longer needed.",
354
+ shlex.join(("tmux", "kill-session", "-t", session_id)),
355
+ )
356
+ )
357
+ if project_dir is None or not Path(project_dir).is_absolute():
358
+ note = "The project path is missing or invalid."
359
+ else:
360
+ root = Path(project_dir)
361
+ if root.is_dir() and not project_config_path(root).exists():
362
+ repairs.append(
363
+ SessionRepair(
364
+ "Project",
365
+ "Initialize fresh state if intended; this cannot recover a deleted database.",
366
+ shlex.join((SHIKIRU_COMMAND, "--project", project_dir, "init")),
367
+ )
368
+ )
369
+ note = "Initialization creates fresh project state."
370
+ elif root.is_dir():
371
+ note = (
372
+ "Removing the retained tmux session does not repair the unavailable project state."
373
+ )
374
+ else:
375
+ note = "The project directory is absent."
376
+ if session_id is None:
377
+ note = f"{note} A stable tmux session ID is unavailable, so no kill command is offered."
378
+ return tuple(repairs), note
@@ -0,0 +1,162 @@
1
+ """Build task-strip view data from domain state for operator interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.ladder import pick_rung
8
+ from shikiru.domain.steps import ACTING_ROLE, Step
9
+ from shikiru.domain.tasks import Hold, Status, Task
10
+ from shikiru.ui.glyphs import (
11
+ GLYPH_DONE,
12
+ GLYPH_ENGINE,
13
+ GLYPH_ESCALATED,
14
+ GLYPH_PENDING,
15
+ GLYPH_STOPPED,
16
+ ROLE_GLYPHS,
17
+ SPINNER_FRAMES,
18
+ STAGE_TRIAGE,
19
+ )
20
+ from shikiru.ui.strip import (
21
+ PanelState,
22
+ PanelView,
23
+ StampKind,
24
+ StripStamp,
25
+ TaskStripData,
26
+ panel_specs_for_mode,
27
+ )
28
+ from shikiru.ui.tokens import STOPPED_DURING
29
+
30
+ if TYPE_CHECKING:
31
+ from shikiru.config.settings import Settings
32
+
33
+ _PANEL_INDEX: dict[Step, int] = {
34
+ Step.TRIAGE: 0,
35
+ Step.PLAN: 1,
36
+ Step.IMPLEMENT: 1,
37
+ Step.CONSULT: 1,
38
+ Step.VERIFY: 2,
39
+ Step.INTEGRATE: 3,
40
+ }
41
+
42
+
43
+ def task_strip_data(task: Task, settings: Settings) -> TaskStripData:
44
+ """Build the render data for one task using the configured role ladder."""
45
+ if task.status is Status.CANCELLED:
46
+ return _unstarted_strip_data(task)
47
+ if task.step is None:
48
+ if task.status is Status.RUNNING:
49
+ return _starting_strip_data(task)
50
+ if task.status not in (Status.QUEUED, Status.DONE, Status.ESCALATED):
51
+ message = f"task {task.id} is active but has no step recorded"
52
+ raise ValueError(message)
53
+ return _unstarted_strip_data(task)
54
+ if task.mode is None:
55
+ if task.step is not Step.TRIAGE:
56
+ message = f"task {task.id} has no mode yet but is at step {task.step!r}, not triage"
57
+ raise ValueError(message)
58
+ specs = (("Triage", STAGE_TRIAGE),)
59
+ else:
60
+ specs = panel_specs_for_mode(task.mode)
61
+ active_state = _active_panel_state(task)
62
+ current_index = _PANEL_INDEX[task.step]
63
+ panels = tuple(
64
+ _panel_view(
65
+ label,
66
+ glyph,
67
+ PanelState.DONE
68
+ if index < current_index
69
+ else (active_state if index == current_index else PanelState.PENDING),
70
+ step=task.step,
71
+ task=task,
72
+ settings=settings,
73
+ )
74
+ for index, (label, glyph) in enumerate(specs)
75
+ )
76
+ return TaskStripData(
77
+ task_id=task.id,
78
+ title=task.title,
79
+ panels=panels,
80
+ stamp=_stamp_for(task),
81
+ )
82
+
83
+
84
+ def _unstarted_strip_data(task: Task) -> TaskStripData:
85
+ if task.status is Status.QUEUED:
86
+ state, glyph = PanelState.PENDING, GLYPH_PENDING
87
+ elif task.status is Status.DONE:
88
+ state, glyph = PanelState.DONE, GLYPH_DONE
89
+ elif task.status is Status.CANCELLED:
90
+ state, glyph = PanelState.PENDING, GLYPH_STOPPED
91
+ elif task.status is Status.ESCALATED:
92
+ state, glyph = PanelState.ESCALATED, GLYPH_ESCALATED
93
+ else:
94
+ message = f"task {task.id} has no step for status {task.status.value!r}"
95
+ raise ValueError(message)
96
+ stopped = (
97
+ f"{STOPPED_DURING} {task.step.value}"
98
+ if task.status is Status.CANCELLED and task.step is not None
99
+ else ""
100
+ )
101
+ panel = PanelView(
102
+ label=task.status.value.title(), stage_glyph=glyph, state=state, detail=stopped
103
+ )
104
+ return TaskStripData(
105
+ task_id=task.id,
106
+ title=task.title,
107
+ panels=(panel,),
108
+ stamp=_stamp_for(task),
109
+ )
110
+
111
+
112
+ def _starting_strip_data(task: Task) -> TaskStripData:
113
+ panel = PanelView(
114
+ label="Starting",
115
+ stage_glyph=GLYPH_ENGINE,
116
+ state=PanelState.ACTIVE,
117
+ detail="setting up",
118
+ spinner_frame=SPINNER_FRAMES[0],
119
+ )
120
+ return TaskStripData(
121
+ task_id=task.id,
122
+ title=task.title,
123
+ panels=(panel,),
124
+ stamp=_stamp_for(task),
125
+ )
126
+
127
+
128
+ def _active_panel_state(task: Task) -> PanelState:
129
+ if task.status is Status.ESCALATED:
130
+ return PanelState.ESCALATED
131
+ if task.status is Status.DONE:
132
+ return PanelState.DONE
133
+ return PanelState.ACTIVE
134
+
135
+
136
+ def _panel_view(
137
+ label: str, glyph: str, state: PanelState, *, step: Step, task: Task, settings: Settings
138
+ ) -> PanelView:
139
+ if state not in (PanelState.ACTIVE, PanelState.ESCALATED):
140
+ return PanelView(label=label, stage_glyph=glyph, state=state)
141
+ role = ACTING_ROLE[step]
142
+ rung = pick_rung(task, role, settings)
143
+ detail = f"iter {task.iteration}" if task.iteration else ""
144
+ role_line = f"{ROLE_GLYPHS[role]} {rung.agent}/{rung.model}"
145
+ spinner_frame = SPINNER_FRAMES[0] if state is PanelState.ACTIVE else ""
146
+ return PanelView(
147
+ label=label,
148
+ stage_glyph=glyph,
149
+ state=state,
150
+ detail=detail,
151
+ role_line=role_line,
152
+ spinner_frame=spinner_frame,
153
+ role=role if state is PanelState.ACTIVE else None,
154
+ )
155
+
156
+
157
+ def _stamp_for(task: Task) -> StripStamp | None:
158
+ if Hold.PAUSED in task.holds:
159
+ return StripStamp(StampKind.PAUSED)
160
+ if Hold.INTERVENTION in task.holds:
161
+ return StripStamp(StampKind.OPERATOR)
162
+ return None