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,1601 @@
1
+ """Read-only operator views over the state reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ from shikiru.agents.contracts import (
17
+ COST_BASIS_UNKNOWN,
18
+ CostBasis,
19
+ QuotaWindow,
20
+ Usage,
21
+ )
22
+ from shikiru.config.settings import MAX_TIMELINE_PAGE_SIZE, MAX_TIMELINE_SIGNAL_SCAN_ROWS
23
+ from shikiru.domain.commands import (
24
+ Command,
25
+ CommandStatus,
26
+ HandBack,
27
+ ResolutionKind,
28
+ Resolve,
29
+ Say,
30
+ SetRung,
31
+ Stop,
32
+ Submit,
33
+ TakeOver,
34
+ )
35
+ from shikiru.domain.engine_uptime import project_engine_uptime
36
+ from shikiru.domain.escalation import EscalationStatus
37
+ from shikiru.domain.escalation_evidence import parse_escalation_packet
38
+ from shikiru.domain.events import Event, EventKind
39
+ from shikiru.domain.faults import FaultStreak, open_fault_streaks
40
+ from shikiru.domain.reports import (
41
+ FACTS_FILENAME,
42
+ HELPER_REPORT_FILENAME,
43
+ REPORT_FILENAME,
44
+ REPORT_KIND_FILENAME,
45
+ FeedbackVerdict,
46
+ Option,
47
+ Report,
48
+ ReportKind,
49
+ )
50
+ from shikiru.domain.runs import Run, RunStatus, has_positive_token_evidence
51
+ from shikiru.domain.scheduling import dispatch_sort_key, occupied_slot_ids
52
+ from shikiru.domain.status_tasks import select_human_status_tasks
53
+ from shikiru.domain.steps import task_acting_role
54
+ from shikiru.domain.tasks import ACTIVE_STATUSES, Role, Status, Task
55
+ from shikiru.domain.timeline import (
56
+ TIMELINE_ACTIVITY_CATEGORY,
57
+ TIMELINE_ALL_CATEGORY,
58
+ TIMELINE_CATEGORIES,
59
+ TIMELINE_DECISION_CATEGORY,
60
+ TIMELINE_DECISION_KINDS,
61
+ TIMELINE_DEFAULT_CATEGORY,
62
+ TIMELINE_ENGINE_CATEGORY,
63
+ TIMELINE_JSON_INDENT,
64
+ TIMELINE_SOURCE_AGENT,
65
+ TIMELINE_SOURCE_ENGINE,
66
+ TIMELINE_TRANSITION_CATEGORY,
67
+ event_category,
68
+ )
69
+ from shikiru.domain.timeline_phrases import event_phrase
70
+ from shikiru.errors import ShikiruError
71
+ from shikiru.operator.timeline import (
72
+ TIMELINE_READ_ERROR_PREFIX,
73
+ SignalBatch,
74
+ TimelineCursor,
75
+ TimelineFilters,
76
+ TimelineItem,
77
+ decode_cursor,
78
+ encode_cursor,
79
+ event_category_is_requested,
80
+ initial_cursor,
81
+ read_signal_batch,
82
+ signal_category_is_requested,
83
+ )
84
+ from shikiru.paths import (
85
+ engine_log_path,
86
+ engine_output_path,
87
+ run_dir,
88
+ runs_root,
89
+ state_db_path,
90
+ task_root,
91
+ turns_dir,
92
+ )
93
+ from shikiru.store.database import format_timestamp
94
+ from shikiru.store.escalations import Escalation
95
+ from shikiru.telemetry.disk import directory_usage
96
+ from shikiru.view_log import ordered_turn_dirs, read_tail_lines, view_log_path
97
+ from shikiru.workspace.reports import (
98
+ ENGINE_VERDICT_FILENAME,
99
+ InvalidTurnReportError,
100
+ load_engine_verdict,
101
+ load_turn_report,
102
+ )
103
+
104
+ if TYPE_CHECKING:
105
+ from shikiru.clock import Clock
106
+ from shikiru.domain.escalation_evidence import EscalationPacket
107
+ from shikiru.process import ProcessIdentity
108
+ from shikiru.store.commands import StoredCommand
109
+ from shikiru.store.reader import StateReader
110
+ from shikiru.store.tasks import TaskRelationships
111
+
112
+ PEEK_TAIL_LINES = 60
113
+ TASK_DETAIL_EVENT_LIMIT = 40
114
+ ENGINE_UPTIME_EVENT_LIMIT = 2
115
+ ENGINE_LIFECYCLE_EVENTS = (EventKind.ENGINE_STARTED, EventKind.ENGINE_STOPPED)
116
+ ENGINE_REPORT_SOURCE = "engine"
117
+ AGENT_REPORT_SOURCE = "agent"
118
+ TASK_REPORT_ERROR = "could not read report evidence: {error}"
119
+ TASK_FACTS_ERROR = "could not read recorded git facts: {error}"
120
+ TASK_REPORT_TURN_MISSING = "No validated report or verdict is available for turn {turn}."
121
+ TASK_FACTS_TURN_MISSING = "No recorded git facts are available for turn {turn}."
122
+ TASK_ACTION_MISSING = "task no longer exists"
123
+ TASK_ACTION_TERMINAL = "task is terminal"
124
+ _COST_BASIS_BILLED = "billed"
125
+ _COST_BASIS_API_EQUIVALENT = "api_equivalent"
126
+ TASK_ACTION_ROLE_MISSING = "role pane is unavailable"
127
+ TASK_ACTION_TAKEN_OVER = "this role is already taken over"
128
+ TASK_ACTION_SESSION_MISSING = "this role has no live session to take over"
129
+ TASK_ACTION_NOT_TAKEN_OVER = "this role is not taken over"
130
+ TASK_ACTION_OTHER_ROLE_TAKEN_OVER = "hand back all taken-over roles before resuming"
131
+ TASK_ACTION_NO_HOLD = "task has no pause or ownership hold"
132
+ TASK_ACTION_NOT_QUEUED = "task can only be prioritized while queued"
133
+ TASK_ACTION_ROLES = (Role.LEADER, Role.IMPLEMENTER)
134
+ TASK_PANE_CONTEXT_STATUSES = (*ACTIVE_STATUSES, Status.QUEUED)
135
+ TASK_TARGET_SEARCH_CATEGORY = "all"
136
+ RECENT_COMMAND_HISTORY_LIMIT = 3
137
+ PROJECT_COMMAND_HISTORY_LIMIT = 50
138
+ ESCALATION_STATUS_MISSING = "task {task_id} is missing; {escalation_id} is stale."
139
+ ESCALATION_STATUS_STALE = "task {task_id} is {status}; {escalation_id} is stale."
140
+ ESCALATION_STATUS_RESOLVED = "{escalation_id} was resolved; task {task_id} is {status}."
141
+ ESCALATION_STATUS_NOT_OPEN = "escalation {escalation_id} is not open"
142
+
143
+
144
+ class OperatorTaskAction(StrEnum):
145
+ """A task action whose availability is projected from current operator-visible state."""
146
+
147
+ PRIORITIZE = "prioritize"
148
+ PAUSE = "pause"
149
+ PAUSE_NOW = "pause-now"
150
+ RESUME = "resume"
151
+ TAKE_OVER = "take-over"
152
+ HAND_BACK = "hand-back"
153
+ CANCEL = "cancel"
154
+ RETRY = "retry"
155
+ RUNG = "rung"
156
+ MESSAGE = "message"
157
+ PEEK = "peek"
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class TaskActionAvailability:
162
+ """The current availability and any disabled reason for one task action."""
163
+
164
+ action: OperatorTaskAction
165
+ disabled_reason: str | None
166
+
167
+ @property
168
+ def enabled(self) -> bool:
169
+ """Whether the current task state permits presenting this action as available."""
170
+ return self.disabled_reason is None
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class TaskTargetSearchPage:
175
+ """One bounded page of task targets and any selected target outside its search."""
176
+
177
+ tasks: tuple[TaskSummary, ...]
178
+ selected_task: TaskSummary | None
179
+ matching_count: int
180
+ project_count: int
181
+ has_more: bool
182
+
183
+
184
+ class OperatorView(BaseModel):
185
+ """Base model for JSON-safe operator views."""
186
+
187
+ model_config = ConfigDict(extra="forbid", frozen=True)
188
+
189
+
190
+ class CommandHistoryEntry(OperatorView):
191
+ """One durable command with its stored lifecycle state and returned result."""
192
+
193
+ id: int
194
+ action: str
195
+ target: str
196
+ status: CommandStatus
197
+ result: dict[str, object] | None
198
+ timestamp: str
199
+
200
+
201
+ class CommandHistory(OperatorView):
202
+ """A bounded newest-first command history and the engine's current liveness."""
203
+
204
+ engine_up: bool
205
+ entries: list[CommandHistoryEntry]
206
+ offset: int
207
+ limit: int
208
+ total_count: int
209
+ has_more: bool
210
+ status: CommandStatus | None
211
+
212
+
213
+ class TaskTimelineEntry(OperatorView):
214
+ """One engine event or normalized agent signal on a task's recorded timeline."""
215
+
216
+ identity: str
217
+ timestamp: str | None
218
+ timestamp_error: str | None
219
+ source: str
220
+ category: str
221
+ summary: str
222
+ phrase: str
223
+ glyph: str
224
+ tone: str
225
+ detail: str
226
+ role: str | None
227
+ turn: str | None
228
+ event_id: int | None
229
+ incomplete: bool
230
+
231
+
232
+ class TaskTimelinePage(OperatorView):
233
+ """One bounded newest-first evidence page and its stable older-page cursor."""
234
+
235
+ entries: list[TaskTimelineEntry]
236
+ older_cursor: str | None
237
+ has_older: bool
238
+ filters: dict[str, str | None]
239
+
240
+
241
+ class TaskSummary(OperatorView):
242
+ """The scheduling fields shown for one task in a queue or status view."""
243
+
244
+ id: str
245
+ title: str
246
+ status: Status
247
+ step: str | None
248
+ priority: int
249
+ wait_reason: str | None
250
+ holds: frozenset[str]
251
+ slot: int | None
252
+ parent_id: str | None
253
+ summary: str | None
254
+
255
+ @classmethod
256
+ def from_task(cls, task: Task) -> TaskSummary:
257
+ return cls(
258
+ id=task.id,
259
+ title=task.title,
260
+ status=task.status,
261
+ step=task.step.value if task.step is not None else None,
262
+ priority=task.priority,
263
+ wait_reason=task.wait_reason,
264
+ holds=frozenset(hold.value for hold in task.holds),
265
+ slot=task.slot,
266
+ parent_id=task.parent_id,
267
+ summary=task.summary,
268
+ )
269
+
270
+
271
+ class ReportRow(OperatorView):
272
+ """A turn's validated report in a task detail view."""
273
+
274
+ turn: str
275
+ kind: ReportKind
276
+ data: Report
277
+ source: str = AGENT_REPORT_SOURCE
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class TaskExportFiles:
282
+ """All retained validated report and facts files with source read errors."""
283
+
284
+ reports: tuple[ReportRow, ...]
285
+ facts: tuple[tuple[str, str], ...]
286
+ errors: tuple[tuple[str, str], ...]
287
+
288
+
289
+ class TaskDetail(OperatorView):
290
+ """A task with its runs and event timeline."""
291
+
292
+ task: Task
293
+ events: list[Event] = Field(default_factory=list)
294
+ reports: list[ReportRow] = Field(default_factory=list)
295
+ recorded_facts: str | None = None
296
+ facts_turn: str | None = None
297
+ evidence_errors: list[str] = Field(default_factory=list)
298
+ branches: list[str] = Field(default_factory=list)
299
+ sessions: list[Run] = Field(default_factory=list)
300
+
301
+
302
+ type TaskStaticFileEvidence = tuple[
303
+ list[ReportRow],
304
+ str | None,
305
+ str | None,
306
+ list[str],
307
+ ]
308
+ type FileIdentity = tuple[str, int | None, int | None, int | None, str | None]
309
+ type TaskFileSignature = tuple[FileIdentity, ...]
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class TaskFileCache:
314
+ task_id: str
315
+ data_version: int
316
+ roles: tuple[str, ...]
317
+ signature: TaskFileSignature
318
+ static_files: TaskStaticFileEvidence
319
+
320
+
321
+ @dataclass(frozen=True)
322
+ class PacketFileCache:
323
+ modified_ns: int
324
+ changed_ns: int
325
+ size: int
326
+ text: str
327
+
328
+
329
+ class EscalationResolutionView(OperatorView):
330
+ """One resolution the engine accepts for an escalation, as its packet lists it."""
331
+
332
+ kind: ResolutionKind
333
+ consequences: str
334
+ recommended: bool
335
+
336
+
337
+ class EscalationView(OperatorView):
338
+ """An escalation row, its packet text and the decision fields parsed from that packet."""
339
+
340
+ escalation: Escalation
341
+ packet: str | None
342
+ packet_error: str | None = None
343
+ question: str | None = None
344
+ agent_options: list[Option] = Field(default_factory=list)
345
+ resolutions: list[EscalationResolutionView] = Field(default_factory=list)
346
+
347
+
348
+ class PeekPane(OperatorView):
349
+ """One role's last message, usage and most recent view log lines."""
350
+
351
+ agent: str
352
+ last_message: str | None
353
+ usage: Usage
354
+ view: list[str]
355
+
356
+
357
+ class PeekView(OperatorView):
358
+ """The recorded state of one or more task roles, keyed by role."""
359
+
360
+ task_id: str
361
+ panes: dict[str, PeekPane]
362
+
363
+
364
+ class SlotView(OperatorView):
365
+ """The engine's configured and currently occupied task slots."""
366
+
367
+ active: int
368
+ maximum: int
369
+
370
+
371
+ class UsageView(OperatorView):
372
+ """Aggregated usage for one agent across recorded runs."""
373
+
374
+ agent: str
375
+ input_tokens: int
376
+ output_tokens: int
377
+ cache_read_tokens: int
378
+ cache_write_tokens: int
379
+ cost_usd: float | None
380
+ cost_basis: CostBasis | Literal["mixed"]
381
+ run_count: int = 0
382
+ usage_reported_runs: int = 0
383
+ legacy_token_runs: int = 0
384
+ billed_cost_usd: float | None = None
385
+ billed_cost_runs: int = 0
386
+ legacy_billed_cost_runs: int = 0
387
+ api_equivalent_cost_usd: float | None = None
388
+ api_equivalent_cost_runs: int = 0
389
+ legacy_api_equivalent_cost_runs: int = 0
390
+ unknown_cost_runs: int = 0
391
+
392
+
393
+ class RunDirectoryUsage(OperatorView):
394
+ """How many task run directories are kept and the bytes they take."""
395
+
396
+ directories: int
397
+ size_bytes: int
398
+
399
+
400
+ class StatusView(OperatorView):
401
+ """The status screen's engine, queue, task, escalation and usage views."""
402
+
403
+ project: str
404
+ engine_up: bool
405
+ slots: SlotView
406
+ active_tasks: list[TaskSummary]
407
+ queued_tasks: list[TaskSummary]
408
+ escalations: list[EscalationView]
409
+ usage: list[UsageView]
410
+ quotas: dict[str, list[QuotaWindow]]
411
+ faults: list[FaultStreak]
412
+ oldest_escalation_age_seconds: float | None
413
+ run_directories: RunDirectoryUsage
414
+ engine_output: str | None = None
415
+ task: TaskDetail | None = None
416
+
417
+
418
+ @dataclass(frozen=True)
419
+ class HumanStatusView:
420
+ """A status view with the bounded task rows selected for human project output."""
421
+
422
+ status: StatusView
423
+ task_rows: tuple[TaskSummary, ...]
424
+
425
+
426
+ StatusView.model_rebuild(_types_namespace={"datetime": datetime})
427
+
428
+
429
+ EngineState = Callable[[], bool]
430
+ EngineIdentity = Callable[[], "ProcessIdentity | None"]
431
+
432
+
433
+ class OperatorQueryError(ShikiruError):
434
+ """A requested operator view could not be read at its boundary."""
435
+
436
+
437
+ class TaskNotFoundError(OperatorQueryError):
438
+ """A requested task does not exist in the current project."""
439
+
440
+ def __init__(self, task_id: str, project_root: Path) -> None:
441
+ self.task_id = task_id
442
+ self.project_root = project_root
443
+ super().__init__(f"Task {task_id} was not found in this project.")
444
+
445
+
446
+ class OperatorQueries:
447
+ """Provide read-only status, task, escalation and pane views to operators."""
448
+
449
+ def __init__(
450
+ self,
451
+ reader: StateReader,
452
+ *,
453
+ project: str,
454
+ project_root: Path,
455
+ engine_up: EngineState,
456
+ engine_identity: EngineIdentity | None = None,
457
+ max_parallel: int,
458
+ clock: Clock,
459
+ ) -> None:
460
+ """Use `reader` and `engine_up` for the operator's read boundaries."""
461
+ self._reader = reader
462
+ self._project = project
463
+ self._project_root = project_root
464
+ self._engine_up = engine_up
465
+ self._engine_identity = engine_identity
466
+ self._max_parallel = max_parallel
467
+ self._clock = clock
468
+ self._task_detail_file_cache: TaskFileCache | None = None
469
+ self._escalation_packet_cache: dict[Path, PacketFileCache] = {}
470
+
471
+ def status(self, task_id: str | None = None) -> StatusView:
472
+ """Return the global status view, optionally including one task's detail."""
473
+ tasks = self._reader.list_tasks()
474
+ return self._build_status_view(tasks, task_id)
475
+
476
+ def human_status(self, task_id: str | None = None) -> HumanStatusView:
477
+ """Return status and bounded task rows from one project task snapshot."""
478
+ tasks = self._reader.list_tasks()
479
+ if task_id is not None and not any(task.id == task_id for task in tasks):
480
+ raise TaskNotFoundError(task_id, self._project_root)
481
+ status = self._build_status_view(tasks, task_id, human_task_detail=True)
482
+ task_rows = (
483
+ tuple(TaskSummary.from_task(task) for task in select_human_status_tasks(tasks))
484
+ if status.task is None
485
+ else ()
486
+ )
487
+ return HumanStatusView(status=status, task_rows=task_rows)
488
+
489
+ def _build_status_view(
490
+ self, tasks: list[Task], task_id: str | None, *, human_task_detail: bool = False
491
+ ) -> StatusView:
492
+ queued_tasks = sorted(
493
+ (task for task in tasks if task.status is Status.QUEUED), key=dispatch_sort_key
494
+ )
495
+ summaries = [TaskSummary.from_task(task) for task in tasks]
496
+ active = [summary for summary in summaries if summary.status in ACTIVE_STATUSES]
497
+ queued = [TaskSummary.from_task(task) for task in queued_tasks]
498
+ open_escalations = self.list_escalations()
499
+ usage, quotas = self.usage_and_quotas()
500
+ task_detail = (
501
+ self._human_task_detail(task_id)
502
+ if task_id is not None and human_task_detail
503
+ else self.get_task(task_id)
504
+ if task_id is not None
505
+ else None
506
+ )
507
+ return StatusView(
508
+ project=self._project,
509
+ engine_up=self._engine_up(),
510
+ slots=SlotView(active=len(occupied_slot_ids(tasks)), maximum=self._max_parallel),
511
+ active_tasks=active,
512
+ queued_tasks=queued,
513
+ escalations=open_escalations,
514
+ usage=usage,
515
+ quotas=quotas,
516
+ faults=self.open_faults(),
517
+ oldest_escalation_age_seconds=self.oldest_escalation_age_seconds(),
518
+ run_directories=self.run_directory_usage(),
519
+ engine_output=self._engine_output(),
520
+ task=task_detail,
521
+ )
522
+
523
+ def _human_task_detail(self, task_id: str) -> TaskDetail | None:
524
+ task = self._reader.get_task(task_id)
525
+ if task is None:
526
+ return None
527
+ runs = self._reader.list_runs(task_id)
528
+ turn_dirs, evidence_errors = self._task_turn_dirs(task_id)
529
+ reports: list[ReportRow] = []
530
+ recorded_facts = None
531
+ facts_turn = None
532
+ if turn_dirs is not None:
533
+ for turn_dir in turn_dirs:
534
+ turn_reports, turn_errors = _task_turn_reports(turn_dir)
535
+ reports.extend(turn_reports)
536
+ evidence_errors.extend(turn_errors)
537
+ recorded_facts, facts_turn, fact_errors = _task_facts(turn_dirs)
538
+ evidence_errors.extend(fact_errors)
539
+ return TaskDetail(
540
+ task=task,
541
+ events=self._reader.events_since(0, task_id=task_id),
542
+ reports=reports,
543
+ recorded_facts=recorded_facts,
544
+ facts_turn=facts_turn,
545
+ evidence_errors=evidence_errors,
546
+ branches=[task.repo.branch] if task.repo is not None else [],
547
+ sessions=runs,
548
+ )
549
+
550
+ def engine_is_up(self) -> bool:
551
+ """Return whether this project's engine currently holds its lock."""
552
+ return self._engine_up()
553
+
554
+ def current_engine_uptime_seconds(self) -> float | None:
555
+ """Return current uptime only when the verified lock identity matches start evidence."""
556
+ process_identity = self._engine_identity() if self._engine_identity is not None else None
557
+ identity = (
558
+ (process_identity.pid, process_identity.started_at)
559
+ if process_identity is not None and process_identity.started_at is not None
560
+ else None
561
+ )
562
+ projection = project_engine_uptime(
563
+ self._reader.latest_events(ENGINE_UPTIME_EVENT_LIMIT, kinds=ENGINE_LIFECYCLE_EVENTS),
564
+ now=self._clock.now(),
565
+ running=process_identity is not None,
566
+ identity=identity,
567
+ )
568
+ return projection.running_seconds
569
+
570
+ def _engine_output(self) -> str | None:
571
+ path = engine_output_path(self._project_root)
572
+ if self._engine_up() or not path.is_file() or path.stat().st_size == 0:
573
+ return None
574
+ return str(path)
575
+
576
+ def run_directory_usage(self) -> RunDirectoryUsage:
577
+ """Return the number and total size of the task run directories under the project."""
578
+ usage = directory_usage(runs_root(self._project_root))
579
+ return RunDirectoryUsage(directories=usage.directories, size_bytes=usage.size_bytes)
580
+
581
+ def open_faults(self) -> list[FaultStreak]:
582
+ """Return the engine fault streaks recorded and not yet recovered."""
583
+ return open_fault_streaks(
584
+ self._reader.events_since(0, kinds=[EventKind.ENGINE_FAULT, EventKind.ENGINE_RECOVERED])
585
+ )
586
+
587
+ def engine_log_location(self) -> str:
588
+ """Return the path of the engine log, which holds the traceback of each logged fault."""
589
+ return str(engine_log_path(self._project_root))
590
+
591
+ def state_database_location(self) -> str:
592
+ """Return the path of the state database, which records each engine fault event."""
593
+ return str(state_db_path(self._project_root))
594
+
595
+ def oldest_escalation_age_seconds(self) -> float | None:
596
+ """Return the age of the oldest unresolved escalation, or `None` when none is open."""
597
+ created = [item.created_at for item in self._reader.list_open_escalations()]
598
+ return (self._clock.now() - min(created)).total_seconds() if created else None
599
+
600
+ def usage_and_quotas(self) -> tuple[list[UsageView], dict[str, list[QuotaWindow]]]:
601
+ """Return shared usage totals and provider-reported quota windows."""
602
+ runs = self._reader.list_all_runs()
603
+ agents = sorted({run.agent for run in runs} | set(self._reader.list_quota_agents()))
604
+ return aggregate_usage(runs), self._reader.list_quotas(agents)
605
+
606
+ def task_usage(self, task_id: str) -> list[UsageView]:
607
+ """Return agent-reported usage totals for one task's sessions."""
608
+ return aggregate_usage(self._reader.list_runs(task_id))
609
+
610
+ def task_runs(self, task_id: str) -> tuple[Run, ...]:
611
+ """Return every persisted session row for one task in recorded order."""
612
+ return tuple(self._reader.list_runs(task_id))
613
+
614
+ def task_relationships(
615
+ self,
616
+ task_id: str,
617
+ *,
618
+ offset: int = 0,
619
+ ) -> TaskRelationships | None:
620
+ """Return one bounded page of stored relationships for the selected task."""
621
+ return self._reader.task_relationships(task_id, offset=offset)
622
+
623
+ async def task_export_files(self, task_id: str) -> TaskExportFiles:
624
+ """Load all retained validated reports and facts away from the UI event loop."""
625
+ return await asyncio.to_thread(self._task_all_export_files, task_id)
626
+
627
+ def list_tasks(self, *, statuses: Sequence[Status] | None = None) -> list[TaskSummary]:
628
+ """Return task summaries ordered by their allocated task sequence."""
629
+ return [TaskSummary.from_task(task) for task in self._reader.list_tasks(statuses=statuses)]
630
+
631
+ def command_history(
632
+ self,
633
+ *,
634
+ expanded: bool = False,
635
+ limit: int | None = None,
636
+ offset: int = 0,
637
+ status: CommandStatus | None = None,
638
+ ) -> CommandHistory:
639
+ """Return one bounded newest-first page, preserving stored command outcomes."""
640
+ default_limit = PROJECT_COMMAND_HISTORY_LIMIT if expanded else RECENT_COMMAND_HISTORY_LIMIT
641
+ page_limit = default_limit if limit is None else limit
642
+ page = self._reader.command_history(limit=page_limit, offset=offset, status=status)
643
+ return CommandHistory(
644
+ engine_up=self._engine_up(),
645
+ entries=[self._command_entry(row) for row in page.entries],
646
+ offset=page.offset,
647
+ limit=page.limit,
648
+ total_count=page.total_count,
649
+ has_more=page.has_more,
650
+ status=status,
651
+ )
652
+
653
+ def command_status(self, command_id: int) -> CommandHistoryEntry | None:
654
+ """Read one durable command status by id without creating or acknowledging it."""
655
+ row = self._reader.command_status(command_id)
656
+ return self._command_entry(row) if row is not None else None
657
+
658
+ def pending_command(self, command: Command) -> CommandHistoryEntry | None:
659
+ """Find an identical pending command without writing or acknowledging it."""
660
+ row = self._reader.pending_command(command)
661
+ return self._command_entry(row) if row is not None else None
662
+
663
+ def _command_entry(self, row: StoredCommand) -> CommandHistoryEntry:
664
+ return CommandHistoryEntry(
665
+ id=row.id,
666
+ action=row.command.kind.value.replace("_", " ").capitalize(),
667
+ target=_command_target(row.command, self._project),
668
+ status=row.status,
669
+ result=row.result,
670
+ timestamp=row.ts,
671
+ )
672
+
673
+ def search_task_targets(
674
+ self,
675
+ search: str,
676
+ *,
677
+ limit: int,
678
+ offset: int,
679
+ selected_task_id: str | None = None,
680
+ ) -> TaskTargetSearchPage:
681
+ """Read a bounded ID/title target page and retain a selected target outside the match."""
682
+ page = self._reader.task_browser_page(
683
+ category=TASK_TARGET_SEARCH_CATEGORY,
684
+ search=search,
685
+ limit=limit,
686
+ offset=offset,
687
+ )
688
+ tasks = tuple(TaskSummary.from_task(task) for task in page.tasks)
689
+ selected_task = None
690
+ if selected_task_id is not None and all(task.id != selected_task_id for task in tasks):
691
+ task = self._reader.get_task(selected_task_id)
692
+ if task is not None:
693
+ selected_task = TaskSummary.from_task(task)
694
+ return TaskTargetSearchPage(
695
+ tasks=tasks,
696
+ selected_task=selected_task,
697
+ matching_count=page.matching_count,
698
+ project_count=page.project_count,
699
+ has_more=offset + len(tasks) < page.matching_count,
700
+ )
701
+
702
+ def task_action_role(self, task_id: str) -> Role:
703
+ """Return the role at this task's current or first scheduled step."""
704
+ task = self._reader.get_task(task_id)
705
+ if task is None:
706
+ return Role.LEADER
707
+ return task_acting_role(task)
708
+
709
+ def get_task(self, task_id: str) -> TaskDetail | None:
710
+ """Return one task, its runs and its event timeline, or `None` when absent."""
711
+ task = self._reader.get_task(task_id)
712
+ if task is None:
713
+ return None
714
+ runs = self._reader.list_runs(task_id)
715
+ return TaskDetail(
716
+ task=task,
717
+ events=self._reader.events_since(0, task_id=task_id),
718
+ reports=self._reports(task_id),
719
+ branches=[task.repo.branch] if task.repo is not None else [],
720
+ sessions=runs,
721
+ )
722
+
723
+ async def get_task_detail(self, task_id: str) -> TaskDetail | None:
724
+ """Read bounded task events and load its file evidence away from the UI loop."""
725
+ task = self._reader.get_task(task_id)
726
+ if task is None:
727
+ return None
728
+ runs = self._reader.list_runs(task_id)
729
+ events = self._reader.latest_events(TASK_DETAIL_EVENT_LIMIT, task_id=task_id)
730
+ roles = tuple(dict.fromkeys(run.role.value for run in runs))
731
+ data_version = self._reader.data_version()
732
+ cached = self._task_detail_file_cache
733
+ signature = cached.signature if cached is not None else ()
734
+ if cached is not None and cached.task_id == task_id and cached.roles == roles:
735
+ if cached.data_version != data_version:
736
+ signature = await asyncio.to_thread(self._task_file_signature, task_id)
737
+ if signature == cached.signature:
738
+ static_files = cached.static_files
739
+ self._task_detail_file_cache = TaskFileCache(
740
+ task_id,
741
+ data_version,
742
+ roles,
743
+ signature,
744
+ static_files,
745
+ )
746
+ else:
747
+ signature, static_files = await asyncio.to_thread(
748
+ self._task_static_files_with_signature, task_id
749
+ )
750
+ self._task_detail_file_cache = TaskFileCache(
751
+ task_id,
752
+ data_version,
753
+ roles,
754
+ signature,
755
+ static_files,
756
+ )
757
+ else:
758
+ signature, static_files = await asyncio.to_thread(
759
+ self._task_static_files_with_signature, task_id
760
+ )
761
+ self._task_detail_file_cache = TaskFileCache(
762
+ task_id,
763
+ data_version,
764
+ roles,
765
+ signature,
766
+ static_files,
767
+ )
768
+ reports, recorded_facts, facts_turn, evidence_errors = static_files
769
+ return TaskDetail(
770
+ task=task,
771
+ events=events,
772
+ reports=reports,
773
+ recorded_facts=recorded_facts,
774
+ facts_turn=facts_turn,
775
+ evidence_errors=evidence_errors,
776
+ branches=[task.repo.branch] if task.repo is not None else [],
777
+ sessions=runs,
778
+ )
779
+
780
+ async def task_timeline(
781
+ self,
782
+ task_id: str,
783
+ *,
784
+ limit: int,
785
+ cursor: str | None = None,
786
+ role: str | None = None,
787
+ turn: str | None = None,
788
+ category: str = TIMELINE_DEFAULT_CATEGORY,
789
+ scan_limit: int,
790
+ ) -> TaskTimelinePage | None:
791
+ """Read a bounded, stable page across task events and normalized signal files."""
792
+ if not 0 < limit <= MAX_TIMELINE_PAGE_SIZE:
793
+ message = f"task evidence page size must be between 1 and {MAX_TIMELINE_PAGE_SIZE}"
794
+ raise ValueError(message)
795
+ if not 0 < scan_limit <= MAX_TIMELINE_SIGNAL_SCAN_ROWS:
796
+ message = (
797
+ "task evidence source scan size must be between 1 and "
798
+ f"{MAX_TIMELINE_SIGNAL_SCAN_ROWS}"
799
+ )
800
+ raise ValueError(message)
801
+ if category not in TIMELINE_CATEGORIES:
802
+ message = f"unknown task evidence category: {category}"
803
+ raise ValueError(message)
804
+ task = self._reader.get_task(task_id)
805
+ if task is None:
806
+ return None
807
+ filters = TimelineFilters(role=role, turn=turn, category=category)
808
+ roles = _timeline_roles(self._reader.list_runs(task_id), role)
809
+ paths = {item: view_log_path(run_dir(self._project_root, task_id), item) for item in roles}
810
+ current = await self._timeline_cursor(cursor, filters, roles, paths)
811
+ if cursor is not None:
812
+ roles = tuple(sorted(current.signal_before))
813
+ event_rows = self._timeline_events(
814
+ task_id, limit, current, role=role, turn=turn, category=category
815
+ )
816
+ signal_batches = await self._timeline_signals(
817
+ paths=paths,
818
+ roles=roles,
819
+ limit=limit,
820
+ cursor=current,
821
+ filters=filters,
822
+ scan_limit=scan_limit,
823
+ )
824
+ return _build_timeline_page(
825
+ limit=limit,
826
+ current=current,
827
+ filters=filters,
828
+ event_rows=event_rows,
829
+ signal_batches=signal_batches,
830
+ )
831
+
832
+ async def _timeline_cursor(
833
+ self,
834
+ token: str | None,
835
+ filters: TimelineFilters,
836
+ roles: Sequence[str],
837
+ paths: dict[str, Path],
838
+ ) -> TimelineCursor:
839
+ if token is None:
840
+ return await asyncio.to_thread(initial_cursor, paths)
841
+ return decode_cursor(token, filters=filters, roles=roles)
842
+
843
+ def _timeline_events(
844
+ self,
845
+ task_id: str,
846
+ limit: int,
847
+ cursor: TimelineCursor,
848
+ *,
849
+ role: str | None,
850
+ turn: str | None,
851
+ category: str,
852
+ ) -> list[Event]:
853
+ if not event_category_is_requested(category):
854
+ return []
855
+ return self._reader.task_timeline_events(
856
+ task_id,
857
+ limit=limit + 1,
858
+ before=cursor.event_before,
859
+ kinds=_timeline_event_kinds(category),
860
+ role=role,
861
+ turn=turn,
862
+ )
863
+
864
+ async def _timeline_signals(
865
+ self,
866
+ *,
867
+ paths: dict[str, Path],
868
+ roles: Sequence[str],
869
+ limit: int,
870
+ cursor: TimelineCursor,
871
+ filters: TimelineFilters,
872
+ scan_limit: int,
873
+ ) -> dict[str, SignalBatch]:
874
+ if not signal_category_is_requested(filters.category):
875
+ return {}
876
+ loaded = await asyncio.gather(
877
+ *(
878
+ asyncio.to_thread(
879
+ read_signal_batch,
880
+ paths[item],
881
+ role=item,
882
+ before_offset=cursor.signal_before[item],
883
+ filters=filters,
884
+ limit=limit + 1,
885
+ scan_limit=scan_limit,
886
+ )
887
+ for item in roles
888
+ )
889
+ )
890
+ return dict(zip(roles, loaded, strict=True))
891
+
892
+ def get_task_state(self, task_id: str) -> Task | None:
893
+ """Return current task state without loading report or signal files."""
894
+ return self._reader.get_task(task_id)
895
+
896
+ def task_action_availability(
897
+ self, task_id: str, role: Role | None
898
+ ) -> tuple[TaskActionAvailability, ...]:
899
+ """Project task command availability and disabled reasons from current state."""
900
+ task = self._reader.get_task(task_id)
901
+ if task is None:
902
+ return tuple(
903
+ TaskActionAvailability(action, TASK_ACTION_MISSING) for action in OperatorTaskAction
904
+ )
905
+ runs = self._reader.list_runs(task_id)
906
+ roles = (role,) if role is not None else TASK_ACTION_ROLES
907
+ role_states = tuple(self._task_action_reasons(task, runs, candidate) for candidate in roles)
908
+ if role is None:
909
+ return tuple(
910
+ TaskActionAvailability(
911
+ action,
912
+ next(
913
+ (states[action] for states in role_states if states[action] is None),
914
+ role_states[0][action],
915
+ ),
916
+ )
917
+ for action in OperatorTaskAction
918
+ )
919
+ return tuple(
920
+ TaskActionAvailability(action, role_states[0][action]) for action in OperatorTaskAction
921
+ )
922
+
923
+ def _task_action_reasons(
924
+ self, task: Task, runs: Sequence[Run], role: Role
925
+ ) -> dict[OperatorTaskAction, str | None]:
926
+ role_is_taken_over = any(
927
+ run.role is role and run.status is RunStatus.TAKEN_OVER for run in runs
928
+ )
929
+ any_role_taken_over = any(run.status is RunStatus.TAKEN_OVER for run in runs)
930
+ role_has_live_session = any(
931
+ run.role is role and run.status is RunStatus.LIVE and run.session_id is not None
932
+ for run in runs
933
+ )
934
+ terminal = task.status in (Status.DONE, Status.CANCELLED)
935
+ pane_missing = role not in task.panes
936
+ return {
937
+ OperatorTaskAction.PRIORITIZE: (
938
+ None if task.status is Status.QUEUED else TASK_ACTION_NOT_QUEUED
939
+ ),
940
+ OperatorTaskAction.PAUSE: TASK_ACTION_TERMINAL if terminal else None,
941
+ OperatorTaskAction.PAUSE_NOW: TASK_ACTION_TERMINAL if terminal else None,
942
+ OperatorTaskAction.RESUME: (
943
+ TASK_ACTION_TERMINAL
944
+ if terminal
945
+ else TASK_ACTION_OTHER_ROLE_TAKEN_OVER
946
+ if any_role_taken_over
947
+ else TASK_ACTION_NO_HOLD
948
+ if not task.holds
949
+ else None
950
+ ),
951
+ OperatorTaskAction.TAKE_OVER: (
952
+ TASK_ACTION_TAKEN_OVER
953
+ if role_is_taken_over
954
+ else TASK_ACTION_TERMINAL
955
+ if terminal
956
+ else TASK_ACTION_ROLE_MISSING
957
+ if pane_missing
958
+ else TASK_ACTION_SESSION_MISSING
959
+ if not role_has_live_session
960
+ else None
961
+ ),
962
+ OperatorTaskAction.HAND_BACK: (
963
+ TASK_ACTION_NOT_TAKEN_OVER
964
+ if not role_is_taken_over
965
+ else TASK_ACTION_ROLE_MISSING
966
+ if pane_missing
967
+ else None
968
+ ),
969
+ OperatorTaskAction.CANCEL: TASK_ACTION_TERMINAL if terminal else None,
970
+ OperatorTaskAction.RETRY: TASK_ACTION_TERMINAL if terminal else None,
971
+ OperatorTaskAction.RUNG: TASK_ACTION_TERMINAL if terminal else None,
972
+ OperatorTaskAction.MESSAGE: TASK_ACTION_TERMINAL if terminal else None,
973
+ OperatorTaskAction.PEEK: None,
974
+ }
975
+
976
+ def task_role_for_pane(self, pane_id: str) -> tuple[str, Role] | None:
977
+ """Return the recorded task and role whose pane matches an invoking tmux pane."""
978
+ for task in self._reader.list_tasks(statuses=TASK_PANE_CONTEXT_STATUSES):
979
+ for role, recorded_pane in task.panes.items():
980
+ if recorded_pane == pane_id:
981
+ return task.id, role
982
+ return None
983
+
984
+ def task_worktree_path(self, task_id: str) -> Path:
985
+ """Return the recorded layout location used for this task's worktree."""
986
+ return task_root(self._project_root, task_id)
987
+
988
+ def list_escalations(self) -> list[EscalationView]:
989
+ """Return every durable open decision with packet text or its read error."""
990
+ escalations = self._reader.list_open_escalations()
991
+ escalations.sort(key=lambda item: (item.created_at, item.id))
992
+ packet_paths = {self._packet_path(item) for item in escalations}
993
+ self._escalation_packet_cache = {
994
+ path: cached
995
+ for path, cached in self._escalation_packet_cache.items()
996
+ if path in packet_paths
997
+ }
998
+ return [self._escalation_view(escalation) for escalation in escalations]
999
+
1000
+ def get_escalation(self, escalation_id: str) -> EscalationView | None:
1001
+ """Return one escalation and packet text, or `None` when absent."""
1002
+ escalation = self._reader.get_escalation(escalation_id)
1003
+ if escalation is None:
1004
+ return None
1005
+ return self._escalation_view(escalation)
1006
+
1007
+ def escalation_resolution_disabled_reason(self, escalation_id: str) -> str | None:
1008
+ """Project whether current durable escalation and task state allow resolution."""
1009
+ escalation = self._reader.get_escalation(escalation_id)
1010
+ if escalation is None:
1011
+ return ESCALATION_STATUS_NOT_OPEN.format(escalation_id=escalation_id)
1012
+ task = self._reader.get_task(escalation.task_id)
1013
+ task_state = task.status.value if task is not None else "missing"
1014
+ if escalation.status is not EscalationStatus.OPEN:
1015
+ return ESCALATION_STATUS_RESOLVED.format(
1016
+ escalation_id=escalation.id,
1017
+ task_id=escalation.task_id,
1018
+ status=task_state,
1019
+ )
1020
+ if task is None:
1021
+ return ESCALATION_STATUS_MISSING.format(
1022
+ task_id=escalation.task_id,
1023
+ escalation_id=escalation.id,
1024
+ )
1025
+ if task.status is not Status.ESCALATED:
1026
+ return ESCALATION_STATUS_STALE.format(
1027
+ task_id=escalation.task_id,
1028
+ status=task_state,
1029
+ escalation_id=escalation.id,
1030
+ )
1031
+ return None
1032
+
1033
+ def _escalation_view(self, escalation: Escalation) -> EscalationView:
1034
+ try:
1035
+ packet = self._packet_text(escalation)
1036
+ except OperatorQueryError as error:
1037
+ return EscalationView(
1038
+ escalation=escalation,
1039
+ packet=None,
1040
+ packet_error=str(error),
1041
+ )
1042
+ parsed = parse_escalation_packet(packet)
1043
+ request = parsed.agent_request
1044
+ return EscalationView(
1045
+ escalation=escalation,
1046
+ packet=packet,
1047
+ question=_escalation_question(parsed),
1048
+ agent_options=request.options if request is not None else [],
1049
+ resolutions=_escalation_resolutions(parsed, escalation),
1050
+ )
1051
+
1052
+ def peek_session(self, task_id: str, role: Role | None = None) -> PeekView | None:
1053
+ """Return the last message, usage and recent view lines of one or every task role."""
1054
+ task = self._reader.get_task(task_id)
1055
+ if task is None:
1056
+ return None
1057
+ latest = {run.role: run for run in reversed(self._reader.list_runs(task_id))}
1058
+ roles = [role] if role is not None else list(latest)
1059
+ if not roles:
1060
+ message = f"{task_id} has no agent sessions"
1061
+ raise OperatorQueryError(message)
1062
+ panes: dict[str, PeekPane] = {}
1063
+ for peek_role in roles:
1064
+ run = latest.get(peek_role)
1065
+ if run is None:
1066
+ message = f"{task_id} has no {peek_role.value} session"
1067
+ raise OperatorQueryError(message)
1068
+ panes[peek_role.value] = PeekPane(
1069
+ agent=run.agent,
1070
+ last_message=run.last_message,
1071
+ usage=run.usage,
1072
+ view=_view_tail(view_log_path(run_dir(self._project_root, task_id), run.role)),
1073
+ )
1074
+ return PeekView(task_id=task_id, panes=panes)
1075
+
1076
+ def _packet_text(self, escalation: Escalation) -> str:
1077
+ packet_path = self._packet_path(escalation)
1078
+ try:
1079
+ info = packet_path.stat()
1080
+ except FileNotFoundError:
1081
+ message = f"escalation packet is missing: {packet_path}"
1082
+ raise OperatorQueryError(message) from None
1083
+ except OSError as error:
1084
+ message = f"cannot inspect escalation packet {packet_path}: {error}"
1085
+ raise OperatorQueryError(message) from error
1086
+ cached = self._escalation_packet_cache.get(packet_path)
1087
+ if (
1088
+ cached is not None
1089
+ and cached.modified_ns == info.st_mtime_ns
1090
+ and cached.changed_ns == info.st_ctime_ns
1091
+ and cached.size == info.st_size
1092
+ ):
1093
+ return cached.text
1094
+ try:
1095
+ packet = packet_path.read_text()
1096
+ except (OSError, UnicodeError) as error:
1097
+ message = f"cannot read escalation packet {packet_path}: {error}"
1098
+ raise OperatorQueryError(message) from error
1099
+ self._escalation_packet_cache[packet_path] = PacketFileCache(
1100
+ info.st_mtime_ns,
1101
+ info.st_ctime_ns,
1102
+ info.st_size,
1103
+ packet,
1104
+ )
1105
+ return packet
1106
+
1107
+ def _packet_path(self, escalation: Escalation) -> Path:
1108
+ packet_path = Path(escalation.packet_path)
1109
+ return packet_path if packet_path.is_absolute() else self._project_root / packet_path
1110
+
1111
+ def _reports(self, task_id: str) -> list[ReportRow]:
1112
+ task_turns_dir = turns_dir(self._project_root, task_id)
1113
+ if not task_turns_dir.is_dir():
1114
+ return []
1115
+ reports: list[ReportRow] = []
1116
+ for turn_dir in ordered_turn_dirs(task_turns_dir):
1117
+ loaded = load_turn_report(turn_dir)
1118
+ if loaded is not None:
1119
+ reports.append(
1120
+ ReportRow(
1121
+ turn=turn_dir.name,
1122
+ kind=loaded.kind,
1123
+ data=loaded.report,
1124
+ )
1125
+ )
1126
+ return reports
1127
+
1128
+ def _task_static_files_with_signature(
1129
+ self,
1130
+ task_id: str,
1131
+ ) -> tuple[TaskFileSignature, TaskStaticFileEvidence]:
1132
+ return self._task_file_signature(task_id), self._task_static_files(task_id)
1133
+
1134
+ def _task_file_signature(self, task_id: str) -> TaskFileSignature:
1135
+ task_turns_dir = turns_dir(self._project_root, task_id)
1136
+ turn_dirs, errors = self._task_turn_dirs(task_id)
1137
+ if turn_dirs is None:
1138
+ return ((str(task_turns_dir), None, None, None, "; ".join(errors)),)
1139
+ filenames = (
1140
+ FACTS_FILENAME,
1141
+ REPORT_KIND_FILENAME,
1142
+ REPORT_FILENAME,
1143
+ HELPER_REPORT_FILENAME,
1144
+ ENGINE_VERDICT_FILENAME,
1145
+ )
1146
+ signature: list[FileIdentity] = []
1147
+ for turn_dir in turn_dirs:
1148
+ for filename in filenames:
1149
+ path = turn_dir / filename
1150
+ try:
1151
+ info = path.stat()
1152
+ except FileNotFoundError:
1153
+ signature.append((str(path), None, None, None, None))
1154
+ except OSError as error:
1155
+ signature.append((str(path), None, None, None, str(error)))
1156
+ else:
1157
+ signature.append(
1158
+ (
1159
+ str(path),
1160
+ info.st_mtime_ns,
1161
+ info.st_ctime_ns,
1162
+ info.st_size,
1163
+ None,
1164
+ )
1165
+ )
1166
+ return tuple(signature)
1167
+
1168
+ def _task_static_files(self, task_id: str) -> TaskStaticFileEvidence:
1169
+ turn_dirs, errors = self._task_turn_dirs(task_id)
1170
+ if turn_dirs is None:
1171
+ return [], None, None, errors
1172
+ reports, report_errors = self._task_reports(turn_dirs)
1173
+ recorded_facts, facts_turn, fact_errors = _task_facts(turn_dirs)
1174
+ return reports, recorded_facts, facts_turn, [*errors, *report_errors, *fact_errors]
1175
+
1176
+ def _task_all_export_files(self, task_id: str) -> TaskExportFiles:
1177
+ turn_dirs, directory_errors = self._task_turn_dirs(task_id)
1178
+ errors = [("reports", error) for error in directory_errors]
1179
+ if turn_dirs is None:
1180
+ return TaskExportFiles(reports=(), facts=(), errors=tuple(errors))
1181
+ reports: list[ReportRow] = []
1182
+ facts: list[tuple[str, str]] = []
1183
+ missing_report_turns: list[str] = []
1184
+ missing_fact_turns: list[str] = []
1185
+ for turn_dir in turn_dirs:
1186
+ turn_reports, report_errors = _task_turn_reports(turn_dir)
1187
+ reports.extend(turn_reports)
1188
+ errors.extend(("reports", error) for error in report_errors)
1189
+ if not turn_reports and not report_errors:
1190
+ missing_report_turns.append(turn_dir.name)
1191
+ facts_path = turn_dir / FACTS_FILENAME
1192
+ try:
1193
+ facts_content = facts_path.read_text()
1194
+ except FileNotFoundError:
1195
+ missing_fact_turns.append(turn_dir.name)
1196
+ continue
1197
+ except (OSError, UnicodeError) as error:
1198
+ errors.append(("facts", TASK_FACTS_ERROR.format(error=error)))
1199
+ else:
1200
+ facts.append((turn_dir.name, facts_content))
1201
+ if reports:
1202
+ errors.extend(
1203
+ ("reports", TASK_REPORT_TURN_MISSING.format(turn=turn))
1204
+ for turn in missing_report_turns
1205
+ )
1206
+ if facts:
1207
+ errors.extend(
1208
+ ("facts", TASK_FACTS_TURN_MISSING.format(turn=turn)) for turn in missing_fact_turns
1209
+ )
1210
+ return TaskExportFiles(
1211
+ reports=tuple(reports),
1212
+ facts=tuple(facts),
1213
+ errors=tuple(errors),
1214
+ )
1215
+
1216
+ def _task_turn_dirs(self, task_id: str) -> tuple[list[Path] | None, list[str]]:
1217
+ task_turns_dir = turns_dir(self._project_root, task_id)
1218
+ try:
1219
+ task_turns_dir.stat()
1220
+ except FileNotFoundError:
1221
+ return None, []
1222
+ except OSError as error:
1223
+ return None, [TASK_REPORT_ERROR.format(error=error)]
1224
+ if not task_turns_dir.is_dir():
1225
+ return None, [TASK_REPORT_ERROR.format(error=f"not a directory: {task_turns_dir}")]
1226
+ try:
1227
+ return ordered_turn_dirs(task_turns_dir), []
1228
+ except (OSError, ValueError) as error:
1229
+ return None, [TASK_REPORT_ERROR.format(error=error)]
1230
+
1231
+ def _task_reports(self, turn_dirs: Sequence[Path]) -> tuple[list[ReportRow], list[str]]:
1232
+ latest_agent: ReportRow | None = None
1233
+ latest_verdict: ReportRow | None = None
1234
+ latest_triage: ReportRow | None = None
1235
+ errors: list[str] = []
1236
+ for turn_dir in reversed(turn_dirs):
1237
+ turn_reports, turn_errors = _task_turn_reports(turn_dir)
1238
+ errors.extend(turn_errors)
1239
+ for report in turn_reports:
1240
+ if report.source == AGENT_REPORT_SOURCE and latest_agent is None:
1241
+ latest_agent = report
1242
+ if report.kind is ReportKind.TRIAGE and latest_triage is None:
1243
+ latest_triage = report
1244
+ if latest_verdict is None:
1245
+ latest_verdict = next(
1246
+ (
1247
+ report
1248
+ for report in turn_reports
1249
+ if report.source == ENGINE_REPORT_SOURCE
1250
+ and report.kind is ReportKind.VERDICT
1251
+ ),
1252
+ None,
1253
+ ) or next(
1254
+ (
1255
+ report
1256
+ for report in turn_reports
1257
+ if report.source == AGENT_REPORT_SOURCE
1258
+ and report.kind is ReportKind.VERDICT
1259
+ ),
1260
+ None,
1261
+ )
1262
+ if (
1263
+ latest_agent is not None
1264
+ and latest_verdict is not None
1265
+ and latest_triage is not None
1266
+ ):
1267
+ break
1268
+ turn_order = {turn_dir.name: index for index, turn_dir in enumerate(turn_dirs)}
1269
+ reports = {
1270
+ (report.turn, report.kind, report.source): report
1271
+ for report in (latest_agent, latest_verdict, latest_triage)
1272
+ if report is not None
1273
+ }
1274
+ return sorted(reports.values(), key=lambda report: turn_order[report.turn]), errors
1275
+
1276
+
1277
+ def _task_turn_reports(turn_dir: Path) -> tuple[list[ReportRow], list[str]]:
1278
+ reports: list[ReportRow] = []
1279
+ errors: list[str] = []
1280
+ try:
1281
+ loaded = load_turn_report(turn_dir)
1282
+ except (InvalidTurnReportError, OSError, ValueError) as error:
1283
+ errors.append(str(error))
1284
+ else:
1285
+ if loaded is not None:
1286
+ reports.append(
1287
+ ReportRow(
1288
+ turn=turn_dir.name,
1289
+ kind=loaded.kind,
1290
+ data=loaded.report,
1291
+ source=AGENT_REPORT_SOURCE,
1292
+ )
1293
+ )
1294
+ try:
1295
+ verdict = load_engine_verdict(turn_dir)
1296
+ except (InvalidTurnReportError, OSError, ValueError) as error:
1297
+ errors.append(str(error))
1298
+ else:
1299
+ if verdict is not None:
1300
+ reports.append(
1301
+ ReportRow(
1302
+ turn=turn_dir.name,
1303
+ kind=(
1304
+ ReportKind.FEEDBACK_VERDICT
1305
+ if isinstance(verdict, FeedbackVerdict)
1306
+ else ReportKind.VERDICT
1307
+ ),
1308
+ data=verdict,
1309
+ source=ENGINE_REPORT_SOURCE,
1310
+ )
1311
+ )
1312
+ return reports, errors
1313
+
1314
+
1315
+ def _task_facts(turn_dirs: Sequence[Path]) -> tuple[str | None, str | None, list[str]]:
1316
+ for turn_dir in reversed(turn_dirs):
1317
+ facts_path = turn_dir / FACTS_FILENAME
1318
+ try:
1319
+ facts = facts_path.read_text()
1320
+ except FileNotFoundError:
1321
+ continue
1322
+ except (OSError, UnicodeError) as error:
1323
+ return None, None, [TASK_FACTS_ERROR.format(error=error)]
1324
+ return facts, turn_dir.name, []
1325
+ return None, None, []
1326
+
1327
+
1328
+ def _view_tail(path: Path) -> list[str]:
1329
+ if not path.is_file():
1330
+ return []
1331
+ try:
1332
+ return read_tail_lines(path, PEEK_TAIL_LINES)
1333
+ except OSError as error:
1334
+ message = f"cannot read view log {path}: {error}"
1335
+ raise OperatorQueryError(message) from error
1336
+
1337
+
1338
+ def _timeline_roles(runs: Sequence[Run], role: str | None) -> tuple[str, ...]:
1339
+ if role is not None:
1340
+ return (Role(role).value,)
1341
+ return tuple(sorted({run.role.value for run in runs}))
1342
+
1343
+
1344
+ def _build_timeline_page(
1345
+ *,
1346
+ limit: int,
1347
+ current: TimelineCursor,
1348
+ filters: TimelineFilters,
1349
+ event_rows: Sequence[Event],
1350
+ signal_batches: dict[str, SignalBatch],
1351
+ ) -> TaskTimelinePage:
1352
+ streams: dict[str, list[TimelineItem]] = {}
1353
+ if event_rows:
1354
+ streams[TIMELINE_SOURCE_ENGINE] = [_event_timeline_item(event) for event in event_rows]
1355
+ streams.update(
1356
+ {
1357
+ f"{TIMELINE_SOURCE_AGENT}:{role}": list(batch.items)
1358
+ for role, batch in signal_batches.items()
1359
+ if batch.items
1360
+ }
1361
+ )
1362
+ merged = _merge_timeline_streams(streams, limit + 1)
1363
+ entries = merged[:limit]
1364
+ has_older = len(merged) > limit or any(batch.has_more for batch in signal_batches.values())
1365
+ older_cursor = (
1366
+ _next_timeline_cursor(current, entries, signal_batches, filters) if has_older else None
1367
+ )
1368
+ return TaskTimelinePage(
1369
+ entries=[_timeline_entry(item) for item in entries],
1370
+ older_cursor=older_cursor,
1371
+ has_older=has_older,
1372
+ filters={"role": filters.role, "turn": filters.turn, "category": filters.category},
1373
+ )
1374
+
1375
+
1376
+ def _next_timeline_cursor(
1377
+ current: TimelineCursor,
1378
+ entries: Sequence[TimelineItem],
1379
+ signal_batches: dict[str, SignalBatch],
1380
+ filters: TimelineFilters,
1381
+ ) -> str:
1382
+ next_event = current.event_before
1383
+ next_signals = dict(current.signal_before)
1384
+ consumed_roles: set[str] = set()
1385
+ for item in entries:
1386
+ if item.event_id is not None and item.timestamp is not None:
1387
+ next_event = (format_timestamp(item.timestamp), item.event_id)
1388
+ elif item.role is not None:
1389
+ consumed_roles.add(item.role)
1390
+ next_signals[item.role] = (
1391
+ -1
1392
+ if item.identity.startswith(("missing:", TIMELINE_READ_ERROR_PREFIX))
1393
+ else item.signal_offset
1394
+ )
1395
+ for role, batch in signal_batches.items():
1396
+ if role not in consumed_roles:
1397
+ next_signals[role] = batch.replay_offset if batch.items else batch.scan_offset
1398
+ return encode_cursor(
1399
+ TimelineCursor(event_before=next_event, signal_before=next_signals), filters
1400
+ )
1401
+
1402
+
1403
+ def _timeline_event_kinds(category: str) -> tuple[EventKind, ...] | None:
1404
+ if category in {
1405
+ TIMELINE_ACTIVITY_CATEGORY,
1406
+ TIMELINE_ALL_CATEGORY,
1407
+ TIMELINE_ENGINE_CATEGORY,
1408
+ }:
1409
+ return None
1410
+ if category == TIMELINE_DECISION_CATEGORY:
1411
+ return tuple(EventKind(kind) for kind in sorted(TIMELINE_DECISION_KINDS))
1412
+ if category == TIMELINE_TRANSITION_CATEGORY:
1413
+ return tuple(kind for kind in EventKind if kind.value not in TIMELINE_DECISION_KINDS)
1414
+ return ()
1415
+
1416
+
1417
+ def _event_timeline_item(event: Event) -> TimelineItem:
1418
+ role = event.data.get("role")
1419
+ turn = event.data.get("turn")
1420
+ return TimelineItem(
1421
+ identity=f"engine:{event.id}",
1422
+ timestamp=event.ts,
1423
+ source=TIMELINE_SOURCE_ENGINE,
1424
+ category=event_category(event.kind.value),
1425
+ summary=event.message,
1426
+ phrase=event_phrase(event),
1427
+ detail=json.dumps(
1428
+ event.data, ensure_ascii=False, sort_keys=True, indent=TIMELINE_JSON_INDENT
1429
+ ),
1430
+ role=role if isinstance(role, str) else None,
1431
+ turn=turn if isinstance(turn, str) else None,
1432
+ event_id=event.id,
1433
+ )
1434
+
1435
+
1436
+ def _merge_timeline_streams(
1437
+ streams: dict[str, list[TimelineItem]], limit: int
1438
+ ) -> list[TimelineItem]:
1439
+ offsets = dict.fromkeys(streams, 0)
1440
+ merged: list[TimelineItem] = []
1441
+ while len(merged) < limit:
1442
+ available = [
1443
+ (source, items[offsets[source]])
1444
+ for source, items in streams.items()
1445
+ if offsets[source] < len(items)
1446
+ ]
1447
+ if not available:
1448
+ break
1449
+ dated = [item for _, item in available if item.timestamp is not None]
1450
+ if dated:
1451
+ newest = max(item.timestamp for item in dated if item.timestamp is not None)
1452
+ tied = [pair for pair in available if pair[1].timestamp == newest]
1453
+ else:
1454
+ tied = available
1455
+ source, item = min(tied, key=_timeline_tie_break)
1456
+ merged.append(item)
1457
+ offsets[source] += 1
1458
+ return merged
1459
+
1460
+
1461
+ def _timeline_tie_break(pair: tuple[str, TimelineItem]) -> tuple[int, str, int]:
1462
+ source, item = pair
1463
+ source_rank = 0 if item.source == TIMELINE_SOURCE_ENGINE else 1
1464
+ stable_position = -(item.event_id or item.signal_offset or 0)
1465
+ return source_rank, item.role or source, stable_position
1466
+
1467
+
1468
+ def _timeline_entry(item: TimelineItem) -> TaskTimelineEntry:
1469
+ return TaskTimelineEntry(
1470
+ identity=item.identity,
1471
+ timestamp=item.timestamp.isoformat() if item.timestamp is not None else None,
1472
+ timestamp_error=item.timestamp_error,
1473
+ source=item.source,
1474
+ category=item.category,
1475
+ summary=item.summary,
1476
+ phrase=item.phrase.text,
1477
+ glyph=item.phrase.glyph,
1478
+ tone=item.phrase.tone.value,
1479
+ detail=item.detail,
1480
+ role=item.role,
1481
+ turn=item.turn,
1482
+ event_id=item.event_id,
1483
+ incomplete=item.incomplete,
1484
+ )
1485
+
1486
+
1487
+ def _command_target(command: Command, project: str) -> str:
1488
+ if isinstance(command, Submit):
1489
+ if command.task_id is None:
1490
+ message = "stored submit command has no allocated task id"
1491
+ raise ValueError(message)
1492
+ return command.task_id
1493
+ if isinstance(command, Resolve):
1494
+ return command.escalation_id
1495
+ if isinstance(command, Stop):
1496
+ return project
1497
+ if isinstance(command, (Say, SetRung, TakeOver, HandBack)):
1498
+ return f"{command.task_id} · {command.role.value}"
1499
+ return command.task_id
1500
+
1501
+
1502
+ def aggregate_usage(runs: Sequence[Run]) -> list[UsageView]:
1503
+ """Aggregate recorded runs with the shared token and cost provenance rules."""
1504
+ by_agent: dict[str, list[Run]] = {}
1505
+ for run in runs:
1506
+ by_agent.setdefault(run.agent, []).append(run)
1507
+ views: list[UsageView] = []
1508
+ for agent in sorted(by_agent):
1509
+ agent_runs = by_agent[agent]
1510
+ reported_runs = [run for run in agent_runs if run.usage_reported is True]
1511
+ legacy_token_runs = [
1512
+ run
1513
+ for run in agent_runs
1514
+ if run.usage_reported is None and has_positive_token_evidence(run)
1515
+ ]
1516
+ token_runs = [*reported_runs, *legacy_token_runs]
1517
+ cost_bases: set[CostBasis] = {
1518
+ run.usage.cost_basis for run in agent_runs if _has_priced_cost_evidence(run)
1519
+ }
1520
+ billed_runs = [
1521
+ run
1522
+ for run in agent_runs
1523
+ if _has_priced_cost_evidence(run) and run.usage.cost_basis == _COST_BASIS_BILLED
1524
+ ]
1525
+ api_equivalent_runs = [
1526
+ run
1527
+ for run in agent_runs
1528
+ if _has_priced_cost_evidence(run) and run.usage.cost_basis == _COST_BASIS_API_EQUIVALENT
1529
+ ]
1530
+ priced_run_count = len(billed_runs) + len(api_equivalent_runs)
1531
+ unknown_cost_runs = len(agent_runs) - priced_run_count
1532
+ cost_basis: CostBasis | Literal["mixed"]
1533
+ if unknown_cost_runs:
1534
+ cost_usd = None
1535
+ cost_basis = COST_BASIS_UNKNOWN
1536
+ elif len(cost_bases) > 1:
1537
+ cost_usd = None
1538
+ cost_basis = "mixed"
1539
+ else:
1540
+ cost_usd = _priced_total(billed_runs or api_equivalent_runs)
1541
+ cost_basis = next(iter(cost_bases)) if cost_bases else COST_BASIS_UNKNOWN
1542
+ views.append(
1543
+ UsageView(
1544
+ agent=agent,
1545
+ input_tokens=sum(run.usage.input_tokens for run in token_runs),
1546
+ output_tokens=sum(run.usage.output_tokens for run in token_runs),
1547
+ cache_read_tokens=sum(run.usage.cache_read_tokens for run in token_runs),
1548
+ cache_write_tokens=sum(run.usage.cache_write_tokens for run in token_runs),
1549
+ cost_usd=cost_usd,
1550
+ cost_basis=cost_basis,
1551
+ run_count=len(agent_runs),
1552
+ usage_reported_runs=len(reported_runs),
1553
+ legacy_token_runs=len(legacy_token_runs),
1554
+ billed_cost_usd=_priced_total(billed_runs),
1555
+ billed_cost_runs=len(billed_runs),
1556
+ legacy_billed_cost_runs=sum(run.usage_reported is None for run in billed_runs),
1557
+ api_equivalent_cost_usd=_priced_total(api_equivalent_runs),
1558
+ api_equivalent_cost_runs=len(api_equivalent_runs),
1559
+ legacy_api_equivalent_cost_runs=sum(
1560
+ run.usage_reported is None for run in api_equivalent_runs
1561
+ ),
1562
+ unknown_cost_runs=unknown_cost_runs,
1563
+ )
1564
+ )
1565
+ return views
1566
+
1567
+
1568
+ def _has_priced_cost_evidence(run: Run) -> bool:
1569
+ return (
1570
+ run.usage_reported is not False
1571
+ and run.usage.cost_usd is not None
1572
+ and run.usage.cost_basis in (_COST_BASIS_BILLED, _COST_BASIS_API_EQUIVALENT)
1573
+ )
1574
+
1575
+
1576
+ def _priced_total(runs: Sequence[Run]) -> float | None:
1577
+ if not runs:
1578
+ return None
1579
+ return sum((run.usage.cost_usd for run in runs if run.usage.cost_usd is not None), 0.0)
1580
+
1581
+
1582
+ def _escalation_question(parsed: EscalationPacket) -> str | None:
1583
+ """Prefer the agent's question, as the dashboard does; restored packets hold the headline."""
1584
+ request = parsed.agent_request
1585
+ if request is not None and request.question:
1586
+ return request.question
1587
+ return parsed.question
1588
+
1589
+
1590
+ def _escalation_resolutions(
1591
+ parsed: EscalationPacket, escalation: Escalation
1592
+ ) -> list[EscalationResolutionView]:
1593
+ recommended = {option.label for option in escalation.options if option.recommended}
1594
+ return [
1595
+ EscalationResolutionView(
1596
+ kind=resolution.kind,
1597
+ consequences=resolution.consequences,
1598
+ recommended=resolution.kind in recommended,
1599
+ )
1600
+ for resolution in parsed.resolutions
1601
+ ]