yothere 1.5.2__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 (70) hide show
  1. yothere/__init__.py +10 -0
  2. yothere/attention.py +236 -0
  3. yothere/board.py +220 -0
  4. yothere/brain/__init__.py +16 -0
  5. yothere/brain/registry.py +196 -0
  6. yothere/brain_advance.py +336 -0
  7. yothere/capture.py +158 -0
  8. yothere/card.py +297 -0
  9. yothere/cli.py +624 -0
  10. yothere/config.py +268 -0
  11. yothere/demo_brain.py +167 -0
  12. yothere/doctor.py +342 -0
  13. yothere/envcompat.py +59 -0
  14. yothere/experiments/fixtures.jsonl +18 -0
  15. yothere/fleet_sources/__init__.py +7 -0
  16. yothere/fleet_sources/remote.py +86 -0
  17. yothere/fleet_state.py +263 -0
  18. yothere/listen.py +390 -0
  19. yothere/llm.py +62 -0
  20. yothere/maintenance.py +190 -0
  21. yothere/mcp_register.py +200 -0
  22. yothere/mcp_server.py +121 -0
  23. yothere/notify.py +47 -0
  24. yothere/onboarding.py +501 -0
  25. yothere/presence.py +157 -0
  26. yothere/presets.py +161 -0
  27. yothere/pwa.py +295 -0
  28. yothere/ratelimit.py +143 -0
  29. yothere/render.py +518 -0
  30. yothere/runner.py +659 -0
  31. yothere/runner_watchdog.py +157 -0
  32. yothere/scope.py +293 -0
  33. yothere/service.py +429 -0
  34. yothere/spawn.py +123 -0
  35. yothere/store.py +248 -0
  36. yothere/task_source.py +721 -0
  37. yothere/tenant_supervisor.py +189 -0
  38. yothere/thread_model/__init__.py +30 -0
  39. yothere/thread_model/ask.py +171 -0
  40. yothere/thread_model/autonomy.py +171 -0
  41. yothere/thread_model/prompts/thread-system.md +174 -0
  42. yothere/thread_model/resume.py +542 -0
  43. yothere/thread_model/thread.py +357 -0
  44. yothere/thread_model/vt_config.py +182 -0
  45. yothere/thread_model/worker_policy.py +97 -0
  46. yothere/voice.py +418 -0
  47. yothere/voicecall/__init__.py +29 -0
  48. yothere/voicecall/auth.py +547 -0
  49. yothere/voicecall/bob_brain.py +392 -0
  50. yothere/voicecall/config.py +335 -0
  51. yothere/voicecall/dispatch.py +58 -0
  52. yothere/voicecall/echo_brain.py +120 -0
  53. yothere/voicecall/headless_call.py +823 -0
  54. yothere/voicecall/overview_state.py +428 -0
  55. yothere/voicecall/pipeline.py +720 -0
  56. yothere/voicecall/remote_brain.py +399 -0
  57. yothere/voicecall/requirements.lock +127 -0
  58. yothere/voicecall/requirements.txt +19 -0
  59. yothere/voicecall/session_manager.py +1808 -0
  60. yothere/voicecall/ubob_brain.py +333 -0
  61. yothere/voicecall/webrtc_server.py +2174 -0
  62. yothere/web.py +42 -0
  63. yothere/worker.py +156 -0
  64. yothere/worker_hooks/__init__.py +72 -0
  65. yothere/worker_hooks/send_deny_gate.py +311 -0
  66. yothere-1.5.2.dist-info/METADATA +201 -0
  67. yothere-1.5.2.dist-info/RECORD +70 -0
  68. yothere-1.5.2.dist-info/WHEEL +4 -0
  69. yothere-1.5.2.dist-info/entry_points.txt +4 -0
  70. yothere-1.5.2.dist-info/licenses/LICENSE +21 -0
yothere/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """yothere — an ambient-agent cockpit (formerly relay-cockpit).
2
+
3
+ Importing the package installs the YOTHERE_* <-> RELAY_* env aliasing so a host
4
+ that still exports the legacy RELAY_* names keeps working unchanged. This is a
5
+ pure os.environ read/fill with no filesystem side effects (importing yothere.*
6
+ stays side-effect-free for the config/onboarding modules that rely on it).
7
+ """
8
+ from yothere.envcompat import apply_process_aliases as _apply_env_aliases
9
+
10
+ _apply_env_aliases()
yothere/attention.py ADDED
@@ -0,0 +1,236 @@
1
+ """Relay attention router — the moat.
2
+
3
+ Camp-A cockpits decide which AGENT runs next. Relay decides which thread wants
4
+ the HUMAN's eyes next, and protects focus while the rest of the fleet churns.
5
+ This module is that decision, kept deterministic and offline-first so it is
6
+ unit-testable and never depends on a model being reachable. The moat is this
7
+ deterministic ranking — NOT an online learning loop (there is none; nothing
8
+ here adapts from past recommendations).
9
+
10
+ This module's one responsibility: rank_fleet / recommend_focus — score every
11
+ normalized fleet row, order them, and name the one thread Phil should look at
12
+ next.
13
+
14
+ The Licklider ping guardrails (pull-on-glance default, quiet hours, focus
15
+ protection, rate-limit) live in the runner's inline gate, not here.
16
+
17
+ Autonomous advance selection (which non-focus threads to progress while Phil is
18
+ busy) is owned solely by runner.plan_tick, not this module.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import datetime as _dt
23
+ from dataclasses import dataclass
24
+
25
+ try: # zoneinfo is stdlib on 3.9+; degrade to host-local time if tz is unknown
26
+ from zoneinfo import ZoneInfo
27
+ except Exception: # pragma: no cover - zoneinfo always present on our runtime
28
+ ZoneInfo = None # type: ignore
29
+
30
+ # The fleet state vocabulary is owned by fleet_state (the normalization layer);
31
+ # import it here instead of re-typing the literals.
32
+ from yothere.fleet_state import BLOCKED, IDLE, READY, RUNNING
33
+
34
+ # State base scores. Blocked beats ready: a blocked thread is stalled ON Phil
35
+ # and burns no compute waiting; a ready one is finished and can sit.
36
+ _STATE_BASE = {BLOCKED: 40.0, READY: 30.0, RUNNING: 5.0, IDLE: 0.0}
37
+
38
+
39
+ @dataclass
40
+ class RouterConfig:
41
+ timezone: str = "Europe/Berlin"
42
+ quiet_start_hour: int = 22
43
+ quiet_end_hour: int = 8
44
+ max_concurrent_advances: int = 2
45
+ min_advance_interval_s: int = 180
46
+ close_epsilon: float = 8.0
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ScoreBreakdown:
51
+ row_id: str
52
+ source: str
53
+ title: str
54
+ state: str
55
+ needs_eyes: bool
56
+ total: float
57
+ parts: dict
58
+ reasons: tuple
59
+ focus: bool = False
60
+
61
+
62
+ def _now() -> _dt.datetime:
63
+ return _dt.datetime.now(_dt.timezone.utc).astimezone()
64
+
65
+
66
+ def _parse_iso(s: str | None) -> _dt.datetime | None:
67
+ if not s:
68
+ return None
69
+ try:
70
+ dt = _dt.datetime.fromisoformat(s)
71
+ except ValueError:
72
+ return None
73
+ if dt.tzinfo is None:
74
+ dt = dt.replace(tzinfo=_dt.timezone.utc)
75
+ return dt
76
+
77
+
78
+ def _age_minutes(row: dict, now: _dt.datetime) -> float:
79
+ dt = _parse_iso(row.get("updated_at"))
80
+ if not dt:
81
+ return 0.0
82
+ return max(0.0, (now - dt).total_seconds() / 60.0)
83
+
84
+
85
+ def score_row(row: dict, now: _dt.datetime) -> ScoreBreakdown:
86
+ """Additive weighted score: higher = wants Phil's eyes more, now."""
87
+ parts: dict = {}
88
+ reasons: list[str] = []
89
+
90
+ if row.get("needs_eyes"):
91
+ parts["needs_eyes"] = 100.0
92
+ reasons.append("needs your eyes")
93
+ else:
94
+ parts["needs_eyes"] = 0.0
95
+
96
+ state = row.get("state", RUNNING)
97
+ parts["state"] = _STATE_BASE.get(state, 0.0)
98
+
99
+ priority = (row.get("priority") or "normal").lower()
100
+ if priority == "urgent":
101
+ parts["urgency"] = 50.0
102
+ reasons.append("urgent")
103
+ elif priority == "high":
104
+ parts["urgency"] = 15.0
105
+ reasons.append("high priority")
106
+ elif priority == "low":
107
+ parts["urgency"] = -10.0
108
+ else:
109
+ parts["urgency"] = 0.0
110
+
111
+ # Deliverable is a TIEBREAK among ready threads, deliberately smaller than the
112
+ # blocked-vs-ready base gap (40-30=10) so a finished artifact never outranks a
113
+ # blocked thread that is actively stalling the fleet on Phil.
114
+ deliverable = state == READY and bool(row.get("artifacts") or row.get("last_progress"))
115
+ parts["deliverable"] = 8.0 if deliverable else 0.0
116
+ if deliverable:
117
+ reasons.append("has a finished artifact")
118
+
119
+ age = _age_minutes(row, now)
120
+ parts["staleness"] = min(15.0, age * 0.05)
121
+
122
+ if row.get("focus"):
123
+ parts["focus_pin"] = 12.0
124
+ reasons.append("you pinned this")
125
+ else:
126
+ parts["focus_pin"] = 0.0
127
+
128
+ total = round(sum(parts.values()), 3)
129
+ if state == BLOCKED:
130
+ reasons.append("blocked, waiting on you")
131
+ elif state == READY and not deliverable:
132
+ reasons.append("finished")
133
+ return ScoreBreakdown(
134
+ row_id=row["id"], source=row.get("source", "relay"), title=row.get("title", row["id"]),
135
+ state=state, needs_eyes=bool(row.get("needs_eyes")), total=total,
136
+ parts=parts, reasons=tuple(reasons), focus=bool(row.get("focus")),
137
+ )
138
+
139
+
140
+ def rank_fleet(rows: list[dict], now: _dt.datetime | None = None,
141
+ cfg: RouterConfig | None = None) -> list[ScoreBreakdown]:
142
+ # cfg is accepted for caller parity but no longer read here: scoring is
143
+ # cfg-free and recommend_focus owns the close_epsilon read.
144
+ now = now or _now()
145
+ scored = [score_row(r, now) for r in rows]
146
+ # Sort: score desc, then needs_eyes, then older-first (updated_at), then id.
147
+ age_by_id = {r["id"]: _age_minutes(r, now) for r in rows}
148
+ scored.sort(key=lambda s: (-s.total, not s.needs_eyes, -age_by_id.get(s.row_id, 0.0), s.row_id))
149
+ return scored
150
+
151
+
152
+ def recommend_focus(ranked: list[ScoreBreakdown], cfg: RouterConfig | None = None) -> dict | None:
153
+ """The single 'look at this next' rec.
154
+
155
+ Focus is single-source: the meta `focus` flag carried on each row (set by
156
+ `relay focus`/`spawn --focus`). A manually pinned thread wins even if it
157
+ ranks below the deterministic top; `margin` is signed (negative means the
158
+ pin overrode a higher-scored thread) and `pin_overrode_top` flags that so a
159
+ caller never mistakes the margin for an absolute gap.
160
+ """
161
+ cfg = cfg or RouterConfig()
162
+ if not ranked:
163
+ return None
164
+ pinned = next((s for s in ranked if s.focus), None)
165
+ if pinned is not None:
166
+ top, source_reason = pinned, "manual pin"
167
+ else:
168
+ top, source_reason = ranked[0], "top-ranked"
169
+ # Runner-up is the best score among the OTHER threads (always the true next-best).
170
+ runner_up = max((s.total for s in ranked if s.row_id != top.row_id), default=0.0)
171
+ margin = round(top.total - runner_up, 3)
172
+ pin_overrode_top = source_reason == "manual pin" and top.row_id != ranked[0].row_id
173
+ if pin_overrode_top:
174
+ confidence = "pinned"
175
+ elif len(ranked) == 1:
176
+ # A one-thread fleet is not a contest: do not imply a margin it never had.
177
+ confidence = "only"
178
+ else:
179
+ confidence = "close" if margin <= cfg.close_epsilon else "clear"
180
+ return {
181
+ "focus_id": top.row_id,
182
+ "focus_title": top.title,
183
+ "focus_source": source_reason,
184
+ "score": top.total,
185
+ "runner_up_score": runner_up,
186
+ "margin": margin,
187
+ "pin_overrode_top": pin_overrode_top,
188
+ "confidence": confidence,
189
+ "reasons": list(top.reasons),
190
+ }
191
+
192
+
193
+ # ── Licklider guardrails ─────────────────────────────────────────────────────
194
+
195
+ def in_quiet_hours(now: _dt.datetime, cfg: RouterConfig) -> bool:
196
+ # Quiet hours are defined in cfg.timezone (Berlin by default), not host-local
197
+ # time. Convert before reading the hour so a runner on a non-Berlin clock
198
+ # still respects Phil's nights. Read the field defensively: runner passes a
199
+ # RunnerConfig that may lack .timezone, and an unknown tz falls back to
200
+ # host-local, so neither caller breaks.
201
+ tz = getattr(cfg, "timezone", None)
202
+ if tz and ZoneInfo is not None:
203
+ try:
204
+ now = now.astimezone(ZoneInfo(tz))
205
+ except Exception:
206
+ pass
207
+ h = now.hour
208
+ if cfg.quiet_start_hour <= cfg.quiet_end_hour:
209
+ return cfg.quiet_start_hour <= h < cfg.quiet_end_hour
210
+ return h >= cfg.quiet_start_hour or h < cfg.quiet_end_hour
211
+
212
+
213
+ if __name__ == "__main__":
214
+ cfg = RouterConfig()
215
+ # Fixed afternoon time so the selftest is deterministic (never trips quiet hours).
216
+ now = _dt.datetime(2026, 6, 8, 14, 0, tzinfo=_dt.timezone.utc).astimezone()
217
+ rows = [
218
+ {"id": "a", "title": "Vault synthesis", "state": "blocked", "needs_eyes": True,
219
+ "priority": "normal", "updated_at": now.isoformat()},
220
+ {"id": "b", "title": "News scan", "state": "running", "needs_eyes": False,
221
+ "priority": "normal", "updated_at": (now - _dt.timedelta(minutes=5)).isoformat()},
222
+ {"id": "c", "title": "LinkedIn draft", "state": "ready", "needs_eyes": True,
223
+ "priority": "normal", "artifacts": ["x.md"], "updated_at": now.isoformat()},
224
+ {"id": "d", "title": "Background crawl", "state": "running", "needs_eyes": False,
225
+ "priority": "low", "updated_at": (now - _dt.timedelta(minutes=20)).isoformat()},
226
+ ]
227
+ ranked = rank_fleet(rows, now, cfg)
228
+ assert ranked[0].row_id == "a", [s.row_id for s in ranked] # blocked+eyes wins
229
+ rec = recommend_focus(ranked, cfg)
230
+ assert rec["focus_id"] == "a"
231
+ # Advance selection lives in runner.plan_tick, and the ping guardrails live in
232
+ # the runner's inline gate, not here — see runner.py.
233
+ # A one-thread fleet is reported as "only", not a fake "clear" contest.
234
+ solo = recommend_focus(rank_fleet([rows[0]], now, cfg), cfg)
235
+ assert solo["confidence"] == "only", solo
236
+ print("attention selftest ok")
yothere/board.py ADDED
@@ -0,0 +1,220 @@
1
+ """Relay board — the standalone glance surface.
2
+
3
+ The Mission Control "Fleet" card is the at-a-glance summary embedded in the
4
+ Personal OS dashboard. THIS is the dedicated, richer board: the focus
5
+ recommendation up top, then every thread as a card with its state, live
6
+ progress line, needs-eyes badge, deliverable link, age, and source. It is the
7
+ screen half of Relay's voice-primary / screen-secondary model.
8
+
9
+ Read-only and fail-soft: it reads the fleet via fleet_state.load_fleet() and
10
+ ranks via attention. It NEVER writes thread state and never touches
11
+ reports/mission-control.html (that stays the dashboard's file). Every dynamic
12
+ field is run through html.escape — worker-authored progress strings are
13
+ attacker-influenced (an LLM emits arbitrary text), so this is load-bearing, not
14
+ cosmetic. No PAGE.format(): the HTML is concatenated, so there is no brace trap.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import datetime as _dt
19
+ import html as _html
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from yothere import attention as _att # noqa: E402
24
+ from yothere import card as _cardmod # noqa: E402 (module alias; _card() below is the row renderer)
25
+ from yothere.config import settings # noqa: E402
26
+ from yothere.fleet_state import BLOCKED, IDLE, READY, RUNNING, load_fleet # noqa: E402
27
+
28
+ OUT = settings.board_html_file
29
+
30
+ STATE_COLOR = {RUNNING: "#b58900", READY: "#2aa198", BLOCKED: "#dc322f", IDLE: "#8c8c8a"}
31
+ STATE_LABEL = {RUNNING: "WORKING", READY: "READY", BLOCKED: "BLOCKED", IDLE: "PARKED"}
32
+
33
+
34
+ def esc(s: object) -> str:
35
+ return _html.escape(str(s if s is not None else ""))
36
+
37
+
38
+ def _age(updated_at: str, now: _dt.datetime) -> str:
39
+ # Reuse attention._parse_iso so ISO parse + tz-coerce lives in one place.
40
+ dt = _att._parse_iso(updated_at)
41
+ if not dt:
42
+ return ""
43
+ secs = (now - dt).total_seconds()
44
+ if secs < 90:
45
+ return "just now"
46
+ if secs < 5400:
47
+ return f"{round(secs / 60)}m ago"
48
+ if secs < 36 * 3600:
49
+ return f"{round(secs / 3600)}h ago"
50
+ return f"{round(secs / 86400)}d ago"
51
+
52
+
53
+ def _card(row: dict, now: _dt.datetime, is_focus: bool) -> str:
54
+ # Axis 3: a thread carrying a structured plan gets the rich progressive card
55
+ # (server-rendered from card.py, the single owner). card.esc() inside still
56
+ # escapes every worker-authored field. Threads without a plan keep the flat
57
+ # card below. build_plan_view returns None when there is no plan.
58
+ view = _cardmod.build_plan_view(row)
59
+ if view:
60
+ rich = _cardmod.render_card(view)
61
+ if is_focus:
62
+ rich = rich.replace('<div class="card">', '<div class="card focus">', 1)
63
+ return rich
64
+ state = row.get("state", RUNNING)
65
+ color = STATE_COLOR.get(state, "#8c8c8a")
66
+ label = STATE_LABEL.get(state, state.upper())
67
+ eye = '<span class="eye">needs eyes</span>' if row.get("needs_eyes") else ""
68
+ focus_cls = " focus" if is_focus else ""
69
+ age = _age(row.get("updated_at", ""), now)
70
+ prog = (row.get("last_progress") or "").strip()
71
+ prog_html = f'<div class="prog">{esc(prog[:200])}</div>' if prog else ""
72
+ # A blocked thread shows the exact decision it is waiting on, so the board
73
+ # says WHAT it needs, not just the last progress line. Worker-authored -> esc.
74
+ q_html = ""
75
+ if state == BLOCKED:
76
+ question = (row.get("question") or "").strip()
77
+ if question:
78
+ opts = [str(o).strip() for o in (row.get("options") or []) if str(o).strip()]
79
+ opt_html = ""
80
+ if opts:
81
+ chips = "".join(f'<span class="opt">{esc(o[:40])}</span>' for o in opts[:4])
82
+ opt_html = f'<div class="opts">{chips}</div>'
83
+ q_html = (f'<div class="ask"><span class="ask-l">needs</span> '
84
+ f'{esc(question[:240])}</div>{opt_html}')
85
+ arts = row.get("artifacts") or []
86
+ art_html = ""
87
+ if arts:
88
+ links = " · ".join(esc(Path(str(a)).name) for a in arts[:4])
89
+ art_html = f'<div class="arts">📄 {links}</div>'
90
+ src = esc(row.get("source", "relay"))
91
+ return (
92
+ f'<div class="card{focus_cls}">'
93
+ f'<div class="top"><span class="dot" style="background:{color}"></span>'
94
+ f'<span class="title">{esc(row.get("title", row.get("id", "thread")))}</span>{eye}</div>'
95
+ f'<div class="meta"><span class="state" style="color:{color}">{label}</span>'
96
+ f'<span class="src">{src}</span><span class="age">{esc(age)}</span></div>'
97
+ f'{q_html}{prog_html}{art_html}</div>'
98
+ )
99
+
100
+
101
+ def render_board(fleet: list[dict], now: _dt.datetime | None = None) -> str:
102
+ now = now or _dt.datetime.now(_dt.timezone.utc).astimezone()
103
+ cfg = _att.RouterConfig()
104
+ ranked = _att.rank_fleet(fleet, now, cfg)
105
+ rec = _att.recommend_focus(ranked, cfg)
106
+ order = {s.row_id: i for i, s in enumerate(ranked)}
107
+ fleet_sorted = sorted(fleet, key=lambda r: order.get(r["id"], 999))
108
+
109
+ need = sum(1 for r in fleet if r.get("needs_eyes"))
110
+ running = sum(1 for r in fleet if r.get("state") == RUNNING)
111
+
112
+ if rec:
113
+ reasons = ", ".join(rec.get("reasons") or []) or "top of the queue"
114
+ focus_html = (
115
+ f'<div class="focus-banner"><span class="fl">FOCUS</span>'
116
+ f'<span class="ft">{esc(rec["focus_title"])}</span>'
117
+ f'<span class="fr">{esc(reasons)}</span></div>'
118
+ )
119
+ else:
120
+ focus_html = ('<div class="focus-banner empty"><span class="ft">'
121
+ 'Fleet is clear. Nothing needs your eyes.</span></div>')
122
+
123
+ cards = "".join(_card(r, now, r["id"] == (rec or {}).get("focus_id")) for r in fleet_sorted)
124
+ if not fleet:
125
+ cards = ('<div class="empty-state">No agent threads in flight. '
126
+ 'Speak or type <code>relay spawn</code> to start one.</div>')
127
+
128
+ sub = f"{len(fleet)} threads · {running} working · {need} need eyes"
129
+ ts = now.strftime("%a %d %b · %H:%M:%S")
130
+
131
+ return (
132
+ "<!DOCTYPE html><html lang=en><head><meta charset=UTF-8>"
133
+ "<meta name=viewport content='width=device-width, initial-scale=1'>"
134
+ "<meta name=robots content='noindex, nofollow'>"
135
+ "<meta http-equiv=refresh content=15>"
136
+ "<title>Relay · Fleet</title>"
137
+ "<style>"
138
+ "*{box-sizing:border-box;margin:0;padding:0}"
139
+ "body{font:15px/1.5 -apple-system,Inter,Segoe UI,sans-serif;background:#0f1419;color:#e6e6e6;padding:24px;max-width:900px;margin:0 auto}"
140
+ "header{display:flex;align-items:baseline;gap:14px;margin-bottom:18px}"
141
+ "h1{font-size:18px;font-weight:700;letter-spacing:-.01em}"
142
+ ".sub{color:#8b98a5;font-size:13px}.ts{margin-left:auto;color:#5c6670;font-size:12px}"
143
+ ".focus-banner{display:flex;align-items:center;gap:12px;background:linear-gradient(90deg,#1e2630,#1a212b);"
144
+ "border:1px solid #2c3744;border-left:3px solid #D97757;border-radius:10px;padding:13px 16px;margin-bottom:16px}"
145
+ ".focus-banner.empty{border-left-color:#2aa198}"
146
+ ".fl{font-size:10px;font-weight:700;letter-spacing:.08em;color:#D97757;background:#2a1d18;padding:3px 8px;border-radius:5px}"
147
+ ".ft{font-weight:600;font-size:15px}.fr{margin-left:auto;color:#8b98a5;font-size:12px;text-align:right}"
148
+ ".grid{display:flex;flex-direction:column;gap:10px}"
149
+ ".card{background:#1a212b;border:1px solid #232c38;border-radius:10px;padding:13px 16px}"
150
+ ".card.focus{border-color:#D97757;box-shadow:0 0 0 1px #D9775733}"
151
+ ".top{display:flex;align-items:center;gap:9px}"
152
+ ".dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}"
153
+ ".title{font-weight:600;font-size:14.5px}"
154
+ ".eye{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.05em;color:#dc322f;background:#2a1517;padding:2px 7px;border-radius:5px}"
155
+ ".meta{display:flex;gap:12px;align-items:center;margin-top:5px;font-size:11.5px}"
156
+ ".state{font-weight:700;letter-spacing:.04em}.src{color:#5c6670}.age{margin-left:auto;color:#5c6670;font-variant-numeric:tabular-nums}"
157
+ ".prog{margin-top:8px;font-size:13px;color:#c4cdd6}"
158
+ ".ask{margin-top:8px;font-size:13px;color:#e6e6e6;background:#241a17;"
159
+ "border-left:2px solid #dc322f;border-radius:0 6px 6px 0;padding:6px 10px}"
160
+ ".ask-l{font-size:10px;font-weight:700;letter-spacing:.05em;color:#dc322f;margin-right:6px;text-transform:uppercase}"
161
+ ".opts{margin-top:5px;display:flex;gap:6px;flex-wrap:wrap}"
162
+ ".opt{font-size:11px;color:#c4cdd6;background:#1a212b;border:1px solid #2c3744;border-radius:5px;padding:2px 8px}"
163
+ ".arts{margin-top:6px;font-size:12px;color:#2aa198}"
164
+ "a.act-link{margin-left:14px;color:#D97757;font-size:12px;text-decoration:none;font-weight:600}"
165
+ "a.act-link:hover{text-decoration:underline}"
166
+ ".empty-state{color:#5c6670;text-align:center;padding:48px 0;font-size:14px}"
167
+ ".empty-state code{background:#1a212b;padding:2px 7px;border-radius:5px;color:#c4cdd6}"
168
+ # Card-scoped CSS from card.py so the rich progressive cards (badge, goal,
169
+ # step tree, next-action) render on the live board, in one place.
170
+ f"{_cardmod.CARD_CSS}"
171
+ "</style></head><body>"
172
+ f"<header><h1>Relay · Fleet</h1><span class=sub>{esc(sub)}</span>"
173
+ f"<a class=act-link href='http://127.0.0.1:8787/'>act &rarr;</a>"
174
+ f"<span class=ts>updated {esc(ts)} · 15s</span></header>"
175
+ f"{focus_html}<div class=grid>{cards}</div></body></html>"
176
+ )
177
+
178
+
179
+ def build(open_after: bool = False) -> Path:
180
+ # Scope the board to the active user (settings.user). "" -> None -> no
181
+ # filtering (today's behaviour for the single-user default).
182
+ fleet = load_fleet(user=settings.user or None)
183
+ OUT.write_text(render_board(fleet), encoding="utf-8")
184
+ if open_after:
185
+ import subprocess
186
+ subprocess.run(["open", str(OUT)], check=False)
187
+ return OUT
188
+
189
+
190
+ if __name__ == "__main__":
191
+ if len(sys.argv) > 1 and sys.argv[1] == "selftest":
192
+ now = _dt.datetime(2026, 6, 8, 14, 0, tzinfo=_dt.timezone.utc).astimezone()
193
+ # Empty fleet renders a clean empty state, no crash.
194
+ empty = render_board([], now)
195
+ assert "in flight" in empty and "FOCUS" not in empty
196
+ # XSS attempt in a worker-authored field must be escaped — including the
197
+ # blocked thread's question + options, which are also LLM-authored.
198
+ evil = [{"id": "x", "title": "<script>alert(1)</script>", "state": "blocked",
199
+ "needs_eyes": True, "last_progress": "<img src=x onerror=alert(2)>",
200
+ "question": "<b>which</b> path: A or B?",
201
+ "options": ["<i>opt A</i>", "opt B"],
202
+ "updated_at": now.isoformat(), "artifacts": ["a/b/c.md"]}]
203
+ html = render_board(evil, now)
204
+ # The only real vector is an unescaped tag delimiter. All payloads must
205
+ # appear ONLY in escaped form (inert text), never as live tags.
206
+ assert "<script>alert(1)" not in html, "raw <script> tag leaked"
207
+ assert "<img src=x onerror" not in html, "raw <img> tag leaked"
208
+ assert "<b>which" not in html and "<i>opt A" not in html, "raw question/option tag leaked"
209
+ assert "&lt;script&gt;alert(1)" in html and "&lt;img src=x onerror" in html
210
+ assert "c.md" in html # artifact basename, path stripped
211
+ assert "FOCUS" in html and "needs eyes" in html
212
+ # The blocked thread surfaces its question + options, and the board links
213
+ # to the cockpit for actions.
214
+ assert "which" in html and "path: A or B" in html, "blocked question not shown"
215
+ assert "opt B" in html, "blocked options not shown"
216
+ assert "127.0.0.1:8787" in html, "cockpit deep-link missing"
217
+ print("board selftest ok")
218
+ else:
219
+ out = build(open_after="--open" in sys.argv)
220
+ print(f"built relay board -> {out}")
@@ -0,0 +1,16 @@
1
+ """Brain registry — named brains a Relay thread can target.
2
+
3
+ A thread no longer has to run on the single hardcoded RELAY_REMOTE_BRAIN_URL.
4
+ ``brains.yaml`` (at ``settings.brains_file``) maps a brain name to a connection
5
+ descriptor (local claude, a ubob daemon, or any Brain-Protocol endpoint); a
6
+ thread picks one by setting its meta ``brain`` key, and the fleet path resolves
7
+ the harness + url + token from this registry.
8
+
9
+ Back-compat: with no brains.yaml the registry falls back to today's env-driven
10
+ behaviour, so existing single-brain fleets are unaffected.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from yothere.brain.registry import BrainDescriptor, load_brains, resolve
15
+
16
+ __all__ = ["BrainDescriptor", "load_brains", "resolve"]