froid-loop 0.11.1__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 (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/tui/data.py ADDED
@@ -0,0 +1,840 @@
1
+ """Read-only filesystem observation layer for the TUI.
2
+
3
+ Everything the dashboard shows comes from the run-dir artifacts the engine
4
+ already writes atomically: state.json (os.replace), journal.jsonl
5
+ (append-only), logs/<task-id>.log, ATTENTION. This module never imports
6
+ textual — it is plain stdlib + core modules + pyte/rich, fully unit-testable,
7
+ and the screens own the poll cadence.
8
+
9
+ The run inventory itself (``discover_runs`` and its status/liveness closure) now
10
+ lives in :mod:`froid_loop.runs` — the core CLI's ``list`` needs it and must not
11
+ drag pyte/rich in with it (#650). It is re-exported below, so ``data.<name>``
12
+ stays the TUI-facing spelling.
13
+
14
+ All readers are stat-gated: parse results are cached while the file's
15
+ (mtime_ns, size, inode) is unchanged. Liveness is the exception — a dying engine
16
+ changes no file, so the pid is re-checked on every call.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import bisect
22
+ import json
23
+ import re
24
+ from collections import deque
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import pyte
30
+ from rich.color import ColorParseError
31
+ from rich.style import Style
32
+ from rich.text import Text
33
+
34
+ from .. import froidconfig, deferredwork, policy, sprintstatus, stories
35
+ from ..gates import ATTENTION_FILE
36
+ from ..journal import JOURNAL_FILE, LOGS_DIR, STATE_FILE, load_state
37
+ from ..model import RunState
38
+ from ..platform_util import resolve_or_lexical
39
+
40
+ # Run-inventory names relocated to froid_loop.runs (#650) and re-exported here so
41
+ # tui.data stays the TUI-facing surface (`data.discover_runs`, `data.RUNNING`,
42
+ # monkeypatched `data.liveness` in tests). Each pin is load-bearing: without it
43
+ # ruff F401 autofix deletes the re-export and every `data.<name>` consumer breaks.
44
+ from ..runs import CRASHED # noqa: F401 — re-export
45
+ from ..runs import FINISHED # noqa: F401 — re-export
46
+ from ..runs import INTERRUPTED # noqa: F401 — re-export
47
+ from ..runs import PAUSED # noqa: F401 — re-export
48
+ from ..runs import RUNNING # noqa: F401 — re-export
49
+ from ..runs import STOPPED # noqa: F401 — re-export
50
+ from ..runs import RunInfo # noqa: F401 — re-export
51
+ from ..runs import _header_cache # noqa: F401 — re-export
52
+ from ..runs import _session_liveness # noqa: F401 — re-export
53
+ from ..runs import discover_runs # noqa: F401 — re-export
54
+ from ..runs import (
55
+ STOP_REQUEST_FILE,
56
+ UNKNOWN,
57
+ _classify,
58
+ _stat_sig,
59
+ _StatSig,
60
+ list_run_dirs,
61
+ liveness,
62
+ )
63
+
64
+ # ------------------------------------------------------------- run watching
65
+
66
+
67
+ class RunWatcher:
68
+ """Stat-gated views of one run dir. Never raises on missing or mid-write
69
+ files: state() keeps returning the last good parse."""
70
+
71
+ def __init__(self, run_dir: Path):
72
+ self.run_dir = run_dir
73
+ self._state_sig: _StatSig | None = None
74
+ self._state: RunState | None = None
75
+ self._attention_sig: _StatSig | None = None
76
+ self._attention = ""
77
+
78
+ def state(self) -> RunState | None:
79
+ sig = _stat_sig(self.run_dir / STATE_FILE)
80
+ if sig is None or sig == self._state_sig:
81
+ return self._state
82
+ try:
83
+ self._state = load_state(self.run_dir)
84
+ self._state_sig = sig
85
+ except (OSError, json.JSONDecodeError, KeyError, ValueError):
86
+ pass # keep last good
87
+ return self._state
88
+
89
+ def liveness(self) -> str:
90
+ return liveness(self.run_dir)
91
+
92
+ def status(self) -> str:
93
+ state = self.state()
94
+ if state is None:
95
+ return UNKNOWN
96
+ return _classify(state.finished, state.paused, state.stopped, state.crashed, self.run_dir)
97
+
98
+ def stopping(self) -> bool:
99
+ """True when a stop request of *either* mode is pending for this run (its
100
+ control file is present) — a bare existence read for the run-header pending
101
+ line, mirroring runs.graceful_stop_requested. Deliberately mode-blind: a run
102
+ with a hard request lodged (#319) is stopping too, and the request is on disk
103
+ only for the seconds it takes the engine to honor it. The caller gates on a
104
+ RUNNING status so a file lingering on a stopped run doesn't read as
105
+ still-stopping."""
106
+ return (self.run_dir / STOP_REQUEST_FILE).is_file()
107
+
108
+ def attention(self) -> str:
109
+ path = self.run_dir / ATTENTION_FILE
110
+ sig = _stat_sig(path)
111
+ if sig is not None and sig != self._attention_sig:
112
+ try:
113
+ self._attention = path.read_text(encoding="utf-8")
114
+ self._attention_sig = sig
115
+ except OSError:
116
+ pass
117
+ return self._attention
118
+
119
+
120
+ class JournalTail:
121
+ """Incremental journal.jsonl reader.
122
+
123
+ The byte offset only ever advances past complete lines, so a partially
124
+ flushed append is withheld until its newline lands. Truncation
125
+ (size < offset) resets to the start; unparseable lines are skipped.
126
+ """
127
+
128
+ def __init__(self, run_dir: Path):
129
+ self.path = run_dir / JOURNAL_FILE
130
+ self._offset = 0
131
+
132
+ def read_new(self) -> list[dict[str, Any]]:
133
+ sig = _stat_sig(self.path)
134
+ if sig is None:
135
+ self._offset = 0
136
+ return []
137
+ size = sig[1]
138
+ if size < self._offset:
139
+ self._offset = 0
140
+ if size == self._offset:
141
+ return []
142
+ with self.path.open("rb") as f:
143
+ f.seek(self._offset)
144
+ chunk = f.read(size - self._offset)
145
+ complete = chunk.rfind(b"\n") + 1
146
+ if complete == 0:
147
+ return []
148
+ self._offset += complete
149
+ entries: list[dict[str, Any]] = []
150
+ for raw in chunk[:complete].splitlines():
151
+ line = raw.decode("utf-8", errors="replace").strip()
152
+ if not line:
153
+ continue
154
+ try:
155
+ entry = json.loads(line)
156
+ except json.JSONDecodeError:
157
+ continue
158
+ if isinstance(entry, dict):
159
+ entries.append(entry)
160
+ return entries
161
+
162
+
163
+ # Pane geometry mirrors adapters.generic PANE_COLUMNS/PANE_LINES (not
164
+ # imported: that module drags in Policy/SignalWatcher, and this layer stays
165
+ # a pure observer).
166
+ _PANE_COLUMNS = 220
167
+ _PANE_LINES = 50
168
+ _HISTORY_LINES = 2000 # matches the dashboard RichLog max_lines
169
+
170
+ # pyte names SGR 33 "brown"; aixterm brights carry no underscore. Values here
171
+ # must stay in pyte's own (underscore-free) namespace: _rich_color re-applies
172
+ # the "bright" -> "bright_" transform after this remap, so a "bright_"-prefixed
173
+ # value would double up into "bright__yellow" (an invalid rich color).
174
+ # "bfightmagenta" is pyte 0.8.2's own BG_AIXTERM[105] typo (SGR 105).
175
+ _PYTE_COLOR_FIX = {
176
+ "brown": "yellow",
177
+ "brightbrown": "brightyellow",
178
+ "bfightmagenta": "brightmagenta",
179
+ }
180
+ _HEX_DIGITS = set("0123456789abcdef")
181
+
182
+
183
+ def _rich_color(value: str) -> str | None:
184
+ """pyte color -> rich color: 'default' (None), bare rrggbb hex
185
+ (256/truecolor), or a named ANSI color."""
186
+ if value == "default":
187
+ return None
188
+ if len(value) == 6 and set(value) <= _HEX_DIGITS:
189
+ return f"#{value}"
190
+ value = _PYTE_COLOR_FIX.get(value, value)
191
+ if value.startswith("bright"):
192
+ return "bright_" + value[6:]
193
+ return value
194
+
195
+
196
+ _style_cache: dict[tuple, Style] = {}
197
+
198
+
199
+ def _char_style(key: tuple) -> Style:
200
+ style = _style_cache.get(key)
201
+ if style is None:
202
+ fg, bg, bold, italics, underscore, strikethrough, reverse = key
203
+ attrs = dict(
204
+ bold=bold or None,
205
+ italic=italics or None,
206
+ underline=underscore or None,
207
+ strike=strikethrough or None,
208
+ reverse=reverse or None,
209
+ )
210
+ try:
211
+ style = Style(color=_rich_color(fg), bgcolor=_rich_color(bg), **attrs)
212
+ except ColorParseError:
213
+ # A color name rich can't parse (e.g. a new pyte table entry) must
214
+ # degrade to an uncolored run, not kill the poll worker — and with
215
+ # it the app (textual workers default to exit_on_error=True).
216
+ style = Style(**attrs)
217
+ _style_cache[key] = style
218
+ return style
219
+
220
+
221
+ def _render_row(row: dict) -> Text:
222
+ """One pyte buffer row (sparse col -> Char dict) -> styled Text. Trailing
223
+ default-background whitespace is trimmed; wide-char stub cells (data '')
224
+ concatenate away naturally."""
225
+ width = max(row, default=-1) + 1
226
+ while width:
227
+ ch = row[width - 1]
228
+ if ch.data not in (" ", "") or ch.bg != "default" or ch.reverse:
229
+ break
230
+ width -= 1
231
+ text = Text()
232
+ run: list[str] = []
233
+ prev_key: tuple | None = None
234
+ for x in range(width):
235
+ ch = row[x]
236
+ key = (ch.fg, ch.bg, ch.bold, ch.italics, ch.underscore, ch.strikethrough, ch.reverse)
237
+ if key != prev_key and run:
238
+ # prev_key is non-None whenever `run` is non-empty (a prior cell was buffered).
239
+ text.append("".join(run), _char_style(prev_key)) # pyright: ignore[reportArgumentType]
240
+ run.clear()
241
+ prev_key = key
242
+ run.append(ch.data)
243
+ if run and prev_key is not None:
244
+ text.append("".join(run), _char_style(prev_key))
245
+ return text
246
+
247
+
248
+ # CSI sequences with a private/secondary marker (< > = ?) are terminal capability
249
+ # negotiation, never display SGR. pyte 0.8.2 ignores a leading marker and
250
+ # misdispatches them to SGR anyway: e.g. XTMODKEYS `CSI > 4 ; 2 m` (modifyOtherKeys,
251
+ # emitted at session start by Claude Code et al.) is read as SGR 4 = underline-on,
252
+ # leaving the whole log underlined until an exit-time disable a live capture never
253
+ # contains. Worse, a marker byte *inside* the params — gemini's `CSI > 4 ; ? m`,
254
+ # vim9's `CSI ? 4 m` — makes pyte dispatch with private=True to a handler that
255
+ # rejects the kwarg, a TypeError that would kill the poll worker (#111; upstream
256
+ # selectel/pyte#202, fixed in master but never released). Strip them before pyte
257
+ # sees them, matching the marker anywhere in the param bytes; a legitimate SGR
258
+ # carries no marker, so this can only remove non-display sequences (never printable
259
+ # text or genuine styling).
260
+ _PRIVATE_MARKER_SGR = re.compile(rb"\x1b\[[0-9;:<>=?]*[<>=?][0-9;:<>=?]*m")
261
+ # Alternate-screen switch sequences (DECSET/DECRST 1049/1047/47). A CLI fullscreen
262
+ # TUI (Claude Code's fullscreen renderer) switches here and repaints in place; pyte
263
+ # has no altscreen buffer, so the capture collapses to the final frame. Detecting
264
+ # the switch lets a consumer flag a fullscreen log instead of trusting it whole.
265
+ # Both enter (h) and exit (l) markers are matched: a finished run read from the
266
+ # max_bytes tail can seek past the start-of-session enter but still carries the
267
+ # exit emitted when the CLI quit.
268
+ _ALTSCREEN_MARKERS = (
269
+ b"\x1b[?1049h",
270
+ b"\x1b[?1049l",
271
+ b"\x1b[?1047h",
272
+ b"\x1b[?1047l",
273
+ b"\x1b[?47h",
274
+ b"\x1b[?47l",
275
+ )
276
+ # Cold-open altscreen detection scans the max_bytes-skipped prefix, but caps it so a
277
+ # huge log doesn't trigger a near-whole-file read that would defeat max_bytes. 8 MiB
278
+ # comfortably covers a multi-MB classic preamble before a TUI switches to the
279
+ # alternate screen (observed ~3.9 MB). A marker past the cap is missed only when the
280
+ # log already exceeds max_bytes at first open (that first read tail-seeks past the
281
+ # prefix); a view attached while the log was still small reads the whole stream from
282
+ # offset 0 and catches it regardless.
283
+ _ALTSCREEN_PREFIX_SCAN_CAP = 8 << 20
284
+ # A trailing, not-yet-terminated CSI at the end of a read: ESC, or ESC[ followed by
285
+ # only param/marker bytes with no final letter. Held back so a marker sequence split
286
+ # across two reads is filtered whole next time (the filter must see it complete).
287
+ _INCOMPLETE_CSI_TAIL = re.compile(rb"\x1b(?:\[[0-9;:<>=?]*)?\Z")
288
+
289
+
290
+ def _strip_private_marker_sgr(chunk: bytes) -> tuple[bytes, int]:
291
+ """(filtered chunk, bytes held back). Drops private-marker SGR sequences and
292
+ returns the length of an unterminated trailing CSI that should not be consumed
293
+ yet, so the caller can re-read it next time and see the sequence whole."""
294
+ m = _INCOMPLETE_CSI_TAIL.search(chunk)
295
+ held = len(m.group()) if m else 0
296
+ body = chunk[: len(chunk) - held] if held else chunk
297
+ return _PRIVATE_MARKER_SGR.sub(b"", body), held
298
+
299
+
300
+ class _TolerantByteStream(pyte.ByteStream):
301
+ """pyte 0.8.2 dispatches a private-marked CSI (marker byte in the params) with
302
+ private=True to handlers that don't accept it, raising TypeError (upstream
303
+ selectel/pyte#202; fixed in master Sep 2025, never released). Any parser
304
+ exception here would kill the poll worker — and with it the app — so drop the
305
+ unparseable sequence instead: the base method re-initializes the parser before
306
+ re-raising, leaving the stream usable for the bytes that follow."""
307
+
308
+ def _send_to_parser(self, data: str) -> bool | None:
309
+ try:
310
+ return super()._send_to_parser(data)
311
+ except Exception:
312
+ return None
313
+
314
+
315
+ class _CountingDeque(deque):
316
+ """history.top replacement that counts rows permanently gone above the
317
+ window: maxlen evictions plus the clear() from pyte's reset()/ED-3.
318
+ Lets checkpoints live in absolute line coordinates while the deque
319
+ slides (indices into the deque itself shift on every eviction)."""
320
+
321
+ def __init__(self, maxlen: int | None = None):
322
+ super().__init__(maxlen=maxlen)
323
+ self.dropped = 0
324
+
325
+ def append(self, item: Any) -> None:
326
+ if self.maxlen is not None and len(self) == self.maxlen:
327
+ self.dropped += 1
328
+ super().append(item)
329
+
330
+ def clear(self) -> None:
331
+ self.dropped += len(self)
332
+ super().clear()
333
+
334
+
335
+ @dataclass(frozen=True)
336
+ class LogIndex:
337
+ """Maps journal log_pos byte offsets to rendered-line indices.
338
+
339
+ Built by the poll worker right after render() and handed across to the
340
+ UI thread, so it is frozen. Line indices match render() output rows
341
+ one-to-one — which equals RichLog scroll lines while the log pane keeps
342
+ wrap=False (the textual default)."""
343
+
344
+ checkpoints: tuple[tuple[int, int], ...] # ascending (file_offset, absolute_line)
345
+ render_base: int # absolute line index of render() row 0
346
+ render_len: int # rows in the last render()
347
+
348
+ def line_for_offset(self, offset: int) -> int | None:
349
+ """Rendered-line index for a byte offset into the log file; None
350
+ when nothing is rendered. Between checkpoints the line is
351
+ interpolated by byte fraction — exact for plain streaming output,
352
+ bounded by the surrounding checkpoints for repaint-heavy segments.
353
+ Offsets below the tail seek or evicted history clamp to the first
354
+ row, offsets past EOF to the last."""
355
+ if self.render_len <= 0:
356
+ return None
357
+ cps = self.checkpoints
358
+ i = bisect.bisect_right(cps, (offset, float("inf"))) - 1
359
+ if i < 0:
360
+ absolute = 0
361
+ elif i + 1 < len(cps) and cps[i + 1][0] > cps[i][0]:
362
+ o0, l0 = cps[i]
363
+ o1, l1 = cps[i + 1]
364
+ absolute = l0 + round((offset - o0) * (l1 - l0) / (o1 - o0))
365
+ else:
366
+ absolute = cps[i][1]
367
+ return max(0, min(absolute - self.render_base, self.render_len - 1))
368
+
369
+
370
+ class LogView:
371
+ """Terminal-emulated view of a pane log (a raw pipe-pane capture full of
372
+ cursor-addressed repaints). Bytes are fed through pyte so the stream
373
+ collapses to what a real pane-sized terminal shows; scrolled-off lines
374
+ land in history, so a finished run shows more than the final screen.
375
+ Same stat-gated incremental contract as JournalTail: the first read seeks
376
+ to the last max_bytes, truncation resets — the emulator included.
377
+
378
+ Bytes are fed in checkpoint_bytes slices, recording (file offset,
379
+ absolute cursor line) pairs so journal log_pos offsets map back to
380
+ rendered lines (see LogIndex). Cursor-addressed repaints make the
381
+ mapping approximate — the cursor sits wherever the CLI left it — but
382
+ it is exact for plain streaming output.
383
+
384
+ Known degradations, all strictly better than rendering the raw stream:
385
+ a mid-stream first frame may be partial until the next repaint; altscreen
386
+ (mode 1049) CLIs merge frames into one buffer; a human attaching to the
387
+ tmux session resizes the pane away from our fixed geometry."""
388
+
389
+ def __init__(
390
+ self,
391
+ path: Path,
392
+ max_bytes: int = 262144,
393
+ columns: int = _PANE_COLUMNS,
394
+ lines: int = _PANE_LINES,
395
+ history: int = _HISTORY_LINES,
396
+ checkpoint_bytes: int = 4096,
397
+ ):
398
+ self.path = path
399
+ self.max_bytes = max_bytes
400
+ self._columns, self._lines, self._history = columns, lines, history
401
+ self._checkpoint_bytes = checkpoint_bytes
402
+ self._offset: int | None = None # None until the file first appears
403
+ self._row_cache: dict[int, Text] = {} # id(history row) -> rendered
404
+ # True once the stream enters the alternate screen (CLI fullscreen TUI):
405
+ # pyte has no altscreen buffer, so those frames repaint the one screen in
406
+ # place and the render collapses to the final frame. A consumer surfaces
407
+ # this so a fullscreen capture isn't mistaken for the whole session.
408
+ self.altscreen_seen = False
409
+ self._reset_screen()
410
+
411
+ def _reset_screen(self) -> None:
412
+ self._screen = pyte.HistoryScreen(self._columns, self._lines, history=self._history)
413
+ self._screen.history = self._screen.history._replace(
414
+ top=_CountingDeque(maxlen=self._history)
415
+ )
416
+ self._stream = _TolerantByteStream(self._screen)
417
+ self._row_cache.clear()
418
+ self._checkpoints: list[tuple[int, int]] = []
419
+ self._render_base = 0
420
+ self._render_len = 0
421
+ # A truncation restart re-reads from scratch, so re-detect altscreen too.
422
+ self.altscreen_seen = False
423
+
424
+ def _scan_prefix_for_altscreen(self, end: int) -> None:
425
+ """One-time scan of bytes [0, end) for an altscreen switch marker, in
426
+ overlapping windows so a marker straddling a window boundary still
427
+ matches. `end` is the caller-bounded scan ceiling (see
428
+ _ALTSCREEN_PREFIX_SCAN_CAP), not necessarily the full prefix. Sets
429
+ altscreen_seen; best-effort, never raises."""
430
+ overlap = max(len(m) for m in _ALTSCREEN_MARKERS) - 1
431
+ window = 1 << 20
432
+ try:
433
+ with self.path.open("rb") as f:
434
+ pos = 0
435
+ while pos < end:
436
+ f.seek(pos)
437
+ # window + overlap so a marker straddling a window boundary is
438
+ # caught; clamped to `end` so the cap stays a strict ceiling.
439
+ buf = f.read(min(window + overlap, end - pos))
440
+ if any(seq in buf for seq in _ALTSCREEN_MARKERS):
441
+ self.altscreen_seen = True
442
+ return
443
+ if not buf:
444
+ return
445
+ pos += window
446
+ except OSError:
447
+ return
448
+
449
+ def read_new(self) -> bool:
450
+ """Feed any new bytes into the emulator; True when content changed."""
451
+ sig = _stat_sig(self.path)
452
+ if sig is None:
453
+ if self._offset is None:
454
+ return False
455
+ self._offset = None
456
+ self._reset_screen()
457
+ return True
458
+ size = sig[1]
459
+ if self._offset is None:
460
+ self._offset = max(0, size - self.max_bytes)
461
+ # offsets at or before the tail seek clamp to the first line
462
+ self._checkpoints = [(self._offset, 0)]
463
+ # The render only emulates the max_bytes tail, but an altscreen switch
464
+ # is a whole-session property whose markers often sit in the skipped
465
+ # prefix (enter at session start). Scan that prefix once on open so a
466
+ # cold-opened fullscreen log is still flagged; the tail is covered by
467
+ # the per-chunk scan below.
468
+ if self._offset > 0 and not self.altscreen_seen:
469
+ self._scan_prefix_for_altscreen(min(self._offset, _ALTSCREEN_PREFIX_SCAN_CAP))
470
+ elif size < self._offset:
471
+ self._offset = 0
472
+ self._reset_screen()
473
+ self._checkpoints = [(0, 0)]
474
+ if size == self._offset:
475
+ return False
476
+ with self.path.open("rb") as f:
477
+ f.seek(self._offset)
478
+ chunk = f.read(size - self._offset)
479
+ if not self.altscreen_seen and any(seq in chunk for seq in _ALTSCREEN_MARKERS):
480
+ self.altscreen_seen = True
481
+ # Drop private-marker SGR (pyte misreads it as underline); hold back an
482
+ # unterminated trailing CSI so a sequence split across reads is filtered
483
+ # whole next time. `consumed` is the original byte span we commit here —
484
+ # the file offset advances over it while pyte sees only the filtered bytes.
485
+ filtered, held = _strip_private_marker_sgr(chunk)
486
+ consumed = len(chunk) - held
487
+ base, total = self._offset, len(filtered)
488
+ top = self._screen.history.top
489
+ for start in range(0, total, self._checkpoint_bytes):
490
+ piece = filtered[start : start + self._checkpoint_bytes]
491
+ # ByteStream buffers escape sequences split across feeds
492
+ self._stream.feed(piece)
493
+ # map back to an original-file offset (filtering only removes a few
494
+ # marker bytes; the log_pos->line mapping is already approximate)
495
+ end = start + len(piece)
496
+ self._offset = base + (consumed if end >= total else round(end / total * consumed))
497
+ # pyte types history.top as base deque; froid-loop installs a _CountingDeque
498
+ # (see class below) that adds `.dropped`.
499
+ line = (
500
+ top.dropped # pyright: ignore[reportAttributeAccessIssue]
501
+ + len(top)
502
+ + self._screen.cursor.y
503
+ )
504
+ self._checkpoints.append((self._offset, line))
505
+ self._offset = base + consumed
506
+ # When a chunk is entirely an unterminated trailing CSI, consumed == 0 and
507
+ # _offset holds short of EOF until more bytes complete the sequence — a few
508
+ # held bytes re-read per poll. We deliberately don't advance past them: a
509
+ # log that's still streaming self-heals on the next write, and one that
510
+ # permanently ends mid-escape has nothing renderable there anyway (we'd
511
+ # only risk eating a legitimately split sequence by forcing it through).
512
+ # drop checkpoints whose lines evicted past the history horizon;
513
+ # their offsets would clamp to line 0 anyway
514
+ # `.dropped` is on the _CountingDeque froid-loop installs as history.top.
515
+ while (
516
+ len(self._checkpoints) > 1
517
+ and self._checkpoints[1][1]
518
+ <= top.dropped # pyright: ignore[reportAttributeAccessIssue]
519
+ ):
520
+ self._checkpoints.pop(0)
521
+ return consumed > 0
522
+
523
+ def index(self) -> LogIndex:
524
+ """Snapshot for log_pos -> rendered-line lookups; reflects the most
525
+ recent render() (render_base/render_len are set there)."""
526
+ return LogIndex(tuple(self._checkpoints), self._render_base, self._render_len)
527
+
528
+ def render(self) -> Text:
529
+ """History + current screen as one styled Text, trailing blank rows
530
+ dropped, capped to the newest history rows. History rows are detached
531
+ dicts that never mutate, so their renders are memoized by id() —
532
+ valid exactly while the row sits in the deque (the cache is re-keyed
533
+ every call, keeping only surviving ids)."""
534
+ screen = self._screen
535
+ fresh: dict[int, Text] = {}
536
+ rows: list[Text] = []
537
+ for row in screen.history.top:
538
+ text = self._row_cache.get(id(row))
539
+ if text is None:
540
+ text = _render_row(row)
541
+ fresh[id(row)] = text
542
+ rows.append(text)
543
+ self._row_cache = fresh
544
+ rows += [_render_row(screen.buffer[y]) for y in range(screen.lines)]
545
+ while rows and not rows[-1].plain:
546
+ rows.pop()
547
+ front_drop = max(0, len(rows) - self._history)
548
+ del rows[:front_drop]
549
+ # `.dropped` is on the _CountingDeque froid-loop installs as history.top.
550
+ self._render_base = (
551
+ screen.history.top.dropped + front_drop # pyright: ignore[reportAttributeAccessIssue]
552
+ )
553
+ self._render_len = len(rows)
554
+ return Text("\n").join(rows)
555
+
556
+
557
+ def _open_session_start(journal_entries: list[dict[str, Any]]) -> dict[str, Any] | None:
558
+ """The session-start entry whose agent session is currently open: the last
559
+ session-start with no later matching session-end. None when every started
560
+ session has ended (or none started). The task_id is tracked as a string so
561
+ the session-end match is byte-identical to what active_task_id compared."""
562
+ open_entry: dict[str, Any] | None = None
563
+ active: str | None = None
564
+ for entry in journal_entries:
565
+ kind = entry.get("kind")
566
+ if kind == "session-start" and entry.get("task_id") is not None:
567
+ active = str(entry["task_id"])
568
+ open_entry = entry
569
+ elif kind == "session-end" and str(entry.get("task_id")) == active:
570
+ active = None
571
+ open_entry = None
572
+ return open_entry
573
+
574
+
575
+ def active_task_id(run_dir: Path, journal_entries: list[dict[str, Any]]) -> str | None:
576
+ """Task whose agent session is currently open: the last session-start
577
+ without a later session-end. Falls back to the newest file in logs/ —
578
+ a tail attached mid-session has no start event in view."""
579
+ entry = _open_session_start(journal_entries)
580
+ if entry is not None:
581
+ return str(entry["task_id"])
582
+ try:
583
+ logs = sorted(
584
+ (run_dir / LOGS_DIR).glob("*.log"),
585
+ key=lambda p: p.stat().st_mtime_ns,
586
+ )
587
+ except OSError:
588
+ return None
589
+ return logs[-1].stem if logs else None
590
+
591
+
592
+ @dataclass(frozen=True)
593
+ class ActiveAgent:
594
+ """Identity of the agent currently driving a run, derived from the open
595
+ session-start journal entry: the task/story it is working, the stage role,
596
+ and the resolved adapter name + model."""
597
+
598
+ task_id: str
599
+ story_key: str
600
+ role: str
601
+ name: str
602
+ model: str
603
+
604
+
605
+ def _story_key_from_task_id(task_id: str, role: str) -> str:
606
+ """Recover the story key from a session task_id when the journal entry
607
+ predates story-key stamping (#153 phase 1). The id is
608
+ ``safe_segment(f"{story_key}-{part}-{seq}{gen}")`` where ``part`` is the role, or
609
+ a workflow label for labeled plugin sessions, and ``gen`` is a ``-g<N>`` re-arm
610
+ generation suffix emitted only above zero (#705). This parser handles the
611
+ unsuffixed shape ONLY: a ``-g1`` tail fails the ``seq.isdigit()`` test below and
612
+ returns the whole id. That is unreachable rather than latent-correct — every
613
+ session-start has carried ``story_key`` since #153 phase 1, so the entries this
614
+ fallback sees are exactly the ones that predate generations — but widen the
615
+ fallback and this is the assumption that breaks. So peel the trailing
616
+ ``-{part}-{seq}``: drop the numeric seq, then the recorded role when it
617
+ matches (the common case), else one more ``-`` group (best-effort, since a
618
+ label is not recoverable from the entry)."""
619
+ head, sep, seq = task_id.rpartition("-")
620
+ if not sep or not seq.isdigit():
621
+ return task_id # not the expected shape — best we can do
622
+ if role and head.endswith(f"-{role}"):
623
+ return head[: -(len(role) + 1)]
624
+ parent = head.rpartition("-")[0]
625
+ return parent or head
626
+
627
+
628
+ def active_agent(
629
+ journal_entries: list[dict[str, Any]], policy_snapshot: dict[str, Any] | None
630
+ ) -> ActiveAgent | None:
631
+ """The agent currently driving the run, or None when no session is open.
632
+
633
+ Journal-only: unlike :func:`active_task_id` there is no logs/ fallback — a
634
+ tail attached mid-session carries no adapter identity. A session-start
635
+ stamped since #153 phase 1 supplies the adapter name/model (and story_key)
636
+ directly. For an older, unstamped entry the run's persisted
637
+ ``policy_snapshot`` is rebuilt and resolved for the entry's role; when that
638
+ yields nothing trustworthy (no/empty snapshot) the agent is unknown -> None.
639
+ Never raises on a malformed entry."""
640
+ try:
641
+ entry = _open_session_start(journal_entries)
642
+ if entry is None:
643
+ return None
644
+ task_id = str(entry.get("task_id", ""))
645
+ role = str(entry.get("role") or "")
646
+ if "adapter" in entry:
647
+ name = str(entry.get("adapter", ""))
648
+ model = str(entry.get("model", ""))
649
+ else:
650
+ rebuilt = policy.adapter_policy_from_snapshot(policy_snapshot)
651
+ if rebuilt is None:
652
+ return None
653
+ resolved = rebuilt.resolved(role)
654
+ name, model = resolved.name, resolved.model
655
+ story_raw = entry.get("story_key")
656
+ story_key = str(story_raw) if story_raw else _story_key_from_task_id(task_id, role)
657
+ return ActiveAgent(task_id=task_id, story_key=story_key, role=role, name=name, model=model)
658
+ except Exception:
659
+ return None
660
+
661
+
662
+ def pending_decision(journal_entries: list[dict[str, Any]]) -> tuple[str, str] | None:
663
+ """(dw_id, question) when the journal's last entry is a decision-pending
664
+ announcement. The sweep prompter blocks on terminal input right after
665
+ writing it, so any subsequent entry of any kind means the prompt was
666
+ answered (or the sweep moved on) and the alert must clear."""
667
+ if not journal_entries:
668
+ return None
669
+ last = journal_entries[-1]
670
+ if last.get("kind") != "decision-pending":
671
+ return None
672
+ return str(last.get("dw_id", "?")), str(last.get("question", ""))
673
+
674
+
675
+ # --------------------------------------------- project-level artifact readers
676
+
677
+ # project root -> (config.yaml sig, ProjectPaths)
678
+ _paths_cache: dict[Path, tuple[_StatSig, froidconfig.ProjectPaths]] = {}
679
+ # sprint-status.yaml path -> (sig or None for missing, parse or None)
680
+ _sprint_cache: dict[Path, tuple[_StatSig | None, sprintstatus.SprintStatus | None]] = {}
681
+ # deferred-work.md path -> (sig or None for missing, items or None)
682
+ _deferred_cache: dict[Path, tuple[_StatSig | None, list[DeferredItem] | None]] = {}
683
+ # project root -> (signature, pending decisions) — invalidated when the ledger,
684
+ # pre-answer store, or the set of run dirs changes
685
+ _missed_cache: dict[Path, tuple[Any, list]] = {}
686
+
687
+
688
+ def _project_paths(project: Path) -> froidconfig.ProjectPaths | None:
689
+ """FROID artifact paths, stat-gated on config.yaml; None when the project
690
+ is not initialized (or the config is unreadable)."""
691
+ project = resolve_or_lexical(project)
692
+ config_sig = _stat_sig(project / "_froid" / "bmm" / "config.yaml")
693
+ cached_paths = _paths_cache.get(project)
694
+ if config_sig is not None and cached_paths is not None and cached_paths[0] == config_sig:
695
+ return cached_paths[1]
696
+ try:
697
+ paths = froidconfig.load_paths(project)
698
+ except (froidconfig.FroidConfigError, OSError):
699
+ return None
700
+ if config_sig is not None:
701
+ _paths_cache[project] = (config_sig, paths)
702
+ return paths
703
+
704
+
705
+ def sprint_overview(project: Path) -> sprintstatus.SprintStatus | None:
706
+ """Parsed sprint-status.yaml, or None when unavailable (uninitialized
707
+ project, missing file, bad YAML). Stat-gated on both config.yaml and the
708
+ sprint file; the same object is returned while the file is unchanged."""
709
+ paths = _project_paths(project)
710
+ if paths is None:
711
+ return None
712
+ sprint_path = paths.sprint_status
713
+ sig = _stat_sig(sprint_path)
714
+ cached = _sprint_cache.get(sprint_path)
715
+ if cached is not None and cached[0] == sig:
716
+ return cached[1]
717
+ overview: sprintstatus.SprintStatus | None = None
718
+ if sig is not None:
719
+ try:
720
+ overview = sprintstatus.load(sprint_path)
721
+ except (sprintstatus.SprintStatusError, OSError):
722
+ overview = None
723
+ _sprint_cache[sprint_path] = (sig, overview)
724
+ return overview
725
+
726
+
727
+ def stories_overview(project: Path, spec_folder: str) -> list[stories.StoryRow] | None:
728
+ """The stories-mode board for a run's pinned spec folder: one StoryRow per
729
+ ``stories.yaml`` entry joined with the live on-disk state of its id-keyed
730
+ story spec. None when unavailable (no spec folder, or a missing/invalid
731
+ ``stories.yaml``).
732
+
733
+ Unlike :func:`sprint_overview` this is per-run (keyed by the run's
734
+ ``spec_folder``) and is re-derived on every call rather than stat-gated: the
735
+ per-story spec files under ``stories/`` change independently, so there is no
736
+ single file to gate on. It reads a handful of small files, called on the
737
+ dashboard's rescan cadence."""
738
+ if not spec_folder:
739
+ return None
740
+ folder = stories.resolve_spec_folder(project, spec_folder)
741
+ try:
742
+ return stories.story_rows(folder)
743
+ except stories.StoriesError:
744
+ return None
745
+
746
+
747
+ @dataclass(frozen=True)
748
+ class DeferredItem:
749
+ id: str
750
+ title: str
751
+ status: str
752
+ done: bool
753
+ severity: str | None # normalized: critical/high/medium/low, None unknown
754
+ body: str
755
+ legacy: bool = False # pre-DW-format item (older FROID-method ledgers)
756
+ option_key: str | None = None # highlight-restore identity; None -> id
757
+
758
+
759
+ def deferred_entries(project: Path) -> list[DeferredItem] | None:
760
+ """All entries from deferred-work.md in file order — canonical DW entries
761
+ plus tolerantly-parsed legacy items — or None when unavailable
762
+ (uninitialized project, missing/unreadable file). Stat-gated; the same
763
+ list object is returned while the file is unchanged."""
764
+ paths = _project_paths(project)
765
+ if paths is None:
766
+ return None
767
+ ledger_path = paths.deferred_work
768
+ sig = _stat_sig(ledger_path)
769
+ cached = _deferred_cache.get(ledger_path)
770
+ if cached is not None and cached[0] == sig:
771
+ return cached[1]
772
+ items: list[DeferredItem] | None = None
773
+ if sig is not None:
774
+ try:
775
+ text = ledger_path.read_text(encoding="utf-8")
776
+ except OSError:
777
+ items = None
778
+ else:
779
+ merged: list[tuple[int, DeferredItem]] = []
780
+ for e in deferredwork.parse_ledger(text):
781
+ merged.append(
782
+ (
783
+ e.span[0],
784
+ DeferredItem(
785
+ id=e.id,
786
+ title=e.title,
787
+ status=e.status,
788
+ done=bool(e.status) and e.status.split()[0] == "done",
789
+ severity=deferredwork.field_severity(e.body),
790
+ body=e.body,
791
+ ),
792
+ )
793
+ )
794
+ for n, le in enumerate(deferredwork.parse_legacy(text), start=1):
795
+ merged.append(
796
+ (
797
+ le.span[0],
798
+ DeferredItem(
799
+ id=le.id or f"L{n}",
800
+ title=le.title,
801
+ status="done (legacy)" if le.done else "open (legacy)",
802
+ done=le.done,
803
+ severity=le.severity,
804
+ body=le.body,
805
+ legacy=True,
806
+ option_key=f"legacy:{le.key}",
807
+ ),
808
+ )
809
+ )
810
+ merged.sort(key=lambda pair: pair[0])
811
+ items = [item for _, item in merged]
812
+ _deferred_cache[ledger_path] = (sig, items)
813
+ return items
814
+
815
+
816
+ def pending_missed_decisions(project: Path) -> list:
817
+ """Deferred-work decisions earlier sweeps surfaced but no one answered (a
818
+ list of sweep.Decision). Cached on a signature of the ledger, the pre-answer
819
+ store, and the set of run dirs — a new sweep (new run dir) or an answer
820
+ (store/ledger change) invalidates it. Empty when the project is unavailable."""
821
+ from .. import decisions # lazy: pulls sweep; keep this module import-light
822
+
823
+ paths = _project_paths(project)
824
+ if paths is None:
825
+ return []
826
+ project = paths.project
827
+ sig = (
828
+ _stat_sig(paths.deferred_work),
829
+ _stat_sig(decisions.store_path(project)),
830
+ tuple(d.name for d in list_run_dirs(project)),
831
+ )
832
+ cached = _missed_cache.get(project)
833
+ if cached is not None and cached[0] == sig:
834
+ return cached[1]
835
+ try:
836
+ result = decisions.pending_missed_decisions(project)
837
+ except (froidconfig.FroidConfigError, OSError):
838
+ result = []
839
+ _missed_cache[project] = (sig, result)
840
+ return result