claudectl_core/runtime.rs
1//! UI ↔ runtime contract.
2//!
3//! Tracking: issue #274 of the workspace-refactor epic (#279).
4//!
5//! The TUI today reaches deep into brain / coord / bus / rules internals. This
6//! module defines the **read-only** boundary it should reach through instead,
7//! so a future `claudectl-tui` crate (#275) can be extracted and iterated on
8//! without recompiling brain or the bus.
9//!
10//! ## Why traits, why core-owned DTOs
11//!
12//! Each view is a trait, not a concrete struct, so:
13//!
14//! - The binary crate can hand the TUI a real implementation backed by SQLite
15//! / the engine / the bus DB. A future remote frontend can hand it an HTTP
16//! client. Tests hand it a fixture.
17//! - Adding a method to a trait is a contract change reviewable in one PR;
18//! adding a method to a concrete struct ripples through every caller.
19//!
20//! Each DTO (`SessionSnapshot`, `LeaseSummary`, …) is owned by `core` so the
21//! traits don't drag `brain::DecisionRecord` / `coord::Lease` upward into the
22//! TUI's dependency surface. Conversion happens once, in the wrapper.
23//!
24//! ## What's in scope here
25//!
26//! Read-only views only. Side-effecting paths (`terminate_session`,
27//! `inject_prompt`, `log_decision`) deserve a separate `Actions` trait once
28//! the TUI's write surface is mapped. Adding it speculatively now would
29//! violate the "only add methods existing call sites need" rule the epic
30//! committed to.
31//!
32//! ## What's NOT in scope
33//!
34//! - The TUI doesn't yet call through these traits — that's #275.
35//! - Brain's review/scorecard surface — it's heavier and worth its own
36//! trait once #275 stabilizes the basic shape.
37//! - Pub/sub claim protocol — #283 builds on top of these traits, not the
38//! other way around.
39
40use serde::{Deserialize, Serialize};
41use std::sync::Arc;
42
43// ============================================================================
44// Sessions
45// ============================================================================
46
47/// One running Claude Code session, as observed by the runtime. Minimal
48/// projection of the binary-crate `ClaudeSession`; only fields the TUI
49/// renders.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionSnapshot {
52 pub session_id: String,
53 pub pid: u32,
54 pub cwd: String,
55 pub project_name: String,
56 pub status: String,
57 pub cost_usd: f64,
58 pub context_tokens: u64,
59 pub context_max: u64,
60 pub last_message_ts: u64,
61}
62
63/// Read access to the live session roster.
64pub trait SessionSource: Send + Sync {
65 /// Snapshot of every running Claude Code session. Order is the
66 /// implementor's choice; the TUI sorts again client-side.
67 fn list(&self) -> Vec<SessionSnapshot>;
68
69 /// Fetch a specific session by its ID. `None` when the session has
70 /// exited or never existed.
71 fn detail(&self, session_id: &str) -> Option<SessionSnapshot> {
72 self.list().into_iter().find(|s| s.session_id == session_id)
73 }
74}
75
76// ============================================================================
77// Brain
78// ============================================================================
79
80/// Mirrors the binary's `brain::GateMode` without depending on the brain
81/// crate. Persisted as the lowercased label.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum BrainGateMode {
85 On,
86 Off,
87 Auto,
88}
89
90impl BrainGateMode {
91 /// Canonical lowercase label — the form persisted to
92 /// `~/.claudectl/brain/gate-mode` and emitted by the TUI status messages.
93 pub fn as_str(&self) -> &'static str {
94 match self {
95 Self::On => "on",
96 Self::Off => "off",
97 Self::Auto => "auto",
98 }
99 }
100}
101
102impl std::fmt::Display for BrainGateMode {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(self.as_str())
105 }
106}
107
108/// A single past brain decision, projected for display.
109///
110/// The first six fields are the common shape used by `BrainView::recent_decisions`.
111/// The remaining fields support the Brain Review surface (`BrainReviewView`); they
112/// are `Option`-wrapped + `#[serde(default)]` so older `BrainView` callers can
113/// keep treating them as opaque.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DecisionSummary {
116 pub id: String,
117 pub timestamp: String,
118 pub action: String,
119 pub confidence: Option<f64>,
120 pub project: Option<String>,
121 pub tool: Option<String>,
122 /// PID of the session this decision belongs to. Used by counterfactual
123 /// analysis to pair decisions with their subsequent outcome from the
124 /// same session.
125 #[serde(default)]
126 pub pid: u32,
127
128 /// Tool input string when the decision was about a specific command.
129 #[serde(default)]
130 pub command: Option<String>,
131 /// Brain's free-form rationale for the suggestion.
132 #[serde(default)]
133 pub reasoning: Option<String>,
134 /// What the user did with the suggestion — `"accept"`, `"reject"`,
135 /// `"deny_rule_override"`, etc.
136 #[serde(default)]
137 pub user_action: Option<String>,
138 /// Why the user overrode the brain (if applicable).
139 #[serde(default)]
140 pub override_reason: Option<String>,
141 /// Wall-clock latency of the brain decision in milliseconds.
142 #[serde(default)]
143 pub brain_decision_ms: Option<u64>,
144 /// Whether the operator has marked this decision as canonical (teaching
145 /// material). `None` for records written before the field existed.
146 #[serde(default)]
147 pub canonical: Option<bool>,
148 /// Cache hit flag — served from the few-shot store without an LLM call.
149 /// `None` before instrumentation.
150 #[serde(default)]
151 pub cache_hit: Option<bool>,
152 /// Cost in USD when this decision was made (context snapshot).
153 #[serde(default)]
154 pub cost_usd: Option<f64>,
155 /// Model that produced the suggestion.
156 #[serde(default)]
157 pub model: Option<String>,
158 /// Resolved outcome category, when known. `"success" | "error" |
159 /// "test_failed" | "skipped"` etc. Mirrors the variants of the binary's
160 /// `brain::decisions::DecisionOutcome` enum, flattened to a string so
161 /// the contract doesn't pull the enum upward.
162 #[serde(default)]
163 pub outcome_kind: Option<String>,
164 /// Free-form detail for failure outcomes (the failing command for
165 /// `test_failed`, the error message for `error`).
166 #[serde(default)]
167 pub outcome_detail: Option<String>,
168 /// Epoch seconds when the brain suggestion was first surfaced. Used by
169 /// time-to-correct analysis. `None` for records pre-instrumentation or
170 /// passive observations.
171 #[serde(default)]
172 pub suggested_at: Option<u64>,
173 /// Epoch seconds when the user acted on the suggestion. `None` for
174 /// passive observations or records still in flight.
175 #[serde(default)]
176 pub resolved_at: Option<u64>,
177 /// One-line, human-readable explanation of *why* the brain reached this
178 /// decision (#372): source, confidence, rule/few-shot provenance. Rendered
179 /// inline in the review detail panel. Empty for records that carry no
180 /// derivable cause.
181 #[serde(default)]
182 pub why: String,
183 /// Where the decision originated (#372): `"llm"`, `"rule"`, `"few_shot"`.
184 #[serde(default)]
185 pub decision_source: Option<String>,
186 /// The auto-rule that fired, when this was a rule decision (#372).
187 #[serde(default)]
188 pub rule_name: Option<String>,
189}
190
191impl DecisionSummary {
192 /// Whether the user agreed with the brain (or the call was auto-executed).
193 /// Mirrors `brain::decisions::DecisionRecord::is_positive`.
194 pub fn is_positive(&self) -> bool {
195 matches!(
196 self.user_action.as_deref(),
197 Some("accept" | "auto" | "user_approve" | "rule_approve")
198 )
199 }
200
201 /// Whether the user disagreed with the brain. Mirrors
202 /// `brain::decisions::DecisionRecord::is_negative`.
203 pub fn is_negative(&self) -> bool {
204 matches!(
205 self.user_action.as_deref(),
206 Some("reject" | "deny_rule_override" | "rule_deny" | "conflict_deny")
207 )
208 }
209}
210
211/// Read access to the brain's decision history and current mode.
212pub trait BrainView: Send + Sync {
213 fn gate_mode(&self) -> BrainGateMode;
214
215 /// Most recent `n` decisions, newest first.
216 fn recent_decisions(&self, n: usize) -> Vec<DecisionSummary>;
217
218 /// Total count of brain decisions on disk. Drives the "decisions: N"
219 /// status line.
220 fn decision_count(&self) -> usize;
221}
222
223/// One entry in the Brain Review queue — a decision worth showing the operator
224/// for canonical-marking review, with a reason and a priority score.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ReviewItemSummary {
227 pub decision: DecisionSummary,
228 /// Free-form rationale for why this decision was queued for review.
229 pub reason: String,
230 /// Priority score (higher = more important to review first).
231 pub score: f64,
232}
233
234/// Read access to the Brain Review surface — the full decision log plus the
235/// review-queue projection. Separate trait from `BrainView` because:
236///
237/// - The Brain Review screen is the only TUI consumer; the dashboard's status
238/// bar only needs `BrainView::recent_decisions(n)`.
239/// - `all_decisions()` returns the whole log (can be thousands of records);
240/// it's a heavier surface than the lightweight `BrainView` methods and
241/// worth gating behind its own trait so callers don't accidentally invoke it.
242pub trait BrainReviewView: Send + Sync {
243 /// Every decision on disk, newest first. Used by the Brain Review screen
244 /// to render the full history list.
245 fn all_decisions(&self) -> Vec<DecisionSummary>;
246
247 /// Priority-ordered review queue. Built from `all_decisions()` plus the
248 /// brain's queue-building heuristics (counterfactual hits, Critical-tier
249 /// safety cases, high-confidence misses).
250 fn review_queue(&self) -> Vec<ReviewItemSummary>;
251}
252
253// ============================================================================
254// Coordination layer
255// ============================================================================
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct LeaseSummary {
259 pub id: String,
260 pub owner_session_id: String,
261 pub resource_kind: String,
262 pub resource_value: String,
263 pub mode: String,
264 pub acquired_at: String,
265 /// ISO timestamp when the lease expires. `None` for leases held without
266 /// an explicit deadline.
267 #[serde(default)]
268 pub expires_at: Option<String>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct HandoffSummary {
273 pub id: String,
274 pub from_session_id: String,
275 pub to_session_id: Option<String>,
276 pub task_id: String,
277 pub summary: String,
278 pub priority: String,
279 pub created_at: String,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct InterruptSummary {
284 pub id: String,
285 pub interrupt_type: String,
286 pub priority: String,
287 pub target_session_id: String,
288 pub reason: String,
289 pub created_at: String,
290}
291
292/// A supervisor task lifecycle, projected for the TUI. One row per task in the
293/// coord `tasks` table. `state` is the wire label (`TaskState::as_str`), kept as
294/// a string so the contract doesn't drag the coord state enum upward.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct TaskSummary {
297 pub id: String,
298 pub name: String,
299 pub state: String,
300 pub role: Option<String>,
301 /// Number of recorded attempts so far.
302 pub attempts: u32,
303 pub max_retries: u32,
304 /// Session id of the most recent attempt, when one has run.
305 pub last_session_id: Option<String>,
306 /// Most recent verifier verdict (e.g. "PASS"/"FAIL"), or `None` if no
307 /// verifier has run for this task yet.
308 #[serde(default)]
309 pub last_verdict: Option<String>,
310 /// Total cost in USD across all attempts.
311 #[serde(default)]
312 pub cost_usd: f64,
313 pub created_at: String,
314 pub updated_at: String,
315}
316
317/// Read access to coordination state (leases, handoffs, interrupts, tasks).
318/// Backed today by the `coord` SQLite store; in tests, by a fixture.
319pub trait CoordView: Send + Sync {
320 fn active_leases(&self) -> Vec<LeaseSummary>;
321 fn pending_handoffs(&self) -> Vec<HandoffSummary>;
322 fn pending_interrupts(&self) -> Vec<InterruptSummary>;
323 /// Supervisor task lifecycles, newest first. Empty when the `coord` feature
324 /// is off so the TUI can render a "no supervisor" state without cfg.
325 fn tasks(&self) -> Vec<TaskSummary>;
326}
327
328// ============================================================================
329// Agent bus
330// ============================================================================
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct AgentDirectoryEntry {
334 pub session_id: String,
335 pub pid: u32,
336 pub cwd: String,
337 pub project: String,
338 pub status: String,
339 pub role: Option<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct RoleBinding {
344 pub name: String,
345 pub cwd_selector: String,
346 pub last_session_id: Option<String>,
347 pub last_seen: String,
348 /// PID this role is bound to (#307). `None` for cwd-only bindings.
349 pub pid: Option<u32>,
350}
351
352/// Read access to the agent-bus roster + role table. Disabled implementations
353/// (`bus` feature off in the binary) return empty vectors so the TUI can
354/// render the panel as "no bus" without conditional compilation.
355pub trait BusView: Send + Sync {
356 fn list_agents(&self) -> Vec<AgentDirectoryEntry>;
357 fn list_roles(&self) -> Vec<RoleBinding>;
358}
359
360// ============================================================================
361// Actions (write surface)
362// ============================================================================
363
364/// What the TUI needs to record alongside a user action. Core-owned so the
365/// trait doesn't drag `brain::decisions::log_observation`'s argument list
366/// upward into the TUI surface.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct ObservationInput {
369 /// PID of the session the observation belongs to.
370 pub session_pid: u32,
371 /// Project label (usually the cwd basename).
372 pub project: String,
373 /// Tool the action targeted, when applicable ("Bash", "Write", …).
374 pub tool: Option<String>,
375 /// The command or input string the user ran/sent.
376 pub command: Option<String>,
377 /// Classification — `"user_approve"`, `"user_input"`, `"rule_approve"`,
378 /// `"rule_deny"`, and friends. Kept as a string so callers can introduce
379 /// new categories without a trait change.
380 pub observed_action: String,
381}
382
383/// Whether a decision was about a session or about an orchestration task.
384/// Mirrors `brain::decisions::DecisionType` without depending on it.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum DecisionScope {
388 Session,
389 Orchestration,
390}
391
392impl DecisionScope {
393 /// Wire label. Matches what `brain::decisions::DecisionType::as_label`
394 /// writes to disk so the round-trip is stable.
395 pub fn as_str(&self) -> &'static str {
396 match self {
397 Self::Session => "session",
398 Self::Orchestration => "orchestration",
399 }
400 }
401}
402
403/// What the TUI needs to record when the user resolves a brain suggestion.
404/// Carries the suggestion that was on screen at the time, plus what the user
405/// did and (optionally) why they overrode the brain.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct LogDecisionInput {
408 /// PID of the session the decision applies to.
409 pub session_pid: u32,
410 /// Project label.
411 pub project: String,
412 /// Tool name the suggestion targeted.
413 pub tool: Option<String>,
414 /// Command / input string the suggestion targeted.
415 pub command: Option<String>,
416 /// The brain's suggestion the user is resolving.
417 pub suggestion: PendingSuggestion,
418 /// What the user did — `"accept"`, `"reject"`, `"deny_rule_override"`, etc.
419 pub user_action: String,
420 /// Whether this was a session decision or an orchestration decision.
421 pub decision_type: DecisionScope,
422 /// Why the user overrode a brain denial (if applicable).
423 pub override_reason: Option<String>,
424}
425
426/// Side-effecting paths the TUI invokes when the user takes an action
427/// (terminating a session, sending a prompt, flipping the brain mode,
428/// recording an observation, marking a decision canonical for teaching).
429///
430/// Each method is fallible — implementations return a `Result` rather than
431/// silently swallowing errors so the TUI can surface failures in the status
432/// bar instead of pretending an action succeeded.
433///
434/// **Not modeled here (intentionally):**
435///
436/// - `launch::*` — launching a new Claude Code session is wider than a single
437/// call (cwd selection, model defaults, plugin propagation). Will get its
438/// own trait once the TUI's launcher surface is refactored alongside #275.
439/// - `mailbox::deliver_pending` — orchestrator-style mailbox delivery; should
440/// eventually move to an `Orchestrator` trait, not `Actions`.
441/// - `log_decision` (vs. `log_observation`) — currently only called from the
442/// plugin gate path, not the TUI. Stays a direct call until a TUI site
443/// needs it.
444pub trait Actions: Send + Sync {
445 /// Terminate the OS process with the given PID. The TUI uses this for
446 /// "kill session" hotkeys.
447 fn terminate_session(&self, pid: u32) -> Result<(), String>;
448
449 /// Inject text into the session's terminal. Implementations pick the
450 /// right backend (tmux, Kitty, etc.) from the session metadata.
451 ///
452 /// The `text` is sent verbatim — the caller is responsible for any
453 /// trailing newline they want. Sanitization (e.g. neutralizing a leading
454 /// `/`) belongs to the caller; this trait is a transport.
455 fn inject_text(&self, session_id: &str, text: &str) -> Result<(), String>;
456
457 /// Update the persisted brain gate mode. Reflected back through
458 /// `BrainView::gate_mode` on the next call.
459 fn set_gate_mode(&self, mode: BrainGateMode) -> Result<(), String>;
460
461 /// Record an observation about a user action — non-LLM "the user did X."
462 /// Drives the brain's outcome telemetry and preference distillation.
463 fn log_observation(&self, observation: ObservationInput) -> Result<(), String>;
464
465 /// Record a user's accept/reject decision on a brain suggestion. The
466 /// distinction from `log_observation` is that `log_decision` carries
467 /// the actual suggestion that was on screen — the brain pairs it with
468 /// the user's response for outcome telemetry, counterfactual analysis,
469 /// and few-shot retrieval.
470 fn log_decision(&self, input: LogDecisionInput) -> Result<(), String>;
471
472 /// Mark a past brain decision as canonical for teaching. Optional `note`
473 /// is the operator's annotation. Used by the Brain Review surface.
474 fn mark_canonical(&self, decision_id: &str, note: Option<String>) -> Result<(), String>;
475
476 /// Bind an agent-bus role to a `(cwd, pid)` pair. The TUI calls this from
477 /// the new role-bind key (Ctrl+R) so the operator can attach a role to a
478 /// specific running Claude session without dropping to another terminal.
479 /// Returns `Err` when the bus feature is compiled out or the DB write
480 /// fails. See issue #307.
481 fn bind_bus_role(&self, name: &str, cwd: &str, pid: u32) -> Result<(), String>;
482
483 /// Cancel a supervisor task (move it to CANCELLED). Used by the Supervisor
484 /// panel's one-key cancel (#368). `Err` when the `coord` feature is compiled
485 /// out, the task is already terminal, or the store write fails.
486 fn cancel_task(&self, task_id: &str) -> Result<(), String>;
487
488 /// Set or clear the supervisor drain marker. When draining, the reconciler
489 /// stops issuing new task assignments while letting running tasks finish.
490 /// `Err` when the `coord` feature is compiled out.
491 fn set_supervisor_drain(&self, draining: bool) -> Result<(), String>;
492
493 /// Re-queue a failed/cancelled task (move it back to PENDING so the
494 /// reconciler re-assigns it). `Err` for a DONE task (nothing to retry), a
495 /// still-active task, or when the `coord` feature is compiled out.
496 fn retry_task(&self, task_id: &str) -> Result<(), String>;
497
498 /// Approve a NEEDS_HUMAN task — accept the work as-is and move it to DONE,
499 /// overriding the verifier that escalated it. `Err` when the task isn't in
500 /// NEEDS_HUMAN, or the `coord` feature is compiled out.
501 fn approve_task(&self, task_id: &str) -> Result<(), String>;
502}
503
504// ============================================================================
505// Hive actions — knowledge-store (`hive`) + transport (`relay`) reads/writes
506// ============================================================================
507
508/// Snapshot of local hive state the overlay reads to render the Hive tab.
509///
510/// Mirrors the binary's same-named struct, lifted here so the TUI can hold
511/// it without depending on the binary's app module.
512#[derive(Debug, Clone, Default)]
513pub struct HiveViewSnapshot {
514 /// Local relay identity (peer ID string). `None` when the `relay`
515 /// feature is compiled out.
516 pub identity: Option<String>,
517 /// Known peers, paired with last-seen address when available.
518 pub peers: Vec<(String, Option<String>)>,
519}
520
521/// Hive + relay surface the TUI needs to render the Skills and Hive panels.
522///
523/// Separate trait from `Actions` because the underlying subsystems are
524/// feature-gated (`hive`, `relay`) — when those features are off the
525/// implementation returns empty/no-op values rather than failing.
526pub trait HiveActions: Send + Sync {
527 /// Semantic keys (`skill:<lowered-name>`) of skills already shared into
528 /// the local hive store. Used by the Skills overlay to mark "already
529 /// shared" rows.
530 fn shared_skill_keys(&self) -> std::collections::HashSet<String>;
531
532 /// Share a discovered skill into the local hive store. Returns the new
533 /// unit ID on success.
534 fn share_skill(&self, skill: &crate::skills::DiscoveredSkill) -> Result<String, String>;
535
536 /// Current view of the local relay identity + known peers for the Hive
537 /// tab.
538 fn hive_view_snapshot(&self) -> HiveViewSnapshot;
539}
540
541// ============================================================================
542// Brain driver (stateful)
543// ============================================================================
544
545/// One pending brain suggestion the user can accept or reject. Mirrors the
546/// binary's `brain::client::BrainSuggestion` without exposing brain types.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct PendingSuggestion {
549 /// PID of the session this suggestion applies to.
550 pub pid: u32,
551 /// String label of the suggested action — `"approve"`, `"deny"`,
552 /// `"send"`, `"terminate"`. String to avoid pulling `RuleAction`'s enum
553 /// shape into the contract (it's already in `claudectl-core::rules` but
554 /// callers may want to add new categories without bumping the trait).
555 pub action: String,
556 /// Suggested message body (a prompt to inject, a rationale to surface).
557 pub message: Option<String>,
558 /// Why the brain suggested this — used for the detail panel and the
559 /// rejection log.
560 pub reasoning: String,
561 /// 0.0–1.0 confidence the brain assigned.
562 pub confidence: f64,
563 /// Epoch seconds when the suggestion was created. Drives time-to-correct
564 /// telemetry.
565 pub suggested_at: u64,
566}
567
568/// Stateful brain orchestration. Unlike the read-only views, this needs
569/// `&mut` per tick — the engine accumulates pending suggestions, cleans up
570/// after exited sessions, and applies user accept/reject decisions.
571///
572/// Boxed as `Box<dyn BrainDriver>` rather than `Arc<dyn BrainDriver>`
573/// because there is exactly one owner (the TUI's `App`) and every method
574/// needs `&mut self`. Sharing across threads is not a goal here.
575///
576/// Implementations may be `None` — the brain is opt-in (`--brain` flag).
577/// When the field is `None` the TUI renders without brain interaction.
578pub trait BrainDriver: Send {
579 /// Run one tick of the brain loop against the current session set and
580 /// the operator's deny rules. Returns `(pid, status_message)` pairs
581 /// for any actions the brain decided on this tick — the TUI surfaces
582 /// the messages in the status bar.
583 fn tick(
584 &mut self,
585 sessions: &[SessionSnapshot],
586 deny_rules: &[crate::rules::AutoRule],
587 ) -> Vec<(u32, String)>;
588
589 /// Drop pending suggestions for sessions that have exited. Called every
590 /// refresh so the pending map stays bounded by the live session count.
591 fn cleanup(&mut self, sessions: &[SessionSnapshot]);
592
593 /// User accepted the pending suggestion for `pid`. Implementations
594 /// return the log message the TUI should surface (`None` when there's
595 /// no suggestion to accept).
596 fn accept(&mut self, pid: u32) -> Option<String>;
597
598 /// User rejected the pending suggestion for `pid`. Returns the
599 /// suggestion that was rejected — the TUI logs it for replay /
600 /// counterfactual analysis.
601 fn reject(&mut self, pid: u32) -> Option<PendingSuggestion>;
602
603 /// Lookup the pending suggestion for `pid`, if any. Used by detail
604 /// panels and the status bar.
605 fn pending_for(&self, pid: u32) -> Option<PendingSuggestion>;
606
607 /// Total pending suggestion count. Used by the status bar.
608 fn pending_count(&self) -> usize;
609
610 /// Drop every pending suggestion. Called when the brain mode flips
611 /// off or the operator resets state.
612 fn clear_pending(&mut self);
613
614 /// Inject a pending suggestion for `pid`. Used by demo mode to fake
615 /// brain activity for screenshots / recordings. Implementations may
616 /// drop suggestions whose `action` string doesn't map to a known
617 /// engine action (rather than erroring out) — demo is best-effort.
618 fn set_pending(&mut self, suggestion: PendingSuggestion);
619}
620
621// ============================================================================
622// Orchestrator
623// ============================================================================
624
625/// Tick the orchestration layer once per refresh: deliver pending mailbox
626/// messages, deliver pending coordination interrupts, and bookkeep stale
627/// rows in the underlying stores.
628///
629/// Each `deliver_*` method returns `(id, status_message)` tuples the TUI
630/// surfaces in the status bar. `id` is a `u32` for mailbox (matching the
631/// brain's per-PID queue) and a `String` for interrupts (matching the
632/// coord interrupt_id). They differ deliberately — keeping the trait honest
633/// to the underlying surfaces rather than papering over the distinction.
634///
635/// Unlike `CoordView` / `BrainView` which are read-only, this trait is
636/// stateful: each call writes to the brain/coord SQLite stores. It still
637/// boxes as `Arc<dyn>` because no method needs `&mut self` — implementations
638/// open a connection per call.
639///
640/// Implementations may be no-ops when the relevant feature is off (e.g. the
641/// `coord` feature is disabled at compile time), so call sites don't need
642/// `#[cfg(feature = "...")]` guards.
643pub trait Orchestrator: Send + Sync {
644 /// Drain pending mailbox messages addressed to running sessions and
645 /// deliver them to the live terminals. Used by `App::tick` to surface
646 /// `"Brain → sess_X: 'go ahead'"` style messages.
647 fn deliver_mailbox(&self, sessions: &[SessionSnapshot]) -> Vec<(u32, String)>;
648
649 /// Deliver pending coordination interrupts (cross-agent signals) to
650 /// running sessions. Returns `(interrupt_id, status_message)` tuples.
651 fn deliver_interrupts(&self, sessions: &[SessionSnapshot]) -> Vec<(String, String)>;
652
653 /// Expire stale leases and interrupts that have passed their deadline.
654 /// Bookkeeping side-effect; best-effort, no return. Implementations
655 /// log failures rather than propagating them.
656 fn expire_stale(&self);
657}
658
659// ============================================================================
660// Runtime aggregate
661// ============================================================================
662
663/// Single struct the binary builds at startup and hands to the TUI.
664///
665/// All fields are `Arc<dyn ...>` so the TUI doesn't care whether an impl is
666/// a thin SQLite wrapper, a remote HTTP client, or an in-memory mock — they
667/// all share the same shape.
668#[derive(Clone)]
669pub struct Runtime {
670 pub sessions: Arc<dyn SessionSource>,
671 pub brain: Arc<dyn BrainView>,
672 pub coord: Arc<dyn CoordView>,
673 pub bus: Arc<dyn BusView>,
674 pub actions: Arc<dyn Actions>,
675 pub review: Arc<dyn BrainReviewView>,
676 pub orchestrator: Arc<dyn Orchestrator>,
677 pub hive: Arc<dyn HiveActions>,
678}
679
680impl Runtime {
681 // 8-trait composition root; each view is its own arc. Splitting this into
682 // sub-records would just trade one wide ctor for several narrow ones with
683 // the same total fan-out.
684 #[allow(clippy::too_many_arguments)]
685 pub fn new(
686 sessions: Arc<dyn SessionSource>,
687 brain: Arc<dyn BrainView>,
688 coord: Arc<dyn CoordView>,
689 bus: Arc<dyn BusView>,
690 actions: Arc<dyn Actions>,
691 review: Arc<dyn BrainReviewView>,
692 orchestrator: Arc<dyn Orchestrator>,
693 hive: Arc<dyn HiveActions>,
694 ) -> Self {
695 Self {
696 sessions,
697 brain,
698 coord,
699 bus,
700 actions,
701 review,
702 orchestrator,
703 hive,
704 }
705 }
706}
707
708// ============================================================================
709// MockRuntime — for tests in this crate and in claudectl-tui
710// ============================================================================
711
712/// In-memory runtime backed by `Vec`s and interior-mutable counters. Used by
713/// tests in this crate to verify the trait shapes compile and roundtrip
714/// cleanly, and by the future `claudectl-tui` crate's tests to render the
715/// TUI against fixtures without dragging in brain / coord / bus.
716///
717/// `Actions` impls record their calls in `actions_log` so tests can assert
718/// "the TUI invoked the right side-effect" without spying on real I/O.
719#[derive(Default)]
720pub struct MockRuntime {
721 pub sessions: Vec<SessionSnapshot>,
722 pub gate_mode: std::sync::Mutex<Option<BrainGateMode>>,
723 pub decisions: Vec<DecisionSummary>,
724 pub review_queue: Vec<ReviewItemSummary>,
725 pub leases: Vec<LeaseSummary>,
726 pub handoffs: Vec<HandoffSummary>,
727 pub interrupts: Vec<InterruptSummary>,
728 pub tasks: Vec<TaskSummary>,
729 pub agents: Vec<AgentDirectoryEntry>,
730 pub roles: Vec<RoleBinding>,
731 pub actions_log: std::sync::Mutex<Vec<MockAction>>,
732}
733
734/// Recorded `Actions` calls. Tests use this to assert side-effect ordering
735/// without needing a real terminal or filesystem.
736#[derive(Debug, Clone, PartialEq, Eq)]
737pub enum MockAction {
738 Terminate {
739 pid: u32,
740 },
741 InjectText {
742 session_id: String,
743 text: String,
744 },
745 SetGateMode(BrainGateMode),
746 LogObservation(ObservationInput),
747 LogDecision(LogDecisionInput),
748 MarkCanonical {
749 decision_id: String,
750 note: Option<String>,
751 },
752 BindBusRole {
753 name: String,
754 cwd: String,
755 pid: u32,
756 },
757 CancelTask {
758 task_id: String,
759 },
760 SetSupervisorDrain {
761 draining: bool,
762 },
763 RetryTask {
764 task_id: String,
765 },
766 ApproveTask {
767 task_id: String,
768 },
769}
770
771impl PartialEq for ObservationInput {
772 fn eq(&self, other: &Self) -> bool {
773 self.session_pid == other.session_pid
774 && self.project == other.project
775 && self.tool == other.tool
776 && self.command == other.command
777 && self.observed_action == other.observed_action
778 }
779}
780
781impl Eq for ObservationInput {}
782
783impl PartialEq for LogDecisionInput {
784 fn eq(&self, other: &Self) -> bool {
785 self.session_pid == other.session_pid
786 && self.project == other.project
787 && self.tool == other.tool
788 && self.command == other.command
789 && self.user_action == other.user_action
790 && self.decision_type == other.decision_type
791 && self.override_reason == other.override_reason
792 // PendingSuggestion's field-by-field equality
793 && self.suggestion.pid == other.suggestion.pid
794 && self.suggestion.action == other.suggestion.action
795 && self.suggestion.message == other.suggestion.message
796 && self.suggestion.reasoning == other.suggestion.reasoning
797 }
798}
799
800impl Eq for LogDecisionInput {}
801
802impl MockRuntime {
803 pub fn into_runtime(self) -> Runtime {
804 let arc = Arc::new(self);
805 Runtime::new(
806 arc.clone(),
807 arc.clone(),
808 arc.clone(),
809 arc.clone(),
810 arc.clone(),
811 arc.clone(),
812 arc.clone(),
813 arc,
814 )
815 }
816
817 pub fn actions(&self) -> Vec<MockAction> {
818 self.actions_log
819 .lock()
820 .expect("actions_log poisoned")
821 .clone()
822 }
823}
824
825impl SessionSource for MockRuntime {
826 fn list(&self) -> Vec<SessionSnapshot> {
827 self.sessions.clone()
828 }
829}
830
831impl BrainView for MockRuntime {
832 fn gate_mode(&self) -> BrainGateMode {
833 self.gate_mode
834 .lock()
835 .expect("gate_mode poisoned")
836 .unwrap_or(BrainGateMode::On)
837 }
838 fn recent_decisions(&self, n: usize) -> Vec<DecisionSummary> {
839 self.decisions.iter().take(n).cloned().collect()
840 }
841 fn decision_count(&self) -> usize {
842 self.decisions.len()
843 }
844}
845
846impl CoordView for MockRuntime {
847 fn active_leases(&self) -> Vec<LeaseSummary> {
848 self.leases.clone()
849 }
850 fn pending_handoffs(&self) -> Vec<HandoffSummary> {
851 self.handoffs.clone()
852 }
853 fn pending_interrupts(&self) -> Vec<InterruptSummary> {
854 self.interrupts.clone()
855 }
856 fn tasks(&self) -> Vec<TaskSummary> {
857 self.tasks.clone()
858 }
859}
860
861impl BusView for MockRuntime {
862 fn list_agents(&self) -> Vec<AgentDirectoryEntry> {
863 self.agents.clone()
864 }
865 fn list_roles(&self) -> Vec<RoleBinding> {
866 self.roles.clone()
867 }
868}
869
870impl BrainReviewView for MockRuntime {
871 fn all_decisions(&self) -> Vec<DecisionSummary> {
872 self.decisions.clone()
873 }
874 fn review_queue(&self) -> Vec<ReviewItemSummary> {
875 self.review_queue.clone()
876 }
877}
878
879impl Orchestrator for MockRuntime {
880 fn deliver_mailbox(&self, _sessions: &[SessionSnapshot]) -> Vec<(u32, String)> {
881 Vec::new()
882 }
883 fn deliver_interrupts(&self, _sessions: &[SessionSnapshot]) -> Vec<(String, String)> {
884 Vec::new()
885 }
886 fn expire_stale(&self) {}
887}
888
889impl HiveActions for MockRuntime {
890 fn shared_skill_keys(&self) -> std::collections::HashSet<String> {
891 std::collections::HashSet::new()
892 }
893 fn share_skill(&self, _skill: &crate::skills::DiscoveredSkill) -> Result<String, String> {
894 Err("MockRuntime does not implement skill sharing".into())
895 }
896 fn hive_view_snapshot(&self) -> HiveViewSnapshot {
897 HiveViewSnapshot::default()
898 }
899}
900
901impl Actions for MockRuntime {
902 fn terminate_session(&self, pid: u32) -> Result<(), String> {
903 self.actions_log
904 .lock()
905 .expect("actions_log poisoned")
906 .push(MockAction::Terminate { pid });
907 Ok(())
908 }
909 fn inject_text(&self, session_id: &str, text: &str) -> Result<(), String> {
910 self.actions_log
911 .lock()
912 .expect("actions_log poisoned")
913 .push(MockAction::InjectText {
914 session_id: session_id.into(),
915 text: text.into(),
916 });
917 Ok(())
918 }
919 fn set_gate_mode(&self, mode: BrainGateMode) -> Result<(), String> {
920 *self.gate_mode.lock().expect("gate_mode poisoned") = Some(mode);
921 self.actions_log
922 .lock()
923 .expect("actions_log poisoned")
924 .push(MockAction::SetGateMode(mode));
925 Ok(())
926 }
927 fn log_observation(&self, observation: ObservationInput) -> Result<(), String> {
928 self.actions_log
929 .lock()
930 .expect("actions_log poisoned")
931 .push(MockAction::LogObservation(observation));
932 Ok(())
933 }
934 fn log_decision(&self, input: LogDecisionInput) -> Result<(), String> {
935 self.actions_log
936 .lock()
937 .expect("actions_log poisoned")
938 .push(MockAction::LogDecision(input));
939 Ok(())
940 }
941 fn mark_canonical(&self, decision_id: &str, note: Option<String>) -> Result<(), String> {
942 self.actions_log
943 .lock()
944 .expect("actions_log poisoned")
945 .push(MockAction::MarkCanonical {
946 decision_id: decision_id.into(),
947 note,
948 });
949 Ok(())
950 }
951 fn bind_bus_role(&self, name: &str, cwd: &str, pid: u32) -> Result<(), String> {
952 self.actions_log
953 .lock()
954 .expect("actions_log poisoned")
955 .push(MockAction::BindBusRole {
956 name: name.into(),
957 cwd: cwd.into(),
958 pid,
959 });
960 Ok(())
961 }
962 fn cancel_task(&self, task_id: &str) -> Result<(), String> {
963 self.actions_log
964 .lock()
965 .expect("actions_log poisoned")
966 .push(MockAction::CancelTask {
967 task_id: task_id.into(),
968 });
969 Ok(())
970 }
971 fn set_supervisor_drain(&self, draining: bool) -> Result<(), String> {
972 self.actions_log
973 .lock()
974 .expect("actions_log poisoned")
975 .push(MockAction::SetSupervisorDrain { draining });
976 Ok(())
977 }
978 fn retry_task(&self, task_id: &str) -> Result<(), String> {
979 self.actions_log
980 .lock()
981 .expect("actions_log poisoned")
982 .push(MockAction::RetryTask {
983 task_id: task_id.into(),
984 });
985 Ok(())
986 }
987 fn approve_task(&self, task_id: &str) -> Result<(), String> {
988 self.actions_log
989 .lock()
990 .expect("actions_log poisoned")
991 .push(MockAction::ApproveTask {
992 task_id: task_id.into(),
993 });
994 Ok(())
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn sample_session(id: &str) -> SessionSnapshot {
1003 SessionSnapshot {
1004 session_id: id.into(),
1005 pid: 12345,
1006 cwd: "/work/proj".into(),
1007 project_name: "proj".into(),
1008 status: "Processing".into(),
1009 cost_usd: 1.23,
1010 context_tokens: 4000,
1011 context_max: 200_000,
1012 last_message_ts: 1_780_000_000,
1013 }
1014 }
1015
1016 #[test]
1017 fn mock_runtime_assembles_and_lists_sessions() {
1018 let mock = MockRuntime {
1019 sessions: vec![sample_session("sess_a"), sample_session("sess_b")],
1020 ..Default::default()
1021 };
1022 let rt = mock.into_runtime();
1023 let listed = rt.sessions.list();
1024 assert_eq!(listed.len(), 2);
1025 assert_eq!(listed[0].session_id, "sess_a");
1026 }
1027
1028 #[test]
1029 fn session_detail_falls_back_to_list_scan() {
1030 let mock = MockRuntime {
1031 sessions: vec![sample_session("sess_a"), sample_session("sess_b")],
1032 ..Default::default()
1033 };
1034 let rt = mock.into_runtime();
1035 assert!(rt.sessions.detail("sess_a").is_some());
1036 assert!(rt.sessions.detail("sess_missing").is_none());
1037 }
1038
1039 #[test]
1040 fn brain_view_returns_recent_decisions_with_cap() {
1041 let mock = MockRuntime {
1042 decisions: (0..5)
1043 .map(|i| DecisionSummary {
1044 id: format!("dec_{i}"),
1045 timestamp: format!("2026-06-06T00:00:0{i}Z"),
1046 action: "approve".into(),
1047 confidence: Some(0.9),
1048 project: None,
1049 tool: None,
1050 pid: 0,
1051 command: None,
1052 reasoning: None,
1053 user_action: None,
1054 override_reason: None,
1055 brain_decision_ms: None,
1056 canonical: None,
1057 cache_hit: None,
1058 cost_usd: None,
1059 model: None,
1060 outcome_kind: None,
1061 outcome_detail: None,
1062 suggested_at: None,
1063 resolved_at: None,
1064 why: String::new(),
1065 decision_source: None,
1066 rule_name: None,
1067 })
1068 .collect(),
1069 ..Default::default()
1070 };
1071 let rt = mock.into_runtime();
1072 assert_eq!(rt.brain.recent_decisions(3).len(), 3);
1073 assert_eq!(rt.brain.decision_count(), 5);
1074 assert_eq!(rt.brain.gate_mode(), BrainGateMode::On);
1075 }
1076
1077 #[test]
1078 fn coord_view_reports_empty_state_cleanly() {
1079 let rt = MockRuntime::default().into_runtime();
1080 assert!(rt.coord.active_leases().is_empty());
1081 assert!(rt.coord.pending_handoffs().is_empty());
1082 assert!(rt.coord.pending_interrupts().is_empty());
1083 assert!(rt.coord.tasks().is_empty());
1084 }
1085
1086 #[test]
1087 fn coord_view_returns_task_fixture() {
1088 let mock = MockRuntime {
1089 tasks: vec![TaskSummary {
1090 id: "t1".into(),
1091 name: "ship the thing".into(),
1092 state: "running".into(),
1093 role: Some("backend".into()),
1094 attempts: 2,
1095 max_retries: 3,
1096 last_session_id: Some("sess-abcdef12".into()),
1097 last_verdict: Some("PASS".into()),
1098 cost_usd: 1.25,
1099 created_at: "2026-06-25T10:00:00Z".into(),
1100 updated_at: "2026-06-25T10:05:00Z".into(),
1101 }],
1102 ..Default::default()
1103 };
1104 let rt = mock.into_runtime();
1105 let tasks = rt.coord.tasks();
1106 assert_eq!(tasks.len(), 1);
1107 assert_eq!(tasks[0].state, "running");
1108 assert_eq!(tasks[0].attempts, 2);
1109 }
1110
1111 #[test]
1112 fn bus_view_reports_empty_state_cleanly() {
1113 let rt = MockRuntime::default().into_runtime();
1114 assert!(rt.bus.list_agents().is_empty());
1115 assert!(rt.bus.list_roles().is_empty());
1116 }
1117
1118 /// Smoke test that the trait shapes are usable behind `dyn`. If this
1119 /// compiles, the boxed-trait composition works.
1120 #[test]
1121 fn runtime_implements_clone_and_send() {
1122 fn assert_send<T: Send>() {}
1123 assert_send::<Runtime>();
1124
1125 let rt = MockRuntime::default().into_runtime();
1126 let _rt2 = rt.clone();
1127 }
1128
1129 #[test]
1130 fn actions_terminate_records_in_log() {
1131 let rt = MockRuntime::default().into_runtime();
1132 rt.actions.terminate_session(42).unwrap();
1133 rt.actions.terminate_session(43).unwrap();
1134 // No public mock accessor on Arc<dyn Actions>, but we can downcast
1135 // through the trait by going via the *original* Arc through inspecting
1136 // the trait's behavior — the integration test in the binary covers
1137 // that surface. Here we just assert the calls succeeded.
1138 }
1139
1140 #[test]
1141 fn actions_task_writes_record_in_log() {
1142 let mock = MockRuntime::default();
1143 // MockRuntime itself implements Actions; call directly so we can read
1144 // the recorded log via the public `actions()` accessor.
1145 mock.cancel_task("t-1").unwrap();
1146 mock.set_supervisor_drain(true).unwrap();
1147 mock.retry_task("t-2").unwrap();
1148 mock.approve_task("t-3").unwrap();
1149 let actions = mock.actions();
1150 assert!(matches!(&actions[0], MockAction::CancelTask { task_id } if task_id == "t-1"));
1151 assert!(matches!(
1152 actions[1],
1153 MockAction::SetSupervisorDrain { draining: true }
1154 ));
1155 assert!(matches!(&actions[2], MockAction::RetryTask { task_id } if task_id == "t-2"));
1156 assert!(matches!(&actions[3], MockAction::ApproveTask { task_id } if task_id == "t-3"));
1157 }
1158
1159 #[test]
1160 fn actions_set_gate_mode_round_trips_through_brain_view() {
1161 let mock = MockRuntime::default();
1162 let arc = Arc::new(mock);
1163 let rt = Runtime::new(
1164 arc.clone(),
1165 arc.clone(),
1166 arc.clone(),
1167 arc.clone(),
1168 arc.clone(),
1169 arc.clone(),
1170 arc.clone(),
1171 arc.clone(),
1172 );
1173 // Initial: default On.
1174 assert_eq!(rt.brain.gate_mode(), BrainGateMode::On);
1175 // Flip via Actions; observe via BrainView.
1176 rt.actions.set_gate_mode(BrainGateMode::Off).unwrap();
1177 assert_eq!(rt.brain.gate_mode(), BrainGateMode::Off);
1178 rt.actions.set_gate_mode(BrainGateMode::Auto).unwrap();
1179 assert_eq!(rt.brain.gate_mode(), BrainGateMode::Auto);
1180 // And the log captured both transitions.
1181 let calls = arc.actions();
1182 assert_eq!(
1183 calls,
1184 vec![
1185 MockAction::SetGateMode(BrainGateMode::Off),
1186 MockAction::SetGateMode(BrainGateMode::Auto),
1187 ]
1188 );
1189 }
1190
1191 #[test]
1192 fn actions_log_observation_captures_full_input() {
1193 let mock = MockRuntime::default();
1194 let arc = Arc::new(mock);
1195 let rt = Runtime::new(
1196 arc.clone(),
1197 arc.clone(),
1198 arc.clone(),
1199 arc.clone(),
1200 arc.clone(),
1201 arc.clone(),
1202 arc.clone(),
1203 arc.clone(),
1204 );
1205 let obs = ObservationInput {
1206 session_pid: 12345,
1207 project: "claudectl".into(),
1208 tool: Some("Bash".into()),
1209 command: Some("cargo test".into()),
1210 observed_action: "user_approve".into(),
1211 };
1212 rt.actions.log_observation(obs.clone()).unwrap();
1213 let calls = arc.actions();
1214 assert_eq!(calls, vec![MockAction::LogObservation(obs)]);
1215 }
1216
1217 #[test]
1218 fn actions_inject_text_and_mark_canonical_record_inputs() {
1219 let mock = MockRuntime::default();
1220 let arc = Arc::new(mock);
1221 let rt = Runtime::new(
1222 arc.clone(),
1223 arc.clone(),
1224 arc.clone(),
1225 arc.clone(),
1226 arc.clone(),
1227 arc.clone(),
1228 arc.clone(),
1229 arc.clone(),
1230 );
1231 rt.actions.inject_text("sess_a", "/compact\n").unwrap();
1232 rt.actions
1233 .mark_canonical("dec_42", Some("nice catch".into()))
1234 .unwrap();
1235 let calls = arc.actions();
1236 assert_eq!(
1237 calls,
1238 vec![
1239 MockAction::InjectText {
1240 session_id: "sess_a".into(),
1241 text: "/compact\n".into(),
1242 },
1243 MockAction::MarkCanonical {
1244 decision_id: "dec_42".into(),
1245 note: Some("nice catch".into()),
1246 },
1247 ]
1248 );
1249 }
1250}