fbtodo 4.30.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fbtodo/__init__.py ADDED
@@ -0,0 +1,2271 @@
1
+ """fbtodo — watch the Freebuff agent's todo list.
2
+
3
+ fbtodo live pane (default); auto-starts the watcher, exits with freebuff
4
+ fbtodo snap one snapshot, plain text
5
+ fbtodo json one snapshot, clean JSON
6
+ fbtodo bar "todos 3/5" — for a tmux status bar
7
+ fbtodo daemon start the background watcher (-f for foreground)
8
+ fbtodo stop stop the watcher
9
+ fbtodo pane-watch keep the todo panes open, for as long as freebuff runs
10
+ (started for you; --once for one pass)
11
+ fbtodo pin pin the list pane's side or size for one window
12
+ (--side v|h|auto, --size N, --role local|nas|both,
13
+ --window TARGET, --list, --clear)
14
+ fbtodo why why each list pane is where it is: the side and size in force,
15
+ the source that supplied them, the pane it sits beside
16
+ (--window TARGET, --json)
17
+ fbtodo status instance, watcher, state-file and scratch footprint
18
+ fbtodo ledger every step's forecast vector beside the outcome it was scored
19
+ against — the rows behind the scoreboard
20
+ (--days N, --limit N, --model M, --json)
21
+ fbtodo prune enforce retention now (defaults: 2000 records / 60 days /
22
+ 1 MiB log); runs by itself about hourly either way
23
+
24
+ The watcher (`daemon`) follows one running Freebuff process: while it lives, the
25
+ todo state is refreshed into `fbtodo-state.json` in the state directory
26
+ (`$XDG_STATE_HOME/fbtodo`, i.e. `~/.local/state/fbtodo`, or `FBTODO_HOME`); when the process
27
+ exits, the watcher shuts itself down and removes its lock. A second process, the pane
28
+ keeper (`pane-watch`), exists because a pane cannot be watched by itself: it puts a
29
+ todo pane back when one is killed mid-session, for every local session, and exits when
30
+ the last freebuff does. Either pane is also put back under the pane its session is drawn
31
+ in — the freebuff pane for a local session, the ssh for a NAS one — since that is where
32
+ a glance looks for it. `--pane-seconds N` sets how often it looks (3s), `--ask-seconds
33
+ N` how often a waiting question is looked for (3s; see `ask-bell.py`), and 0 switches
34
+ either off — as does FBTODO_NO_PANE for the panes, the same switch the shell wrapper
35
+ honours.
36
+
37
+ Every one of those failures is quiet by construction: the keeper has no stderr anybody
38
+ reads, and its log records a pane that came BACK, never one that did not. So a fourth
39
+ watch reports the watcher itself — `--pane-bell-seconds N` (60s; 0 = never) asks
40
+ `pane-bell.py` whether a session has no todo pane the keeper has failed to put back, or
41
+ whether there is no keeper at all while one is needed, and pushes to your phone once per
42
+ occurrence. `fbtodo status` says what it currently thinks.
43
+
44
+ The colour pane is framed, and its palette is themeable: FBTODO_ACCENT (the title's
45
+ badge), FBTODO_FAINT (the frame's own ink and the right-hand metadata), FBTODO_MUTED (the
46
+ grey of secondary text — step durations, the goal's label, the status strip), FBTODO_TRACK
47
+ (the progress bar's empty cells) and FBTODO_GRADIENT_START / FBTODO_GRADIENT_END (a
48
+ `#rrggbb` colour, or — for the accent — a raw SGR code like `1;36`) override
49
+ `./.fbtodo-theme.json`, which overrides `~/.config/fbtodo/theme.json`. Layout never names
50
+ a colour: `_styles` resolves the palette into roles, and the drawing code asks for a role.
51
+ Progress is a gradient bar of `█` over a visible `░` track — an eighth block for the one
52
+ cell at the boundary — drawn in 24-bit colour where the terminal takes it and in the nearest
53
+ 256-colour entry where it does not, always as a glyph in a FOREGROUND colour rather than a
54
+ filled background cell. The title sits on an accent badge (reverse video, so no background
55
+ selector is needed), a step's duration is right-aligned against the frame's right wall, the
56
+ status strip badges the live state and spins while a step runs, and a heading the agent
57
+ never wrote is not drawn at all. The plain renderer is deliberately untouched: it is the
58
+ machine-readable path, and the patch row below appears there too, but only when the state
59
+ carries it.
60
+
61
+ Above that strip is one more row when there is something to say: `PATCH` — the last
62
+ outcome of the CLI-patch step, with the binary's version and how long ago — and `ALERT` —
63
+ the last thing the phone was told. Both are read from the log the producing step already
64
+ writes (`~/.config/freebuff-patch-watch/watch.log` and `~/.config/freebuff-notify/phone.log`,
65
+ or the NAS hook's own two, read at the far end of the ssh the pane already makes), so
66
+ neither has to be fetched with a probe of its own; a state with neither fact renders
67
+ exactly as before. `fbtodo status` prints the same pair in the same words.
68
+
69
+ Three stores: the two this machine keeps, and the one a remote host keeps when you run
70
+ freebuff there over ssh (`docker exec -it <container> freebuff`):
71
+ CLI ~/.config/manicode/projects/<project>/chats/<ISO>/log.jsonl live, mid-turn
72
+ Desktop ~/.config/freebuff-desktop/projects/<slug>/desktop-v2.db per turn, app only
73
+ NAS <host>:<root>/<project>/chats/<ISO>/log.jsonl live, over ssh
74
+ `--source auto` prefers a CLI chat for the cwd. `-s nas` reads the remote store
75
+ over ssh (see --nas-*, and set them: there is no built-in host), and follows the remote
76
+ freebuff PROCESS rather than a local pid, so the pane lives as long as the remote
77
+ session does.
78
+
79
+ Every new list replaces the old one: state is never merged, and when the session
80
+ changes the previous list is dropped immediately instead of lingering.
81
+
82
+ Each list is headed by its big goal: the agent's own one-line `Goal:` statement, which
83
+ AGENTS.md asks for and the journal keeps in `fullResponse`. The request the list was
84
+ written for is never shown as the goal — a quote is the phrasing, not the objective — so
85
+ a session that skips the line says `big goal · — none stated` in the plain renderer, and
86
+ in the framed pane simply draws no heading row. When a newer
87
+ request has arrived since, a `now` line says so: a list can sit unchanged for hours while
88
+ the session works on.
89
+
90
+ A comment that begins `# In plain words:` explains the code under it for a reader who does
91
+ not program. They are asides, not part of the program: read them straight down for a tour
92
+ of the file, or skip every one with a search for `In plain words`.
93
+ """
94
+ from __future__ import annotations
95
+
96
+ import argparse
97
+ import calendar
98
+ import copy
99
+ import glob
100
+ import hashlib
101
+
102
+ try:
103
+ # POSIX only, and the watcher's claim is a real one because of it: see `write_lock`.
104
+ import fcntl
105
+ except ImportError: # pragma: no cover - no flock, so a claim is the record alone
106
+ fcntl = None
107
+
108
+ import re
109
+ import shlex
110
+ import shutil as _shutil
111
+ import json
112
+ import os
113
+ import signal
114
+ import sqlite3
115
+ import subprocess
116
+ import sys
117
+ import tempfile
118
+ import textwrap
119
+ import time
120
+ import unicodedata
121
+ import zlib
122
+
123
+ # Running this file AS A SCRIPT — `python3 src/fbtodo/__init__.py …`, which is what a copy
124
+ # carrying the package without the launcher ends up doing (`self_argv` names the launcher
125
+ # when there is one). Left alone it would be a module called `__main__` sitting beside a
126
+ # package called `fbtodo`: every file loaded twice and no relative import resolvable. So it
127
+ # re-enters as the package, where there is exactly one of everything, and exits there.
128
+ if __name__ == "__main__" and not __package__:
129
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
130
+ from fbtodo import main as _main
131
+
132
+ sys.exit(_main())
133
+
134
+ from .render import * # noqa: F401,F403 — the package is one namespace
135
+
136
+
137
+ from .panes import * # noqa: F401,F403 — the package is one namespace
138
+
139
+
140
+ from .sources import * # noqa: F401,F403 — the package is one namespace
141
+
142
+
143
+ # The program, module by module: `base` is the floor (paths, settings, the generic tools), and
144
+ # each layer above it is imported with `*` so that everything stays reachable as `fbtodo.X` —
145
+ # one namespace, however many files it is written in. What each module holds is its `__all__`;
146
+ # the self-check reads the package statically to keep this promise honest.
147
+ from .base import * # noqa: F401,F403 — the package is one namespace
148
+ from .locks import * # noqa: F401,F403 — the package is one namespace
149
+ from .alerts import * # noqa: F401,F403 — the package is one namespace
150
+ from .scan import * # noqa: F401,F403 — the package is one namespace
151
+ from .desktop import * # noqa: F401,F403 — the package is one namespace
152
+ from .nas import * # noqa: F401,F403 — the package is one namespace
153
+ from .tasks import * # noqa: F401,F403 — the package is one namespace
154
+
155
+
156
+ def list_fingerprint(state: dict) -> str:
157
+ """Identity of one todo list: the session plus the *set of tasks*.
158
+
159
+ Completion flags are deliberately excluded — ticking a task off is progress
160
+ within a list, not a new list.
161
+ """
162
+ tasks = [str(t.get("task", "")) for t in (state.get("todos") or [])]
163
+ payload = json.dumps(
164
+ {"s": state.get("session"), "t": tasks}, ensure_ascii=False, sort_keys=True
165
+ )
166
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]
167
+
168
+
169
+ def finish_state(state: dict, prev: dict | None, claim_first: bool = False) -> dict:
170
+ """Apply the replace-on-new-list, drop-on-new-session and drop-on-new-turn rules.
171
+
172
+ `claim_first` belongs to the watcher: only it may declare an unseen list to be
173
+ "list #1". A one-shot probe cannot see the list history, so it must borrow the
174
+ counter from the watcher (see `adopt_version`) rather than invent one.
175
+ """
176
+ todos = state.get("todos") or []
177
+ # In plain words: a list that is FINISHED does not belong to the turn that comes after it.
178
+ # Left standing, it reads as this turn's progress — the previous project's steps, at 100%,
179
+ # while the agent is on something else entirely — and it stays that way until a new list
180
+ # happens to arrive, which can be many minutes. So a finished list is dropped the moment a
181
+ # newer request or a newer turn shows up. A list with work LEFT on it is never dropped:
182
+ # that is the agent's standing plan, and `now`/`NUDGE` is how its drift gets reported.
183
+ written = int(state.get("ts") or state.get("source_updated_ms") or 0)
184
+ turn_start = int((state.get("turn") or {}).get("start_ms") or 0)
185
+ after_turn = bool(turn_start and written and turn_start > written)
186
+ after_request = bool(state.get("now") or state.get("nudge"))
187
+ drop_turn = bool(
188
+ todos and all(t.get("completed") for t in todos) and (after_turn or after_request)
189
+ )
190
+ if drop_turn:
191
+ todos = []
192
+ state["todos"] = []
193
+ # What goes with the list is the objective it was written FOR — a pane still headed
194
+ # with the previous turn's goal is the same misreading in prose, and this turn's own
195
+ # heading arrives with its own list. `now` and `nudge` deliberately stay: they describe
196
+ # the request that replaced the list, not the list, and with nothing on screen they are
197
+ # the only thing left naming what this turn is about.
198
+ for gone in ("goal", "goal_source"):
199
+ state[gone] = None
200
+ fp = list_fingerprint(state)
201
+ version = int((prev or {}).get("list_version") or 0)
202
+ session_changed = bool(prev) and prev.get("session") != state.get("session")
203
+ list_changed = bool(prev) and prev.get("list_id") != fp
204
+ cleared = False
205
+
206
+ if session_changed:
207
+ # A new session owns the pane now: its list is not the previous one, and
208
+ # the old list must not linger while the new one is being written.
209
+ version = 0
210
+ if not todos:
211
+ cleared = True
212
+ if drop_turn:
213
+ # Note that this does NOT touch the counter: the list number identifies a list, and a
214
+ # dropped one is still the list it was. The new turn's list is what increments it.
215
+ cleared = True
216
+ if list_changed and todos:
217
+ version += 1
218
+ elif not prev and claim_first:
219
+ version = 1 if todos else 0
220
+
221
+ state["list_id"] = fp
222
+ state["list_version"] = version
223
+ if session_changed or prev is None:
224
+ state["session_changed_ms"] = state.get("probed_ms") or int(time.time() * 1000)
225
+ else:
226
+ state["session_changed_ms"] = (prev or {}).get("session_changed_ms")
227
+ state["cleared"] = cleared
228
+ # Which of the two drops it was decides the sentence the pane prints, so it is carried
229
+ # rather than inferred: "a new session" and "a finished turn" are different news.
230
+ state["cleared_turn"] = drop_turn
231
+ state["replaced"] = bool(list_changed and todos and not session_changed)
232
+ state["done"] = sum(1 for t in todos if t.get("completed"))
233
+ state["total"] = len(todos)
234
+ return state
235
+
236
+
237
+ def finish_probe(state: dict) -> dict:
238
+ """`finish_state` for a one-shot probe, which may be reading a FILE.
239
+
240
+ A probe that read a file has that file's own list identity in hand, so the file is
241
+ compared with itself: renumbering it would let a recorded state say `#7` and the pane
242
+ print `#0`. A probe that read a journal or a database has no history and passes None,
243
+ borrowing the counter from the watcher (`adopt_version`).
244
+ """
245
+ if state.get("backend") == "file":
246
+ # The file states its own list number, so the comparison is the file with itself and
247
+ # nothing moves: the number it recorded is the number the pane prints, and a file that
248
+ # recorded none starts at the first (`1`), not at a borrowed watcher's counter.
249
+ recorded = state.get("list_version")
250
+ state["list_version"] = int(recorded) if recorded is not None else (
251
+ 1 if state.get("todos") else 0)
252
+ state["list_id"] = list_fingerprint(state)
253
+ return finish_state(state, dict(state))
254
+ return adopt_version(finish_state(state, None))
255
+
256
+
257
+ # ==================================================================== push
258
+ # In plain words: the generic source. A journal, a database and an ssh session are the three
259
+ # ways THIS machine finds a list; anything else — another agent, a script, a CI job, a hand
260
+ # written file — can say what the list is in one JSON object. `fbtodo push` takes one on
261
+ # stdin and makes it the live state, and `-s file:PATH` reads the same shape back. Neither
262
+ # knows what a Freebuff is.
263
+ PUSH_FIELDS = (
264
+ "session", "title", "summary", "goal", "goal_source", "now", "nudge", "todos",
265
+ "model", "turn_ended", "iteration", "list_id", "list_version", "ts",
266
+ )
267
+
268
+ # The ingress caps, shared by every path that turns untrusted JSON into state — `fbtodo
269
+ # push` on stdin and `-s file:PATH` off disk. They are limits, not truncations: a payload
270
+ # over one is refused with the data error (65) and the state it would have replaced stands.
271
+ # 1 MiB of stdin is far past any real list; 200 steps is past anything a session writes;
272
+ # 500 characters is past any goal or step name, and a task is a STRING — coercing a number
273
+ # or an object with `str()` was how a dict of junk reached a step row.
274
+ PUSH_MAX_BYTES = 1 << 20
275
+ PUSH_MAX_STEPS = 200
276
+ PUSH_MAX_STRING = 500
277
+
278
+
279
+ def check_ingress(data) -> None:
280
+ """Refuse a payload that breaks an ingress cap. Raises ValueError; writes nothing.
281
+
282
+ Shared by `state_from_push` and the `file:PATH` source, so the two doorways answer to
283
+ one contract rather than each having its own idea of "too big".
284
+ """
285
+ if not isinstance(data, dict):
286
+ raise ValueError("stdin must be a JSON object, not an array or a scalar")
287
+ todos = data.get("todos")
288
+ if todos is None:
289
+ todos = []
290
+ if not isinstance(todos, list):
291
+ raise ValueError("todos must be a list")
292
+ if len(todos) > PUSH_MAX_STEPS:
293
+ raise ValueError(f"too many steps: {len(todos)} (limit {PUSH_MAX_STEPS})")
294
+ for t in todos:
295
+ if not isinstance(t, dict):
296
+ raise ValueError("every todo must be an object")
297
+ task = t.get("task")
298
+ if not isinstance(task, str):
299
+ raise ValueError("a todo's task must be a string")
300
+ if len(task) > PUSH_MAX_STRING:
301
+ raise ValueError(f"a todo's task is longer than {PUSH_MAX_STRING} characters")
302
+ if not task.strip():
303
+ raise ValueError("a todo with no task text")
304
+ done = t.get("completed")
305
+ # Absent (or an explicit null) means "not done"; anything present must be a real
306
+ # boolean. The coercion `bool(value)` counted the STRING "false" as a finished step,
307
+ # so a pusher that sent a string got its list inverted, not refused.
308
+ if done is not None and not isinstance(done, bool):
309
+ raise ValueError("a todo's completed must be true or false")
310
+ for key in PUSH_FIELDS:
311
+ value = data.get(key)
312
+ if isinstance(value, str) and len(value) > PUSH_MAX_STRING:
313
+ raise ValueError(f"{key} is longer than {PUSH_MAX_STRING} characters")
314
+
315
+
316
+ def check_file_ingress(path: str) -> int | None:
317
+ """The `file:PATH` doorway to `check_ingress`, refused the way `push` refuses a payload.
318
+
319
+ The file is an ingress like stdin, so it gets the same byte cap before it is parsed (a
320
+ 1 MiB file cannot make the reader allocate the denial-of-service it was capped against)
321
+ and a recursive payload gets the same data error the parser's `RecursionError` would
322
+ otherwise turn into "no state file". A simply absent file is not an error here — that is
323
+ the source's own "no state file" answer — so it returns None and lets the source speak.
324
+ Returns an exit code to stop on, or None when the file is fine or missing.
325
+ """
326
+ try:
327
+ with open(path, "rb") as fh:
328
+ chunk = fh.read(PUSH_MAX_BYTES + 1)
329
+ except (FileNotFoundError, NotADirectoryError):
330
+ return None
331
+ except OSError as exc:
332
+ print(f"fbtodo: {path}: {exc}", file=sys.stderr)
333
+ return EX_CODES["ioerr"]
334
+ if len(chunk) > PUSH_MAX_BYTES:
335
+ print(f"fbtodo: {path}: larger than {PUSH_MAX_BYTES} bytes — refused", file=sys.stderr)
336
+ return EX_CODES["dataerr"]
337
+ try:
338
+ data = json.loads(chunk.decode("utf-8", "replace"))
339
+ except RecursionError:
340
+ print(f"fbtodo: {path}: too deeply nested to parse", file=sys.stderr)
341
+ return EX_CODES["dataerr"]
342
+ except ValueError as exc:
343
+ print(f"fbtodo: {path}: not JSON: {exc}", file=sys.stderr)
344
+ return EX_CODES["dataerr"]
345
+ try:
346
+ check_ingress(data)
347
+ except ValueError as exc:
348
+ print(f"fbtodo: {path}: {exc}", file=sys.stderr)
349
+ return EX_CODES["dataerr"]
350
+ return None
351
+
352
+
353
+ def state_from_push(data: dict) -> dict:
354
+ """A pushed JSON object as a state the rest of the tool can read.
355
+
356
+ Only the fields a list is MADE of are taken — the steps, the goal, the session, and the
357
+ identity a pusher may carry over (`list_id`/`list_version`) — and everything else is
358
+ stamped here: the pusher does not need to know the tool's clock fields, and a pusher that
359
+ sent the whole object `fbtodo json` printed is not punished for the extra keys. What it
360
+ WILL refuse is a payload over the ingress caps (`check_ingress`), before anything is
361
+ built from it.
362
+ """
363
+ check_ingress(data)
364
+ todos = data.get("todos") or []
365
+ steps = [{"task": t["task"].strip(), "completed": bool(t.get("completed"))}
366
+ for t in todos]
367
+ now = int(time.time() * 1000)
368
+ st = {k: data[k] for k in PUSH_FIELDS if k in data}
369
+ st["todos"] = steps
370
+ st.update({
371
+ "ts": int(st.get("ts") or now),
372
+ "backend": "push",
373
+ "source": "push",
374
+ "status": "watching",
375
+ "tool_version": VERSION,
376
+ "heartbeat_ms": now,
377
+ "probed_ms": now,
378
+ "source_updated_ms": int(st.get("source_updated_ms") or st.get("ts") or now),
379
+ })
380
+ # The pusher's prose is filtered here like any other source's: the state file is read by
381
+ # `json` and `status` too, so cleaning only at render would leave them exposed.
382
+ return clean_observation(st)
383
+
384
+
385
+ def cmd_push(args) -> int:
386
+ """Make a state JSON on stdin the live state, so anything can drive the pane.
387
+
388
+ The pushed object is normalized to the fields a list is made of and put through the same
389
+ `finish_state` a watched list goes through, so its counts, its session and its list number
390
+ are the tool's rather than the pusher's — a pusher that says `list_version: 9` gets 9 only
391
+ if it is the same list the state already held, and one that says nothing gets 1. `--to
392
+ PATH` writes that file instead of the live state (which is how the demo does not touch
393
+ yours), `--dry-run` writes nothing at all, `--quiet` prints nothing, and anything that is
394
+ not an object with a list of steps is refused with the state left alone.
395
+ """
396
+ # Bounded read: one byte past the cap is enough to know it was broken, and reading the
397
+ # whole thing first is exactly the denial-of-service the cap exists to refuse.
398
+ stream = getattr(sys.stdin, "buffer", sys.stdin)
399
+ chunk = stream.read(PUSH_MAX_BYTES + 1)
400
+ if isinstance(chunk, bytes):
401
+ oversize, raw = len(chunk) > PUSH_MAX_BYTES, chunk.decode("utf-8", "replace")
402
+ else:
403
+ oversize, raw = len(chunk) > PUSH_MAX_BYTES, chunk
404
+ if oversize:
405
+ print(f"fbtodo push: stdin is larger than {PUSH_MAX_BYTES} bytes — refused",
406
+ file=sys.stderr)
407
+ return EX_CODES["dataerr"]
408
+ if not raw.strip():
409
+ print("fbtodo push: nothing on stdin — expected a state JSON (see docs/SOURCES.md)",
410
+ file=sys.stderr)
411
+ return EX_CODES["noinput"]
412
+ try:
413
+ data = json.loads(raw)
414
+ except RecursionError:
415
+ # A pathological run of `[`/`{` recurses the parser past its limit; that is a
416
+ # malformed payload, so it is the same data error as any other bad JSON.
417
+ print("fbtodo push: stdin is too deeply nested to parse", file=sys.stderr)
418
+ return EX_CODES["dataerr"]
419
+ except ValueError as exc:
420
+ print(f"fbtodo push: stdin is not JSON: {exc}", file=sys.stderr)
421
+ return EX_CODES["dataerr"]
422
+ if not isinstance(data, dict):
423
+ print("fbtodo push: stdin must be a JSON object, not an array or a scalar",
424
+ file=sys.stderr)
425
+ return EX_CODES["dataerr"]
426
+ try:
427
+ state = state_from_push(data)
428
+ except ValueError as exc:
429
+ print(f"fbtodo push: {exc}", file=sys.stderr)
430
+ return EX_CODES["dataerr"]
431
+ target = getattr(args, "push_to", None) or STATE_PATH
432
+ state = finish_state(state, read_json(target, None), claim_first=True)
433
+ if not args.dry_run:
434
+ atomic_write_json(target, state)
435
+ if not args.quiet:
436
+ json.dump(state, sys.stdout, ensure_ascii=False)
437
+ sys.stdout.write("\n")
438
+ return 0
439
+
440
+
441
+ def state_matches_request(state: dict | None, args) -> bool:
442
+ """Is the cached watcher state an answer to THIS question?
443
+
444
+ `auto` takes whatever is live, but an explicit `-s nas` must not be answered by a
445
+ state file describing the local CLI session: a status bar asking for the NAS list
446
+ printed the Mac's own list instead, which looked like the NAS had one. The
447
+ producer's version is checked for the same reason: a watcher left running across an
448
+ upgrade keeps writing the old layout, and then a pane that trusts it silently loses
449
+ every field the new one added (that is how a goal line would go missing).
450
+ """
451
+ if not state:
452
+ return False
453
+ if state.get("tool_version") != VERSION:
454
+ return False
455
+ source = source_kind(getattr(args, "source", "auto"))
456
+ return source == "auto" or (state.get("backend") or "") == source
457
+
458
+
459
+ # =================================================================== commands
460
+ def ensure_daemon(args, quiet: bool = True) -> int | None:
461
+ # The panes are kept by their own process, asked for here because this is the moment
462
+ # a session and its pane appear — and asked for BEFORE the watcher lock is consulted,
463
+ # so a `-s nas` daemon holding that lock cannot leave a local pane unguarded.
464
+ ensure_pane_keeper(args, quiet=quiet)
465
+ running = live_watcher_pid(LOCK_PATH) # an upgrade's leftover is replaced, not adopted
466
+ if running:
467
+ return running
468
+ cwd = os.path.realpath(os.getcwd())
469
+ if args.source == "nas":
470
+ # Nothing local to adopt: the session is a process on the NAS, and the watcher
471
+ # finds out about it from the probe rather than from this machine's process table.
472
+ return spawn_daemon(args, cwd, None, quiet=quiet)
473
+ inst, _ = find_instance(
474
+ cwd, getattr(args, "watch_pid", None), getattr(args, "instance_of", None)
475
+ )
476
+ if not inst:
477
+ if not quiet:
478
+ print(f"no running Freebuff instance for {cwd}", file=sys.stderr)
479
+ return None
480
+ return spawn_daemon(args, cwd, inst, quiet=quiet)
481
+
482
+
483
+ def spawn_daemon(args, cwd: str, instance_pid: int, quiet: bool = True) -> int | None:
484
+ argv = [
485
+ *self_argv(),
486
+ "daemon",
487
+ "--foreground",
488
+ "--quiet",
489
+ "--cwd",
490
+ cwd,
491
+ "-i",
492
+ str(args.interval),
493
+ "-s",
494
+ args.source,
495
+ "--cli-root",
496
+ args.cli_root,
497
+ "--db",
498
+ args.db,
499
+ "--state",
500
+ args.state,
501
+ # The pane draws what the WATCHER wrote, so how many threads to stack has to reach
502
+ # it: a daemon that fell back to the module default would quietly unstack a pane
503
+ # that asked for two.
504
+ "--threads",
505
+ str(getattr(args, "threads", DESKTOP_MAX_THREADS)),
506
+ "--thread-live",
507
+ str(getattr(args, "thread_live", DESKTOP_LIVE_MS // 60_000)),
508
+ # Passed on for the same reason as the NAS knobs below: a daemon that inherited
509
+ # the module default would keep a killed pane reopened for a caller who asked it
510
+ # not to (or never notice one, for a caller who asked it to be quicker).
511
+ "--pane-seconds",
512
+ str(args.pane_seconds),
513
+ "--ask-seconds",
514
+ str(args.ask_seconds),
515
+ "--pause-seconds",
516
+ str(args.pause_seconds),
517
+ ]
518
+ if instance_pid:
519
+ argv += ["--instance-pid", str(instance_pid)]
520
+ if args.project:
521
+ argv += ["--project", args.project]
522
+ if args.chat:
523
+ argv += ["--chat", args.chat]
524
+ # Only meaningful for -s nas, but passed unconditionally so the daemon never falls back
525
+ # to the module defaults when the caller overrode them.
526
+ argv += [
527
+ "--nas-host",
528
+ args.nas_host,
529
+ "--nas-root",
530
+ args.nas_root,
531
+ "--nas-project",
532
+ args.nas_project,
533
+ ]
534
+ # Which session the daemon watches. Absent this, a `-s nas` pane that named its own
535
+ # marker spawned a daemon watching `$HOME/.fb-session` instead, and then read that
536
+ # daemon's answer about a DIFFERENT session: a pane for a live marker saw "no session"
537
+ # (and closed early), and one whose marker had ended stayed up because the file the
538
+ # daemon watched belonged to something else that was still alive.
539
+ if getattr(args, "fb_marker", None):
540
+ argv += ["--fb-marker", args.fb_marker]
541
+ # The estimate knobs, forwarded only when the caller actually set them: the watcher is
542
+ # the process that builds the memories, so a flag it never saw would be silently halved.
543
+ if args.label_floor is not None:
544
+ argv += ["--label-floor", str(args.label_floor)]
545
+ if args.blend_weight is not None:
546
+ argv += ["--blend-weight", str(args.blend_weight)]
547
+ log = open(LOG_PATH, "ab", buffering=0)
548
+ try:
549
+ proc = subprocess.Popen(
550
+ argv, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True
551
+ )
552
+ finally:
553
+ log.close()
554
+ # wait for the lock so callers can rely on the daemon existing
555
+ for _ in range(60):
556
+ time.sleep(0.05)
557
+ if daemon_pid():
558
+ break
559
+ pid = daemon_pid()
560
+ if not quiet and not pid:
561
+ print("watcher failed to start; see " + LOG_PATH, file=sys.stderr)
562
+ return pid
563
+
564
+
565
+ def resolve_instance(args):
566
+ """(cwd to serve, instance pid) — or exit 66 when Freebuff is not running.
567
+
568
+ Started from a shell hook (no --cwd, no --watch-pid) the watcher deliberately
569
+ adopts whichever instance is running and serves *its* directory, so one
570
+ watcher covers the machine instead of only the shell's own cwd.
571
+ """
572
+ served = os.path.realpath(os.path.expanduser(args.cwd)) if args.cwd else None
573
+ cwd = served or os.path.realpath(os.getcwd())
574
+ if args.source == "nas":
575
+ # The session is on the NAS, so there is no local pid to report — the lock carries
576
+ # None and liveness comes from the probe.
577
+ return cwd, None
578
+ if args.instance_pid is not None:
579
+ return cwd, args.instance_pid
580
+ inst, inst_cwd = find_instance(cwd, args.watch_pid, args.instance_of)
581
+ if not inst:
582
+ return None, None
583
+ if served is None and args.watch_pid is None and args.instance_of is None and inst_cwd:
584
+ cwd = os.path.realpath(inst_cwd)
585
+ return cwd, inst
586
+
587
+
588
+ def cmd_daemon(args) -> int:
589
+ if not args.foreground:
590
+ # The shell autostart comes through here, so a keeper appears with the first
591
+ # freebuff of the day even if nothing opens a pane in this terminal.
592
+ ensure_pane_keeper(args, quiet=args.quiet)
593
+ if live_watcher_pid(LOCK_PATH) and not args.force:
594
+ if not args.quiet:
595
+ print(f"watcher already running (pid {daemon_pid()})", file=sys.stderr)
596
+ return 0
597
+ cwd, inst = resolve_instance(args)
598
+ if not inst:
599
+ if not args.quiet:
600
+ print("no running Freebuff instance", file=sys.stderr)
601
+ return EX_CODES["nofile"]
602
+ pid = spawn_daemon(args, cwd, inst, quiet=args.quiet)
603
+ if not pid:
604
+ return EX_CODES["tempfail"]
605
+ if not args.quiet:
606
+ print(f"fbtodo watcher started (pid {pid}, following freebuff pid {inst})", file=sys.stderr)
607
+ return 0
608
+ return daemon_loop(args)
609
+
610
+
611
+ def daemon_loop(args) -> int:
612
+ if daemon_pid() and not args.force:
613
+ if not args.quiet:
614
+ print(f"watcher already running (pid {daemon_pid()})", file=sys.stderr)
615
+ return 0
616
+
617
+ cwd, instance_pid = resolve_instance(args)
618
+ remote = args.source == "nas"
619
+ if not instance_pid and not remote:
620
+ if not args.quiet:
621
+ print("no running Freebuff instance", file=sys.stderr)
622
+ return EX_CODES["nofile"]
623
+ remote_seen = False
624
+
625
+ prev = read_json(STATE_PATH, None)
626
+ # What the last poll had to say (the clock taken out), and when the heartbeat on disk
627
+ # next needs refreshing. `0.0` is due immediately, so the first poll always writes.
628
+ prev_evidence = state_evidence(prev)
629
+ heartbeat_due = 0.0
630
+ if not claim_or_force(LOCK_PATH, cwd, instance_pid, bool(getattr(args, "force", False))):
631
+ # Between the check above and here another watcher could have taken the claim; the
632
+ # claim is what decides, so losing that race means standing down rather than
633
+ # writing a second watcher's state over the same files.
634
+ if not args.quiet:
635
+ print(f"watcher already running (pid {daemon_pid() or '—'})", file=sys.stderr)
636
+ return 0
637
+ # The ask watch's own clock: one scan of the panes every `--ask-seconds`, whatever the
638
+ # render interval is, because a question can arrive between two of them.
639
+ ask_due = 0.0
640
+ # ...and the stall watch's, asked on its own pace: a stop is measured in minutes, so
641
+ # looking more often than `--pause-seconds` buys nothing.
642
+ pause_due = 0.0
643
+ # ...and the pane watch's, on a third clock. It starts one whole interval out, and that
644
+ # is not a detail: it only reports a session that has had no pane for longer than its
645
+ # 30s grace, so an ask before that can never have anything to say — while costing the
646
+ # same half second as the other two, in the very iteration whose heartbeat a reader is
647
+ # most likely to be watching. The ask watch is the opposite case (a question can be on
648
+ # screen at any moment) and starts at once.
649
+ bell_due = time.monotonic() + max(1.0, args.pane_bell_seconds)
650
+ stop_reason = "stopped"
651
+ stop_code = 0
652
+
653
+ def shutdown(signum=None, _frame=None):
654
+ nonlocal stop_reason, stop_code
655
+ stop_reason = f"signal-{signum}" if signum else "stopped"
656
+ stop_code = 128 + signum if signum else 0
657
+ raise SystemExit(stop_code)
658
+
659
+ for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
660
+ signal.signal(sig, shutdown)
661
+
662
+ try:
663
+ while True:
664
+ if not remote and not pid_alive(instance_pid):
665
+ stop_reason = "instance-exited"
666
+ stop_code = 0
667
+ break
668
+ if not lock_ours(LOCK_PATH):
669
+ # the claim is this watcher's hold on the state directory; if it is gone
670
+ # (removed, or the directory itself), or if the file is no longer the one
671
+ # this process locked (something replaced it), the watcher is no longer
672
+ # the owner — stop instead of re-creating the directory.
673
+ stop_reason = "lock-removed"
674
+ stop_code = 0
675
+ break
676
+ st = snapshot(args, cwd=cwd, instance_pid=instance_pid)
677
+ if remote:
678
+ # There is no local pid to lose, so the session ending is read from the probe:
679
+ # close only after one has actually been SEEN, because the pane is opened by the
680
+ # ssh alias and `fb` may not be typed for minutes afterwards.
681
+ if st.get("instance_alive"):
682
+ remote_seen = True
683
+ elif remote_seen:
684
+ stop_reason = "instance-exited"
685
+ stop_code = 0
686
+ break
687
+ st = finish_state(st, prev, claim_first=True)
688
+ st = track_tasks(st)
689
+ prune_scratch() # no-op unless an hour has passed
690
+ st["daemon_pid"] = os.getpid()
691
+ st["heartbeat_ms"] = int(time.time() * 1000)
692
+ st["tool_version"] = VERSION
693
+ st["status"] = "watching"
694
+ st["stop_reason"] = None
695
+ # Write when there is something to say, and otherwise only to keep the
696
+ # heartbeat inside the window a reader calls fresh — measured on a live list:
697
+ # 0.96 rewrites a second (26.8 MB/hour) of a file whose only change was the
698
+ # clock. A rewrite that carries no evidence also carries no fsync: there is
699
+ # nothing in it worth flushing.
700
+ evidence = state_evidence(st)
701
+ if evidence != prev_evidence or time.monotonic() >= heartbeat_due:
702
+ atomic_write_json(STATE_PATH, st, fsync=evidence != prev_evidence)
703
+ heartbeat_due = time.monotonic() + HB_REFRESH
704
+ prev = st
705
+ prev_evidence = evidence
706
+ if args.ask_seconds > 0 and time.monotonic() >= ask_due:
707
+ ask_due = time.monotonic() + args.ask_seconds
708
+ ask_notify_once(args, quiet=args.quiet)
709
+ if not remote and args.pause_seconds > 0 and time.monotonic() >= pause_due:
710
+ pause_due = time.monotonic() + args.pause_seconds
711
+ pause_notify_once(args, instance_pid, quiet=args.quiet)
712
+ # Not gated on `remote`: what it reports is this machine's tmux, and a `-s
713
+ # nas` daemon is often the only thing running here to ask.
714
+ if args.pane_bell_seconds > 0 and time.monotonic() >= bell_due:
715
+ bell_due = time.monotonic() + args.pane_bell_seconds
716
+ pane_notify_once(args, quiet=args.quiet)
717
+ time.sleep(max(0.2, args.interval))
718
+ except SystemExit:
719
+ pass
720
+ except Exception as exc: # never die silently leaving a stale lock
721
+ stop_reason = f"error: {exc.__class__.__name__}"
722
+ stop_code = EX_CODES["tempfail"]
723
+ finally:
724
+ try:
725
+ if os.path.isdir(os.path.dirname(LOCK_PATH)):
726
+ # leave a tombstone so consumers can tell "stopped" from "never
727
+ # ran" — but never resurrect a directory that was removed
728
+ final = dict(prev or {})
729
+ final.update(
730
+ status="stopped",
731
+ stop_reason=stop_reason,
732
+ heartbeat_ms=int(time.time() * 1000),
733
+ daemon_pid=None,
734
+ )
735
+ final.pop("todos", None) # a stopped watcher owns no list
736
+ atomic_write_json(STATE_PATH, final)
737
+ except Exception:
738
+ pass
739
+ clear_lock(os.getpid())
740
+ if not args.quiet and stop_reason:
741
+ print(f"fbtodo watcher stopped ({stop_reason})", file=sys.stderr)
742
+ return stop_code
743
+
744
+
745
+ def cmd_doctor(args) -> int:
746
+ """Whether this machine can show a pane, and what to fix when it cannot.
747
+
748
+ Every question here is one the tool asks for real somewhere else in this file — tmux,
749
+ a writable scratch dir, the terminal's colour story, the tools discovery needs — asked
750
+ all at once and ANSWERED rather than assumed. `ok` needs no action, `warn` is a
751
+ preference that is not met (a pane still works), `FAIL` is something that stops one,
752
+ and any FAIL exits non-zero: that is what makes this a gate a wrapper or a CI job can
753
+ call, rather than a paragraph of advice. `--json` is the same list for a machine.
754
+ """
755
+ checks: list = [] # (level, name, detail)
756
+
757
+ py = sys.version_info
758
+ checks.append(("ok" if py >= (3, 9) else "FAIL", "python",
759
+ f"{py.major}.{py.minor}.{py.micro} at {sys.executable}"))
760
+
761
+ tmux_bin = TMUX_BIN[0] if TMUX_BIN else "tmux"
762
+ if not _shutil.which(tmux_bin):
763
+ checks.append(("FAIL", "tmux", f"no {tmux_bin} on PATH — a pane cannot be opened"))
764
+ else:
765
+ ver = ""
766
+ try:
767
+ ver = subprocess.run(TMUX_BIN + ["-V"], capture_output=True, text=True,
768
+ timeout=5).stdout.strip()
769
+ except Exception:
770
+ ver = ""
771
+ checks.append(("ok" if ver else "warn", "tmux", ver or "present, but would not answer -V"))
772
+ feats = ""
773
+ try:
774
+ feats = subprocess.run(TMUX_BIN + ["show", "-gv", "terminal-features"],
775
+ capture_output=True, text=True, timeout=5).stdout.strip()
776
+ except Exception:
777
+ feats = ""
778
+ if not feats:
779
+ checks.append(("warn", "tmux colour", "no server to ask (a pane will start one)"))
780
+ elif "RGB" in feats:
781
+ checks.append(("ok", "tmux colour", "RGB in terminal-features"))
782
+ else:
783
+ # tmux's own forwarding story, which is NOT what the pane's colours are decided
784
+ # by (that is the `colour` line below, from COLORTERM/TERM): a server without
785
+ # RGB takes the 256-colour palette, and the pane knows it.
786
+ checks.append(("warn", "tmux colour",
787
+ "no RGB in terminal-features — tmux forwards 256 colours"))
788
+
789
+ truecolor = _supports_truecolor()
790
+ checks.append(("ok" if truecolor else "warn", "colour",
791
+ f"{'24-bit' if truecolor else '256-colour'} "
792
+ f"(TERM={os.environ.get('TERM') or '—'}, "
793
+ f"COLORTERM={os.environ.get('COLORTERM') or '—'})"
794
+ + ("" if truecolor else " — COLORTERM=truecolor gets the designed palette")))
795
+ loc = os.environ.get("LC_ALL") or os.environ.get("LC_CTYPE") or os.environ.get("LANG") or ""
796
+ enc = sys.stdout.encoding or ""
797
+ utf8 = "utf" in loc.lower() or "utf" in enc.lower()
798
+ checks.append(("ok" if utf8 else "warn", "locale",
799
+ f"{loc or 'unset'} / stdout {enc or '—'}"
800
+ + ("" if utf8 else " — the frame's glyphs need a UTF-8 locale")))
801
+
802
+ for tool, level, why in (
803
+ ("ps", "FAIL", "the process table is how the instance is found"),
804
+ ("lsof", "warn" if os.path.isdir(PROC_ROOT) else "FAIL", "cwd discovery"),
805
+ ):
806
+ found = _shutil.which(tool)
807
+ checks.append(("ok", tool, found) if found else (level, tool, f"missing — {why}"))
808
+ checks.append(("ok", "cwd source",
809
+ f"/proc at {PROC_ROOT}" if os.path.isdir(PROC_ROOT) else "lsof (no /proc)"))
810
+
811
+ try:
812
+ os.makedirs(SCRATCH, mode=0o700, exist_ok=True)
813
+ probe = os.path.join(SCRATCH, ".fbtodo-doctor-probe")
814
+ with open(probe, "w", encoding="utf-8") as fh:
815
+ fh.write("ok")
816
+ os.unlink(probe)
817
+ checks.append(("ok", "scratch", SCRATCH))
818
+ except OSError as exc:
819
+ checks.append(("FAIL", "scratch", f"{SCRATCH}: {exc.__class__.__name__}"))
820
+ if STATE_NOTE == "moved":
821
+ checks.append(("ok", "state", f"{SCRATCH} (moved from {LEGACY_SCRATCH})"))
822
+ elif STATE_NOTE == "live":
823
+ checks.append(("warn", "state",
824
+ f"still {SCRATCH} — a live watcher or keeper owns it; "
825
+ f"stop both and the next run moves it to {STATE_DIR}"))
826
+ elif STATE_NOTE == "failed":
827
+ checks.append(("warn", "state", f"still {SCRATCH} — could not move to {STATE_DIR}"))
828
+ holder = lock_holder(LOCK_PATH)
829
+ checks.append(("ok" if holder else "warn", "watcher",
830
+ f"pid {holder} holds {os.path.basename(LOCK_PATH)}" if holder
831
+ else "not running (a pane starts one)"))
832
+
833
+ kit = os.path.dirname(NAS_NOTIFY)
834
+ if not os.path.isdir(kit):
835
+ checks.append(("warn", "notify kit", f"not installed ({kit}) — panes work, bells do not"))
836
+ else:
837
+ missing = [n for n in ("phone.sh", "todo-bell.py", "bell.sh")
838
+ if not os.path.exists(os.path.join(kit, n))]
839
+ checks.append(("warn", "notify kit", f"missing {', '.join(missing)}") if missing
840
+ else ("ok", "notify kit", kit))
841
+
842
+ inst, inst_cwd = find_instance(os.path.realpath(os.getcwd()))
843
+ checks.append(("ok", "instance", f"freebuff pid {inst} in {inst_cwd}") if inst
844
+ else ("warn", "instance",
845
+ "no freebuff for this directory — a watcher here would exit 66"))
846
+
847
+ fails = [c for c in checks if c[0] == "FAIL"]
848
+ warns = [c for c in checks if c[0] == "warn"]
849
+ if args.json:
850
+ json.dump({"version": VERSION, "ok": not fails, "fail": len(fails),
851
+ "warn": len(warns),
852
+ "checks": [{"level": lv, "name": name, "detail": detail}
853
+ for lv, name, detail in checks]},
854
+ sys.stdout, ensure_ascii=False)
855
+ sys.stdout.write("\n")
856
+ return 1 if fails else 0
857
+ ink = {"ok": "2", "warn": "33", "FAIL": "1;31"}
858
+ c = paint(use_color())
859
+ print(f"{c('1', 'fbtodo doctor')} {c('2', VERSION)}")
860
+ for level, name, detail in checks:
861
+ print(f" {c(ink[level], level.ljust(4))} {name.ljust(11)} {detail}")
862
+ print(f" {len(checks) - len(warns) - len(fails)} ok · {len(warns)} warn · {len(fails)} fail"
863
+ + (f" first problem: {fails[0][1]}" if fails else ""))
864
+ return 1 if fails else 0
865
+
866
+
867
+ def cmd_stop(args) -> int:
868
+ pid = daemon_pid()
869
+ if not pid:
870
+ clear_lock()
871
+ print("fbtodo watcher: not running", file=sys.stderr)
872
+ return 0
873
+ try:
874
+ os.kill(pid, signal.SIGTERM)
875
+ except OSError:
876
+ pass
877
+ for _ in range(40):
878
+ if not pid_alive(pid):
879
+ break
880
+ time.sleep(0.05)
881
+ clear_lock()
882
+ if not args.quiet:
883
+ print(f"fbtodo watcher stopped (pid {pid})", file=sys.stderr)
884
+ return 0
885
+
886
+
887
+ def cmd_status(args) -> int:
888
+ cwd = os.path.realpath(os.getcwd())
889
+ inst, inst_cwd = find_instance(cwd, args.watch_pid, args.instance_of)
890
+ pid = daemon_pid()
891
+ st = read_json(STATE_PATH, None)
892
+ # The state file is whatever the LOCAL watcher follows. `-s nas` asks about the NAS, so
893
+ # ask it (one probe): reporting the local session's list against "nas session: — no
894
+ # session found" was a lie about a box that was running a freebuff at the time.
895
+ live = adopt_version(finish_state(snapshot(args), None)) if args.source == "nas" else None
896
+ view = live or st
897
+ read_theme() # the palette resolves its refusals here, so both branches can report them
898
+ if args.json:
899
+ json.dump(
900
+ {
901
+ "instance_pid": inst,
902
+ "instance_cwd": inst_cwd,
903
+ "watcher_pid": pid,
904
+ "state_file": STATE_PATH,
905
+ "state_fresh": state_is_fresh(st),
906
+ "theme_problems": [list(p) for p in THEME_PROBLEMS],
907
+ "refit_readiness": refit_readiness(
908
+ load_tasklog().get("tasks") or {}, model=(st or {}).get("model")
909
+ ),
910
+ "state": st,
911
+ },
912
+ sys.stdout,
913
+ ensure_ascii=False,
914
+ )
915
+ sys.stdout.write("\n")
916
+ return 0
917
+ c = paint(use_color())
918
+ print(f"{c('1', 'fbtodo')} {c('2', cwd)}")
919
+ print(f" freebuff instance : {inst or '—'}{f' ({inst_cwd})' if inst_cwd else ''}")
920
+ others = [p for p in freebuff_pids() if p != inst]
921
+ if others:
922
+ ages = ages_for(others)
923
+ shown = ", ".join(
924
+ f"{p} ({short_duration(ages.get(p, 0) * 1000)} old)" for p in others
925
+ )
926
+ print(f" other instances : {shown} — pin one with --instance-of/--watch-pid")
927
+ print(f" watcher : {pid or '— not running'}")
928
+ # A theme value the palette REFUSED (see `read_theme`) is reported here rather than at
929
+ # render time: the pane falls back to the default and looks right, which is exactly
930
+ # how a bad value survived unnoticed before.
931
+ read_theme()
932
+ if THEME_PROBLEMS:
933
+ shown = ", ".join(f"{name}={value!r}" for name, value in THEME_PROBLEMS)
934
+ print(f" theme values : {len(THEME_PROBLEMS)} ignored ({shown})")
935
+ # In plain words: which build of fbtodo is actually running. The version is
936
+ # hand-maintained, so a watcher left over from an earlier copy keeps working while the
937
+ # pane beside it is a different build — the one way this pane has ever misled its
938
+ # owner. Both numbers on one line make that self-diagnosing: they agree when the pane
939
+ # and its watcher are the same build, and the state's own stamp is exactly what a
940
+ # version-stale watcher is killed for on the next start (see `live_watcher_pid`).
941
+ watching = (st or {}).get("tool_version")
942
+ stale_note = (
943
+ f" (watcher {watching} — stale, replaced on the next start)"
944
+ if watching and watching != VERSION else ""
945
+ )
946
+ print(f" tool version : {VERSION}{stale_note}")
947
+ sess = (st or {}).get("session")
948
+ reason = (st or {}).get("stop_reason")
949
+ bits = ["fresh" if state_is_fresh(st) else "stale"]
950
+ if sess:
951
+ bits.append(f"session {sess}")
952
+ if reason:
953
+ bits.append(str(reason))
954
+ print(f" state file : {' '.join(bits)}")
955
+ # The patches' own two facts, in the same words the pane's row uses and from the same
956
+ # reader: what the last patch pass did, and when the phone was last told something.
957
+ # The NAS answer rides in the probe above; locally it is two tail reads.
958
+ rep = (
959
+ {"patch": (view or {}).get("patch"), "alert": (view or {}).get("alert")}
960
+ if args.source == "nas"
961
+ else local_patch_alert()
962
+ )
963
+ seen = {label: text for label, text, _sev in patch_row(rep)}
964
+ print(f" cli patches : {seen.get('PATCH') or '— nothing logged'}")
965
+ print(f" last alert : {seen.get('ALERT') or '— nothing logged'}")
966
+ if args.source == "nas" or (st or {}).get("backend") == "nas":
967
+ nas = (view or {}).get("nas") or {}
968
+ print(
969
+ f" nas session : {nas.get('dir') or '— no session found'}"
970
+ f" ({'live' if nas.get('live') else 'not running'})"
971
+ )
972
+ if nas.get("fb") and nas.get("fb") != "-":
973
+ marker = "live" if nas.get("fb") == "1" else "stale"
974
+ print(f" fb marker : {marker} {nas.get('fb_project') or '-'}")
975
+ if view and not view.get("todos"):
976
+ # Said out loud, in the pane's own words. Until this line existed a session with no
977
+ # list had no `todos` row at all, and a missing row reads as a broken command rather
978
+ # than as "the agent has not written one yet" — which is the whole question this
979
+ # answers. Same sentence as the pane, from the same function.
980
+ print(f" todos : — none yet ({no_list_reason(view)})")
981
+ # The second basket, in the same words the pane uses: what the session has DONE
982
+ # instead of writing a list. Absent when the state carries no calls, so a fixture
983
+ # or a brand-new session prints exactly as it did before this line existed.
984
+ feed = observed_rows(view, 4)
985
+ for i, seg in enumerate(feed):
986
+ print(f" {'last actions ' if not i else ' '}: {seg}")
987
+ if view and view.get("todos"):
988
+ counts = f"{view.get('done', 0)}/{view.get('total', len(view['todos']))} done"
989
+ if view.get("list_version"):
990
+ counts += f", list #{view['list_version']}"
991
+ print(f" todos : {counts}")
992
+ # The list's own age. The pane has this number too, but only in the strip's
993
+ # parentheses and only while the state is idle — a clock on a step takes that slot,
994
+ # by design. Said here it is always answered, which is what makes "an agent that
995
+ # stopped calling `write_todos` leaves the old progress up" a question the command
996
+ # line can settle.
997
+ print(
998
+ " list written : "
999
+ + (fmt_age(view.get("source_updated_ms") or view.get("ts")) or "?")
1000
+ )
1001
+ if list_behind(view, int(time.time() * 1000)):
1002
+ moved = int(view.get("store_mtime_ms") or 0) - int(
1003
+ view.get("ts") or view.get("source_updated_ms") or 0
1004
+ )
1005
+ print(
1006
+ " list behind : yes — every step is ticked, and no newer"
1007
+ f" `write_todos` in {short_duration(moved) or '10m'}"
1008
+ )
1009
+ if view and (view.get("goal") or view.get("todos")):
1010
+ goal = str(view.get("goal") or "")
1011
+ if not goal:
1012
+ print(" big goal : — none stated (the agent owes a `Goal:` line)")
1013
+ else:
1014
+ over = len(goal) - GOAL_MAX_CHARS
1015
+ note = "" if over <= 0 else f" — {over} chars over the {GOAL_MAX_CHARS} that fit"
1016
+ print(f" big goal : {goal}{note}")
1017
+ print(f" goal source : agent's `Goal:` line")
1018
+ if view and view.get("now"):
1019
+ # "has not caught up" is a claim about a list: with none on screen (a finished one was
1020
+ # just dropped), the request is simply the newer thing, and nothing is behind it.
1021
+ behind = " — the list has not caught up" if view.get("todos") else ""
1022
+ print(f" newer request : {view.get('now')}{behind}")
1023
+ if view:
1024
+ # The turn, for both cases: with a list it says which turn the list belongs to, and
1025
+ # without one it is the only measured thing there is. Same sentence as the pane's.
1026
+ turn_line = turn_note(view, int(time.time() * 1000))
1027
+ if turn_line:
1028
+ print(f" turn : {turn_line}")
1029
+ if view and view.get("nudge"):
1030
+ print(
1031
+ f" pending nudge : {view.get('nudge')} — "
1032
+ "re-write the list, then continue"
1033
+ )
1034
+ if view and view.get("backend") == "cli" and view.get("todos"):
1035
+ # What the finished-task bell rings on, said out loud: a ticked-off list with the
1036
+ # agent still working is exactly the case that must stay silent.
1037
+ if view.get("turn_ended"):
1038
+ print(" agent : turn ended — waiting for you")
1039
+ else:
1040
+ print(" agent : working (the list is not the finish line)")
1041
+ # The ask watch, said out loud like the other notifiers: a question on screen stops
1042
+ # the agent until it is answered, so "is one up right now" belongs in a status line.
1043
+ if not os.path.exists(PAUSE_NOTIFY):
1044
+ print(" stall watch : none installed (~/.config/freebuff-notify/pause-bell.py)")
1045
+ elif args.pause_seconds <= 0:
1046
+ print(" stall watch : off (--pause-seconds 0)")
1047
+ else:
1048
+ print(f" stall watch : on, every {args.pause_seconds:.0f}s")
1049
+ if not os.path.exists(ASK_NOTIFY):
1050
+ print(" ask watch : none installed (~/.config/freebuff-notify/ask-bell.py)")
1051
+ elif args.ask_seconds <= 0:
1052
+ print(" ask watch : off (--ask-seconds 0)")
1053
+ else:
1054
+ asking = ""
1055
+ try:
1056
+ proc = subprocess.run(
1057
+ [ASK_NOTIFY, "--print"], capture_output=True, text=True, timeout=10
1058
+ )
1059
+ line = (proc.stdout or "").strip()
1060
+ if line.startswith("ASK: "):
1061
+ asking = " — a question is on screen now: " + line.split(" — ", 1)[-1][:60]
1062
+ except (OSError, subprocess.SubprocessError):
1063
+ pass
1064
+ print(f" ask watch : on, every {args.ask_seconds:.0f}s{asking}")
1065
+ # The pane watch, in the same three states — and it says what it currently thinks,
1066
+ # because "is the pane watcher working" is otherwise only answerable by killing a pane
1067
+ # and waiting to see whether it comes back.
1068
+ if not os.path.exists(PANE_NOTIFY):
1069
+ print(" pane watch : none installed (~/.config/freebuff-notify/pane-bell.py)")
1070
+ elif getattr(args, "pane_bell_seconds", 0) <= 0:
1071
+ print(" pane watch : off (--pane-bell-seconds 0)")
1072
+ else:
1073
+ verdict = ""
1074
+ try:
1075
+ proc = subprocess.run(
1076
+ [PANE_NOTIFY, "--print", "--keeper", PANE_KEEPER_PATH],
1077
+ capture_output=True, text=True, timeout=30,
1078
+ )
1079
+ line = (proc.stdout or "").strip()
1080
+ if line.startswith("PANE: "):
1081
+ verdict = " — FAILING NOW: " + line.split(" — ", 1)[-1].splitlines()[0][:70]
1082
+ elif line.startswith("silent: "):
1083
+ verdict = " — " + line.split(": ", 1)[-1][:70]
1084
+ except (OSError, subprocess.SubprocessError):
1085
+ pass
1086
+ print(
1087
+ f" pane watch : on, every {args.pane_bell_seconds:.0f}s{verdict}"
1088
+ )
1089
+ # Where this session's list is drawn. It is put back within seconds of being killed,
1090
+ # so "none" is a fact about right now rather than a broken setup — and outside tmux
1091
+ # there is nothing to show, which is not an error either.
1092
+ if inst:
1093
+ mine = local_pane_ids(inst)
1094
+ print(f" todo pane : {', '.join(mine) if mine else '— none right now'}")
1095
+ log = load_tasklog()
1096
+ last = log.get("pruned_ms")
1097
+ when = f"{short_duration((time.time() * 1000 - last))} ago" if last else "never"
1098
+ print(
1099
+ f" task records : {len(log.get('tasks') or {})} kept "
1100
+ f"(caps {MAX_TASK_RECORDS} records / {MAX_TASK_AGE_DAYS}d), pruned {when}"
1101
+ )
1102
+ # What the estimates ride on, and where each waiting step's number comes from: the
1103
+ # feature is measured from the log, so this is the block that answers "why is that
1104
+ # step ~5m?" without reading the JSON by hand.
1105
+ model = (view or {}).get("model") or None
1106
+ history = task_history_from_log(log.get("tasks") or {}, model=model)
1107
+ shapes = shape_history_from_log(log.get("tasks") or {}, model=model)
1108
+ calls_mem = call_memory_from_log(log.get("tasks") or {}, model=model)
1109
+ todos = (view or {}).get("todos") or []
1110
+ times = (view or {}).get("task_times") or {}
1111
+ now_est = int(time.time() * 1000)
1112
+ pace = step_pace_ms(times, todos, now_est, history)
1113
+ pace_spread = pace_spread_ms(times, todos, now_est, history)
1114
+ n_samples = sum(int((v or {}).get("n") or 1) for v in history.values())
1115
+ origin = f"{n_samples} remembered step(s)" if history else "no remembered steps yet"
1116
+ # The spread is printed with the pace it belongs to: `2m` from 10s and 20m of finished
1117
+ # steps is a different claim from `2m` from 2m and 2m, and only the spread says which.
1118
+ if pace_spread and is_wide(*pace_spread):
1119
+ origin += f", spread {fmt_range(*pace_spread)}"
1120
+ print(f" remembered pace : {short_duration(pace, seconds=True)} ({origin})")
1121
+ # The memory that can improve with use: keyed by what a step DID rather than what it was
1122
+ # called, so its samples accumulate where the wording memory has almost none. Printed
1123
+ # with its own sample counts, because "is this getting better?" should be answerable
1124
+ # from the terminal and not from faith.
1125
+ if shapes:
1126
+ # What the rung can actually READ first: a bucket the ladder would never look up is
1127
+ # not evidence that the memory is pulling its weight, so the held-back ones are
1128
+ # counted beside it rather than shown as if they were in use.
1129
+ readable = {b: e for b, e in shapes.items() if sized_entry(shapes, b)}
1130
+ young = [b for b in shapes if (bucket_ordinal(b) or 0) < SHAPE_MIN_BUCKET]
1131
+ thin = [b for b in shapes if b not in young and sized_entry(shapes, b) is None]
1132
+ held = ""
1133
+ if young or thin:
1134
+ why = []
1135
+ if young:
1136
+ why.append(f"{len(young)} under the calls{SHAPE_MIN_BUCKET} floor")
1137
+ if thin:
1138
+ why.append(f"{len(thin)} short of {SHAPE_MIN_SAMPLES} sample(s)")
1139
+ held = f" · {len(young) + len(thin)} not read yet ({', '.join(why)})"
1140
+ top = sorted(readable.items(), key=lambda kv: -int(kv[1].get("n") or 0))[:3]
1141
+ shown = ", ".join(
1142
+ f"{bucket} {fmt_estimate(entry.get('med') or 0)} (n={entry.get('n')})"
1143
+ for bucket, entry in top
1144
+ )
1145
+ if top:
1146
+ print(f" remembered sizes : {len(shapes)} kept — {shown}{held}")
1147
+ else:
1148
+ print(f" remembered sizes : {len(shapes)} kept, none the rung may read yet{held}")
1149
+ else:
1150
+ print(" remembered sizes : none yet (a size is learned when a step finishes)")
1151
+ # What a WAITING step is priced from: its wording's kind, the calls that kind usually
1152
+ # takes, and what each of those calls costs. Printed because it is the memory behind
1153
+ # every row that has not started, and those are most of what `EST REM` sums.
1154
+ if calls_mem:
1155
+ kinds = ", ".join(
1156
+ f"{k} {v}" for k, v in sorted((calls_mem.get("classes") or {}).items())
1157
+ )
1158
+ print(f" remembered calls : {calls_mem.get('calls')} calls x "
1159
+ f"{short_duration(calls_mem.get('rate_ms') or 0, seconds=True)} "
1160
+ f"({kinds})")
1161
+ else:
1162
+ print(" remembered calls : none yet (a kind is learned when a step finishes)")
1163
+ # The one line that answers "is this getting better?" — and the only honest way to
1164
+ # answer it, since the estimate and the outcome are both in the log. Split by source so
1165
+ # a shape that is pulling its weight shows up as a lower median than the list's pace.
1166
+ err = estimate_error(log.get("tasks") or {}, model=model)
1167
+ parts = [
1168
+ f"{src} {err[src]['med']:.2f}x median over {err[src]['n']}"
1169
+ for src in ("shape", "blend", "pace")
1170
+ if err.get(src)
1171
+ ]
1172
+ if parts:
1173
+ print(f" estimate error : {' · '.join(parts)} (factor error of closed steps)")
1174
+ else:
1175
+ print(
1176
+ " estimate error : — nothing closed yet since estimates were stamped "
1177
+ "(a factor of 1.0x would be exact)"
1178
+ )
1179
+ # ...and the honest half: the same rungs scored on the forecast each step got on the
1180
+ # FIRST poll that saw it running, before its size was known. The line above scores the
1181
+ # number the pane was showing as the step closed, whose size key is built from calls the
1182
+ # step had already made; this one cannot flatter a rung that recognises.
1183
+ ferr = forecast_error(log.get("tasks") or {}, model=model)
1184
+ # The interval rides beside the median, because the median alone cannot tell a rung that
1185
+ # misses by a tenth every time from one that misses by a tenth on average.
1186
+ fparts = [f"{src} {fmt_rung(ferr[src])}" for src in SHIPPED_RUNGS if ferr.get(src)]
1187
+ skipped = (ferr.get("late") or {}).get("n") or 0
1188
+ tail = f" · {skipped} stamped late, not scored" if skipped else ""
1189
+ if fparts:
1190
+ print(
1191
+ f" forecast error : {' · '.join(fparts)} "
1192
+ f"(first poll, no answer in hand{tail})"
1193
+ )
1194
+ elif skipped:
1195
+ print(f" forecast error : — {skipped} step(s) stamped late, not scored")
1196
+ else:
1197
+ print(" forecast error : — no step has started since the ledger was added")
1198
+ # ...and the rungs that are scored but never picked. Their own line, because it has to be
1199
+ # visible that the pane is carrying an experiment and that none of it moves a row on
1200
+ # screen: `recent` is judged here and can be picked by nothing.
1201
+ sparts = [f"{src} {fmt_rung(ferr[src])}" for src in SHADOW_RUNGS if ferr.get(src)]
1202
+ if sparts:
1203
+ print(f" shadow rungs : {' · '.join(sparts)} (scored, never picked)")
1204
+ else:
1205
+ print(" shadow rungs : — nothing scored yet (stamped into the vector, never picked)")
1206
+ # ...and whether a gap between two of those rungs is the rung or the sample. Only pairs
1207
+ # the log can actually judge are printed: a duel nobody has the steps for is not a draw,
1208
+ # and showing it as one would read as "these two rungs are equivalent".
1209
+ verdicts = duel_summaries(ledger_rows(log.get("tasks") or {}, now_est, model=model))
1210
+ if verdicts:
1211
+ for a_rung, b_rung, verdict in verdicts:
1212
+ print(f" rung duel : {duel_note(a_rung, b_rung, verdict)}")
1213
+ else:
1214
+ print(" rung duel : — no pair of rungs has been scored on the same steps yet")
1215
+ # ...and whether there is yet enough of that to re-choose the constants from the log
1216
+ # rather than from a replay of the journals. Both counts are what the constants' own
1217
+ # comments say they need; the second is the one that governs the pace bound, because the
1218
+ # clip is consulted on the first few steps of every list and changes nothing on most.
1219
+ rr = refit_readiness(log.get("tasks") or {}, model=model)
1220
+ if not rr["scored"]:
1221
+ print(" refit readiness : — no closed step carries a forecast yet")
1222
+ else:
1223
+ short = max(0, REFIT_MIN_SCORED - rr["scored"])
1224
+ head = f"{rr['scored']} closed step(s) scored"
1225
+ if short:
1226
+ head += f", {short} short of the {REFIT_MIN_SCORED} a median needs"
1227
+ else:
1228
+ head += f" — a median can be read from these ({REFIT_MIN_SCORED}+)"
1229
+ print(f" refit readiness : {head}")
1230
+ detail = (f"the bound was consulted on {rr['eligible']} of them and moved the number "
1231
+ f"on {rr['decided']}")
1232
+ if rr["spans_needed"]:
1233
+ print(f" {detail}; judging the clip needs ~"
1234
+ f"{REFIT_MIN_DECIDED} decided, which at this rate is ~{rr['spans_needed']} "
1235
+ f"spans")
1236
+ elif rr["decided"]:
1237
+ print(f" {detail}; too few to say how many spans judging the "
1238
+ f"clip would take")
1239
+ else:
1240
+ print(f" {detail} — the clip has not changed a single number "
1241
+ f"here yet")
1242
+ # whose steps those are: the memory is per model, so a number without its model is
1243
+ # not auditable — a fast model remembered here must not project a slow one
1244
+ if model:
1245
+ print(f" remembered from : {model}")
1246
+ for t in [t for t in todos if not t.get("completed")][:5]:
1247
+ task = str(t.get("task", ""))
1248
+ bucket = step_shape_bucket(times, task)
1249
+ entry = sized_entry(shapes, bucket)
1250
+ samples = len(step_spans_ms(times, todos, now_est))
1251
+ blended = pending_blend_ms(task, pace, calls_mem)
1252
+ if entry:
1253
+ source = f"size {bucket}, {entry.get('n')} sample(s)"
1254
+ elif blended:
1255
+ source = f"label blend, {label_class(task)}"
1256
+ else:
1257
+ source = f"list pace, {samples} sample(s)" if samples else "list pace"
1258
+ spread = estimate_spread_ms(times, task, shapes, bucket) or pace_spread
1259
+ print(
1260
+ f" estimate : {_clip(task, 44)} "
1261
+ f"{fmt_estimate_spread(estimate_for(times, task, pace, shapes, calls_mem), spread)}"
1262
+ f" ({source})"
1263
+ )
1264
+ print(f" scratch : {scratch_size()} bytes in {SCRATCH}")
1265
+ if STATE_NOTE == "moved":
1266
+ print(f" state : {SCRATCH} (moved from {LEGACY_SCRATCH})")
1267
+ elif STATE_NOTE in ("live", "failed"):
1268
+ print(f" state : {SCRATCH} — "
1269
+ + ("a watcher or the keeper still holds it; stop them to finish the move to "
1270
+ f"{STATE_DIR}" if STATE_NOTE == "live" else f"could not move to {STATE_DIR}"))
1271
+ return 0
1272
+
1273
+
1274
+ def cmd_prune(args) -> int:
1275
+ report = prune_scratch(
1276
+ max_records=args.max_records,
1277
+ max_age_days=args.max_age_days,
1278
+ log_cap=(args.log_cap_kb * 1024) if args.log_cap_kb is not None else None,
1279
+ force=True,
1280
+ )
1281
+ if args.json:
1282
+ json.dump(
1283
+ {"scratch": SCRATCH, "scratch_bytes": scratch_size(), **report},
1284
+ sys.stdout,
1285
+ ensure_ascii=False,
1286
+ )
1287
+ sys.stdout.write("\n")
1288
+ return 0
1289
+ c = paint(use_color())
1290
+ trunc = report["log_truncated_bytes"]
1291
+ print(f"{c('1', 'fbtodo prune')} {c('2', SCRATCH)}")
1292
+ print(f" task records : kept {report['records_kept']}, removed {report['records_removed']}")
1293
+ print(f" temp files swept : {report['temp_removed']}")
1294
+ print(f" daemon log : {'truncated ' + str(trunc) + ' bytes' if trunc else 'within cap'}")
1295
+ print(f" scratch after : {scratch_size()} bytes")
1296
+ return 0
1297
+
1298
+
1299
+ # Which rungs are worth judging against each other, in the order a reader reads them. The
1300
+ # three the pane can pick between, paired: the question "is this one better" only has a
1301
+ # meaning between two rungs that were both asked the same steps.
1302
+ DUEL_PAIRS = (("pace", "blend"), ("pace", "shape"), ("blend", "shape"),
1303
+ ("pace", "recent")) # ...and the shadow rung against the one it would replace
1304
+
1305
+
1306
+ def ledger_rows(
1307
+ tasks: dict,
1308
+ now_ms: int | None = None,
1309
+ days: float | None = None,
1310
+ model: str | None = None,
1311
+ ) -> list[dict]:
1312
+ """One row per step that carries a forecast vector, newest first.
1313
+
1314
+ `fbtodo status` gives the SCOREBOARD; this gives the numbers it was computed from, which
1315
+ is the only way to see why a rung scores what it scores: the vector as it stood before the
1316
+ work started, the span it was scored against, and the factor error of each rung on it.
1317
+ A row carries no errors when there is nothing to score it against — the step is still
1318
+ running, its span is under the evidence floor, or the vector was stamped too late to be a
1319
+ forecast — and `why` says which of those it is rather than letting the row read as a miss.
1320
+ """
1321
+ now_ms = now_ms or int(time.time() * 1000)
1322
+ days = HISTORY_MAX_AGE_DAYS if days is None else days
1323
+ cutoff = now_ms - int(days * 86400 * 1000)
1324
+ rows: list[dict] = []
1325
+ for key, rec in (tasks or {}).items():
1326
+ if not isinstance(rec, dict):
1327
+ continue
1328
+ fc = rec.get("fc")
1329
+ if not isinstance(fc, dict):
1330
+ continue
1331
+ started, stopped = rec.get("started_ms"), rec.get("done_ms")
1332
+ if not started or started < cutoff:
1333
+ continue
1334
+ session, _, label = str(key).partition("\x1f")
1335
+ if model and (rec.get("model") or "") != model:
1336
+ continue
1337
+ span = (stopped - started) if (stopped and stopped > started) else None
1338
+ late = int(fc.get("late") or 0)
1339
+ # How far into the step the vector was written. A step that is ticked, unticked and
1340
+ # worked again restarts its clock while KEEPING the vector it earned on the first
1341
+ # sighting, so the stamp can read as negative — it was written before this run began,
1342
+ # which is the safest kind of forecast there is, but it is not what a reader assumes.
1343
+ stamp_in = (int(fc.get("at") or 0) - started) if fc.get("at") else None
1344
+ preds = {}
1345
+ for rung in SCORED_RUNGS:
1346
+ try:
1347
+ value = int(fc.get(rung) or 0)
1348
+ except (TypeError, ValueError):
1349
+ value = 0
1350
+ if value > 0:
1351
+ preds[rung] = value
1352
+ errs: dict[str, float] = {}
1353
+ why = ""
1354
+ if late:
1355
+ why = f"stamped {short_duration(late, seconds=True)} in — late, not scored"
1356
+ elif span is None:
1357
+ why = "no outcome yet"
1358
+ elif span < label_floor_ms():
1359
+ why = f"span under the {short_duration(label_floor_ms())} floor — not scored"
1360
+ else:
1361
+ errs = {r: round(max(p / span, span / p), 2) for r, p in preds.items()}
1362
+ ver = f"v{fc.get('v')}" if fc.get("v") else ""
1363
+ if late or stamp_in is None:
1364
+ # a late row's `why` already says when the stamp landed, so do not say it twice
1365
+ note = ver
1366
+ elif stamp_in < 0:
1367
+ note = f"{ver} · stamped {short_duration(-stamp_in, seconds=True)} before this run"
1368
+ elif stamp_in < 1000:
1369
+ # the normal case: the poll that started the clock wrote the vector on the same
1370
+ # pass, and `short_duration(0)` is an empty string — which read as "stamped in"
1371
+ note = f"{ver} · stamped on the first poll"
1372
+ else:
1373
+ note = f"{ver} · stamped {short_duration(stamp_in, seconds=True)} in"
1374
+ rows.append({
1375
+ "session": session, "label": label, "model": rec.get("model") or "",
1376
+ "started_ms": started, "span_ms": span, "fc_at": int(fc.get("at") or 0),
1377
+ "v": fc.get("v") or "", "pick": fc.get("pick") or "",
1378
+ "preds": preds, "errors": errs, "late_ms": late, "stamp_in_ms": stamp_in,
1379
+ "scored": bool(errs), "why": why, "note": note,
1380
+ })
1381
+ rows.sort(key=lambda r: -r["started_ms"])
1382
+ return rows
1383
+
1384
+
1385
+ def duel_summaries(rows: list[dict]) -> list[tuple]:
1386
+ """The rung pairs this set of ledger rows can actually judge, with their verdicts.
1387
+
1388
+ Returned rather than printed so `doctor` and `fbtodo ledger` say the same sentence over
1389
+ the same evidence — and so the pair nobody has the steps for is left out of both instead
1390
+ of being reported as a draw. The rows are the whole window, not the page a `--limit`
1391
+ shows: the verdict is about the log, and truncating the display must not change it.
1392
+ """
1393
+ out = []
1394
+ for a_rung, b_rung in DUEL_PAIRS:
1395
+ verdict = rung_duel(rows, a_rung, b_rung)
1396
+ if verdict["steps"]:
1397
+ out.append((a_rung, b_rung, verdict))
1398
+ return out
1399
+
1400
+
1401
+ def fmt_ledger(
1402
+ rows: list[dict],
1403
+ now_ms: int,
1404
+ days: float | None = None,
1405
+ limit: int = 20,
1406
+ color: bool = False,
1407
+ empty: str = "",
1408
+ ) -> str:
1409
+ """The `fbtodo ledger` report as text — split out from the command so a fixture can
1410
+ assert on what a reader would actually see, honesty notes included."""
1411
+ c = paint(color)
1412
+ days = HISTORY_MAX_AGE_DAYS if days is None else days
1413
+ out = [f"{c('1', 'fbtodo ledger')} {c('2', TASKS_PATH)}"]
1414
+ if not rows:
1415
+ out.append(empty or (
1416
+ " no step carries a forecast yet — one is written on the first poll "
1417
+ "that finds a step running, and scored when that step closes"
1418
+ ))
1419
+ return "\n".join(out)
1420
+ scored = sum(1 for r in rows if r["scored"])
1421
+ late = sum(1 for r in rows if r["late_ms"])
1422
+ out.append(f" {len(rows)} step(s) with a forecast · {scored} scored · {late} stamped "
1423
+ f"late (not scored) · evidence floor {short_duration(label_floor_ms())} · "
1424
+ f"last {days:g}d")
1425
+ # ...and the verdict on the rungs those rows are evidence for, above them: the rows say
1426
+ # what each step predicted, and this says what the difference between two of them is worth
1427
+ for a_rung, b_rung, verdict in duel_summaries(rows):
1428
+ out.append(f" rung duel: {duel_note(a_rung, b_rung, verdict)}")
1429
+ shown = rows if not limit else rows[:limit]
1430
+ for r in shown:
1431
+ span = short_duration(r["span_ms"], seconds=True) if r["span_ms"] else "—"
1432
+ label = r["label"] if len(r["label"]) <= 62 else r["label"][:61] + "…"
1433
+ out.append(f" {span:<8} {label:<62} {c('2', fmt_age(r['started_ms'], now_ms))}")
1434
+ bits = []
1435
+ # the shipped rungs in their reading order, then any shadow rung the row actually
1436
+ # carries: a row recorded before the shadow rung existed is not shown a `recent —` it
1437
+ # never had, and a row that has one shows it beside the rungs it is competing with
1438
+ shown_rungs = list(SHIPPED_RUNGS) + [x for x in SHADOW_RUNGS if x in r["preds"]]
1439
+ for rung in shown_rungs:
1440
+ pred = r["preds"].get(rung)
1441
+ if pred is None:
1442
+ if rung != "pace":
1443
+ bits.append(f"{rung} —")
1444
+ continue
1445
+ text = f"{rung} {short_duration(pred, seconds=True)}"
1446
+ miss = r["errors"].get(rung)
1447
+ if miss is not None:
1448
+ text += " ×%.2f" % miss
1449
+ bits.append(text)
1450
+ picked = f" · pick {r['pick']}" if r["pick"] else ""
1451
+ detail = " · ".join(x for x in (r["model"], r["note"], r["why"]) if x)
1452
+ suffix = f" ({detail})" if detail else ""
1453
+ out.append(f" {c('2', ' · '.join(bits) + picked)}{c('2', suffix)}")
1454
+ if len(shown) < len(rows):
1455
+ out.append(f" … {len(rows) - len(shown)} more (--limit 0 for all, --days N to widen)")
1456
+ return "\n".join(out)
1457
+
1458
+
1459
+ def cmd_ledger(args) -> int:
1460
+ """Every step's forecast vector, next to the outcome it was scored against.
1461
+
1462
+ The ledger is the honest half of the scoreboard: each step's prediction is written once,
1463
+ on the first poll that saw it running, so the rungs are compared before anything about
1464
+ the step's size was known. `fbtodo status` prints the summary; this prints the rows,
1465
+ newest first, with each rung's factor error — and says when a row could not be scored
1466
+ rather than showing it as a miss. Read-only: it changes nothing. `--days N` (default 60),
1467
+ `--limit N` (default 20, 0 for all), `--model M`, `--json` for the same fields.
1468
+ """
1469
+ now_ms = int(time.time() * 1000)
1470
+ rows = ledger_rows(load_tasklog().get("tasks") or {}, now_ms, args.days, args.model)
1471
+ if args.json:
1472
+ json.dump({"at": now_ms, "count": len(rows), "ledger": rows}, sys.stdout,
1473
+ ensure_ascii=False)
1474
+ sys.stdout.write("\n")
1475
+ return 0
1476
+ if args.quiet:
1477
+ return 0
1478
+ filters = []
1479
+ if args.model:
1480
+ filters.append(f"--model {args.model}")
1481
+ if args.days is not None:
1482
+ filters.append(f"--days {args.days:g}")
1483
+ empty = (f" no step matches {' '.join(filters)} — drop the filter to see what there is"
1484
+ if filters else "")
1485
+ sys.stdout.write(
1486
+ fmt_ledger(rows, now_ms, args.days, args.limit, use_color(), empty) + "\n"
1487
+ )
1488
+ return 0
1489
+
1490
+
1491
+ def cmd_pane(args) -> int:
1492
+ cwd = os.path.realpath(os.getcwd())
1493
+ if not args.no_daemon:
1494
+ ensure_daemon(args)
1495
+ if not sys.stdout.isatty() and not args.once:
1496
+ print("refusing to poll without a TTY (use `fbtodo snap`)", file=sys.stderr)
1497
+ return EX_CODES["usage"]
1498
+
1499
+ color = use_color()
1500
+ hide = False
1501
+ saw_instance = False
1502
+ last_sig = None
1503
+ last_draw = 0.0
1504
+ last_frame = None
1505
+ tick = max(1.0, args.tick)
1506
+ stale_after_s = max(0.0, args.stale_after) * 60.0
1507
+ last_act = None
1508
+ last_activity = time.time()
1509
+ # Poll and paint run on separate clocks. A poll is expensive — on the NAS it is an
1510
+ # ssh round trip plus a remote probe — while a repaint is free, so the pane wakes
1511
+ # often enough to move its own numbers at 1Hz and only reaches for the store every
1512
+ # `args.interval`. Sleeping the whole gap is what made a NAS pane look stuck: its
1513
+ # clock and its "N ago" could not move until the next ssh had come back.
1514
+ WAKE = 0.25
1515
+ next_poll = 0.0
1516
+ state = watching = inst = None
1517
+
1518
+ def restore(*_a):
1519
+ if hide:
1520
+ sys.stderr.write("\x1b[?25h")
1521
+ sys.stderr.flush()
1522
+ raise SystemExit(130)
1523
+
1524
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
1525
+ signal.signal(sig, restore)
1526
+
1527
+ def draw(text: str) -> None:
1528
+ # Row by row against the last paint (see `pane_repaint`): the clock moves every tick and
1529
+ # only its own row changes, so a full clear-and-paint would flash the whole pane to move
1530
+ # one line — and would write kilobytes a second to move it.
1531
+ nonlocal last_frame
1532
+ frame = text.splitlines()
1533
+ rows = _shutil.get_terminal_size(fallback=(80, 24)).lines
1534
+ out = pane_repaint(last_frame, frame, rows)
1535
+ last_frame = frame
1536
+ if not out:
1537
+ return
1538
+ sys.stdout.write(out)
1539
+ sys.stdout.flush()
1540
+
1541
+ try:
1542
+ if not args.once:
1543
+ sys.stdout.write("\x1b[?25l")
1544
+ sys.stdout.flush()
1545
+ hide = True
1546
+ while True:
1547
+ now = time.time()
1548
+ if state is None or now >= next_poll:
1549
+ remote = args.source == "nas"
1550
+ if remote:
1551
+ inst = None # decided from the state below: a live remote session, or nothing
1552
+ else:
1553
+ inst, _ = find_instance(cwd, args.watch_pid, args.instance_of)
1554
+ st = read_json(STATE_PATH, None)
1555
+ if state_is_fresh(st, max(HEARTBEAT_GRACE, args.interval * 3)) and state_matches_request(st, args):
1556
+ # Only this source's watcher state: a `-s nas` pane must not render the
1557
+ # local CLI watcher's list just because that one happens to be fresh.
1558
+ state = st
1559
+ watching = st.get("daemon_pid") or daemon_pid()
1560
+ else:
1561
+ state = finish_probe(snapshot(args, cwd=cwd, instance_pid=inst))
1562
+ # No watcher is serving THIS request (that is why we snapshotted), so name
1563
+ # one only if the cached state actually describes it: a `-s nas` pane was
1564
+ # crediting the local CLI watcher for a remote session it never watched.
1565
+ watching = daemon_pid() if (st and state_matches_request(st, args)) else None
1566
+ if remote:
1567
+ # A live session on the NAS is the "instance" this pane follows; once it is gone
1568
+ # the pane closes itself (and until one appears, it says it is waiting).
1569
+ inst = True if state.get("instance_alive") else None
1570
+ if not state.get("error") and not state.get("task_times"):
1571
+ # either no watcher, or one from before timings existed — track
1572
+ # here so task clocks are never silently missing
1573
+ state = track_tasks(state)
1574
+ next_poll = time.time() + max(0.2, args.interval)
1575
+ now = time.time()
1576
+ # “stale” means the *store* stopped moving, not that the list stopped:
1577
+ # the journal is appended every iteration while the agent works, so a
1578
+ # long single step keeps the pane alive while a finished or abandoned
1579
+ # session lets it expire.
1580
+ act = (
1581
+ state.get("store_mtime_ms"),
1582
+ state.get("iteration"),
1583
+ state.get("list_id"),
1584
+ state.get("done"),
1585
+ state.get("total"),
1586
+ state.get("session"),
1587
+ state.get("goal"),
1588
+ state.get("now"),
1589
+ )
1590
+ if act != last_act:
1591
+ last_act, last_activity = act, now
1592
+ idle_s = now - last_activity
1593
+ text = render(
1594
+ state,
1595
+ color,
1596
+ watching=watching,
1597
+ width=width_of_default(),
1598
+ idle_s=idle_s,
1599
+ stale_after_s=stale_after_s,
1600
+ goal_lines=args.goal_lines,
1601
+ height=_shutil.get_terminal_size(fallback=(80, 24)).lines,
1602
+ )
1603
+ if args.once:
1604
+ print(text)
1605
+ return 0
1606
+ # repaint when the *content* changes, and otherwise on a tick so the
1607
+ # clock in the footer keeps moving — a still pane must still look alive
1608
+ sig = (
1609
+ state.get("list_id"),
1610
+ state.get("list_version"),
1611
+ state.get("done"),
1612
+ state.get("total"),
1613
+ state.get("backend"),
1614
+ state.get("session"),
1615
+ state.get("goal"),
1616
+ state.get("now"),
1617
+ state.get("cleared"),
1618
+ state.get("error"),
1619
+ watching,
1620
+ )
1621
+ if sig != last_sig or now - last_draw >= (
1622
+ 1.0 if has_running_clock(state, int(now * 1000)) else tick
1623
+ ):
1624
+ draw(text)
1625
+ last_sig, last_draw = sig, now
1626
+ if stale_after_s and idle_s >= stale_after_s:
1627
+ draw(text)
1628
+ print(c_stale_notice(color, idle_s, stale_after_s))
1629
+ return 0
1630
+ if inst is not None:
1631
+ saw_instance = True
1632
+ elif saw_instance and not args.wait:
1633
+ # the instance this pane was watching is gone: don't linger.
1634
+ # --wait is for a pane whose real lifetime is the shell that owns
1635
+ # it (an ssh into the remote host: freebuff can be run again in it).
1636
+ draw(text)
1637
+ print(c_notice(color))
1638
+ return 0
1639
+ time.sleep(min(WAKE, max(0.05, next_poll - time.time())))
1640
+ except KeyboardInterrupt:
1641
+ restore()
1642
+ return 0
1643
+ finally:
1644
+ if hide:
1645
+ sys.stderr.write("\x1b[?25h")
1646
+ sys.stderr.flush()
1647
+
1648
+
1649
+ def c_notice(color: bool) -> str:
1650
+ return paint(color)("2", "freebuff instance exited — fbtodo pane closing")
1651
+
1652
+
1653
+ def c_stale_notice(color: bool, idle_s: float, limit_s: float) -> str:
1654
+ return paint(color)(
1655
+ "2",
1656
+ f"todo list idle {short_duration((idle_s or 0) * 1000)} "
1657
+ f"(limit {short_duration(limit_s * 1000)}) — fbtodo pane closing",
1658
+ )
1659
+
1660
+
1661
+ def cmd_snap(args) -> int:
1662
+ state = finish_probe(snapshot(args))
1663
+ if not state.get("error"):
1664
+ state = track_tasks(state)
1665
+ print(render(state, use_color(), width=width_of_default(), goal_lines=args.goal_lines))
1666
+ return 0
1667
+
1668
+
1669
+ def cmd_json(args) -> int:
1670
+ st = read_json(STATE_PATH, None)
1671
+ state = (
1672
+ st
1673
+ if state_is_fresh(st) and state_matches_request(st, args)
1674
+ else finish_probe(snapshot(args))
1675
+ )
1676
+ if not state.get("error") and not state.get("task_times"):
1677
+ state = track_tasks(state)
1678
+ json.dump(state, sys.stdout, ensure_ascii=False)
1679
+ sys.stdout.write("\n")
1680
+ return 0
1681
+
1682
+
1683
+ def cmd_bar(args) -> int:
1684
+ st = read_json(STATE_PATH, None)
1685
+ state = (
1686
+ st
1687
+ if state_is_fresh(st) and state_matches_request(st, args)
1688
+ else finish_probe(snapshot(args))
1689
+ )
1690
+ print(bar_text(state))
1691
+ return 0
1692
+
1693
+
1694
+ def nas_anchor_in_window(window: str | None, nas_host: str | None) -> str | None:
1695
+ """The ssh pane a NAS list in THIS window belongs under, when exactly one login is in it.
1696
+
1697
+ Only panes already on this server are looked at — no ssh, no marker: a pin set at the
1698
+ keyboard has to be answered at keyboard speed, and the watcher does the authoritative
1699
+ placement while a session runs anyway. Several logins in one window is ambiguous (which
1700
+ one runs `fb` takes the far side's marker), so the pin is left to the watcher then.
1701
+ """
1702
+ if not window or not nas_host:
1703
+ return None
1704
+ rows, table = pane_rows(), process_table()
1705
+ here = [
1706
+ cand["pane"] for cand in ssh_session_candidates(nas_host, rows, table)
1707
+ if window_of(cand["pane"]) == window
1708
+ ]
1709
+ return here[0] if len(here) == 1 else None
1710
+
1711
+
1712
+ def apply_pin(window: str, role: str = "both", nas_host: str | None = None) -> list[str]:
1713
+ """Put this window's list panes where the pin now says; their ids back.
1714
+
1715
+ Run when a pin is set, so the effect is visible at once instead of on the next keeper
1716
+ pass, and with the same two halves the keeper and the NAS watcher enforce: the side the
1717
+ pane sits on, and the size it holds. `role` limits it to one of them — that is what
1718
+ `--role nas` is for, and a window holding both a session and an ssh has two panes.
1719
+ """
1720
+ rows, table = pane_rows(), process_table()
1721
+ rects = pane_rects()
1722
+ touched = []
1723
+ for one in (("local", "nas") if role == "both" else (role,)):
1724
+ layout = pane_layout(window, one)
1725
+ anchors = []
1726
+ if one == "local":
1727
+ for inst, win in session_windows(rows, table).items():
1728
+ if win != window:
1729
+ continue
1730
+ inst_pane = freebuff_pane_id(inst, rows=rows, table=table)
1731
+ if inst_pane:
1732
+ anchors.append((inst_pane, local_pane_ids(inst, rows, table)))
1733
+ else:
1734
+ anchor = nas_anchor_in_window(window, nas_host or NAS_HOST)
1735
+ if anchor:
1736
+ anchors.append(
1737
+ (anchor, [pane for pane in nas_pane_ids() if window_of(pane) == window])
1738
+ )
1739
+ for anchor, panes in anchors:
1740
+ for pane in panes:
1741
+ if place_pane_beside(pane, anchor, rects, layout["side"]):
1742
+ touched.append(pane)
1743
+ rects = pane_rects()
1744
+ if resize_pane_to(pane, rects, layout["side"], layout["size"]):
1745
+ touched.append(pane)
1746
+ rects = pane_rects()
1747
+ return touched
1748
+
1749
+ def pane_reason(pane, role: str, window: str | None, anchor: str | None, rects: dict,
1750
+ layout: dict, anchor_note: str) -> dict:
1751
+ """What decided one list pane's place: the rule, the anchor, and the numbers.
1752
+
1753
+ Four answers, always the same four, because that is what a person asks about a pane:
1754
+ which pane and role; where it should be (the anchor it is placed against, and the side
1755
+ that decides which edge of it); what side and size are in force and which source
1756
+ supplied them; and whether it is actually there.
1757
+ """
1758
+ here = rects.get(pane) if pane else None
1759
+ there = rects.get(anchor) if anchor else None
1760
+ # A pane in ANOTHER window than its session's is an arrangement the owner made, and both
1761
+ # the keeper and the watcher leave it alone — so a report that called it misplaced would
1762
+ # be sending somebody to fix what is not broken (`place_pane_beside`).
1763
+ elsewhere = bool(here and there and here["window"] != there["window"])
1764
+ # The side as it stands for this pane, not only as the file remembers it: a pane kept on
1765
+ # the other axis of its session reads `seen` until the keeper has filed it (kept_layout).
1766
+ kept = kept_layout(pane, anchor, rects, layout) if pane else layout
1767
+ placed = bool(pane and anchor) and placed_beside(anchor, pane, rects, kept["side"])
1768
+ return {
1769
+ "pane": pane,
1770
+ "role": role,
1771
+ "elsewhere": elsewhere,
1772
+ "window_id": window,
1773
+ "window": layout["window"] or (window or ""),
1774
+ "anchor": anchor,
1775
+ "anchor_note": anchor_note,
1776
+ "side": kept["side"],
1777
+ "side_source": kept["side_source"],
1778
+ "size": layout["size"],
1779
+ "size_source": layout["size_source"],
1780
+ "placed": placed,
1781
+ "now": (f"L{here['left']} T{here['top']} {here['width']}x{here['height']}" if here else ""),
1782
+ "expected": (f"below {anchor}" if kept["side"] == "v" else f"beside {anchor}")
1783
+ if anchor else "no pane to place it against",
1784
+ }
1785
+
1786
+
1787
+ def why_records(pins: dict | None = None, last: dict | None = None) -> list[dict]:
1788
+ """One record per list pane on this server — and per session with no pane at all.
1789
+
1790
+ The missing ones are records too (pane None): "why is there no list here" is the same
1791
+ question as "why is it there", and the answer is the same layout — the pane keeper has
1792
+ not opened it yet, at the side and size the sources below it say.
1793
+ """
1794
+ rows, table = pane_rows(), process_table()
1795
+ rects = pane_rects()
1796
+ pins = load_pins() if pins is None else pins
1797
+ last = load_last() if last is None else last
1798
+ records = []
1799
+ for inst, window in session_windows(rows, table).items():
1800
+ layout = pane_layout(window, "local", pins, last)
1801
+ anchor = freebuff_pane_id(inst, rows=rows, table=table)
1802
+ panes = local_pane_ids(inst, rows, table) or [None]
1803
+ for pane in panes:
1804
+ records.append(
1805
+ pane_reason(pane, "local", window, anchor, rects, layout, f"freebuff pid {inst}")
1806
+ )
1807
+ # The NAS half needs no ssh: the panes are on this server, and the ssh anchor is the
1808
+ # login already sitting here. WHICH login runs `fb` over there takes the marker on the
1809
+ # far side, so with several candidates the record says which rule the watcher applies.
1810
+ cands = ssh_session_candidates(NAS_HOST)
1811
+ nas_window = None
1812
+ nas_panes = nas_pane_ids()
1813
+ for pane in nas_panes:
1814
+ if nas_window is None:
1815
+ nas_window = window_of(pane)
1816
+ layout = pane_layout(nas_window, "nas", pins, last)
1817
+ if len(cands) == 1:
1818
+ anchor, note = cands[0]["pane"], f"the only ssh login to {NAS_HOST}"
1819
+ elif cands:
1820
+ anchor, note = None, (
1821
+ f"{len(cands)} ssh logins ({', '.join(c['pane'] for c in cands)}) — "
1822
+ "the watcher picks the newest that began before the session"
1823
+ )
1824
+ else:
1825
+ anchor, note = None, f"no ssh login to {NAS_HOST} on this server"
1826
+ records.append(pane_reason(pane, "nas", nas_window, anchor, rects, layout, note))
1827
+ return records
1828
+
1829
+
1830
+ def cmd_why(args) -> int:
1831
+ """Say why every list pane is where it is — the rule, the anchor, and the numbers.
1832
+
1833
+ The pane keeper moves panes and the NAS watcher opens them, and both answer to the same
1834
+ four things: the window's pin (that role's own, then the window's shared one), what the
1835
+ pane was left at last time, FBTODO_SPLIT / FBTODO_PANE_SIZE, and the built-in default.
1836
+ Which one won is invisible in a layout that merely looks right — and the first question
1837
+ when one is wrong — so this prints, per pane, the side and size in force WITH the source
1838
+ that supplied them, the pane it is placed against, and whether it is actually there.
1839
+ A pane the owner has moved to the other axis of its session is not one of the four
1840
+ sources winning but a decision being kept: it reads `seen (kept, not filed yet)` until
1841
+ the keeper has seen it twice and written it down as the remembered side (`kept_layout`).
1842
+ `--window TARGET` limits it to one window; `--json` is the same thing for a script.
1843
+ """
1844
+ pins, last = load_pins(), load_last()
1845
+ records = why_records(pins, last)
1846
+ asked = (getattr(args, "window", None) or "").strip()
1847
+ if asked:
1848
+ want = window_of(asked)
1849
+ if not want:
1850
+ print(f"fbtodo why: no such window: {asked}", file=sys.stderr)
1851
+ return EX_CODES["nofile"]
1852
+ records = [rec for rec in records if rec["window_id"] == want]
1853
+ if args.json:
1854
+ json.dump(
1855
+ {
1856
+ "panes": records,
1857
+ "pins": pins,
1858
+ "last": last,
1859
+ "knobs": {
1860
+ "FBTODO_SPLIT": os.environ.get("FBTODO_SPLIT") or None,
1861
+ "FBTODO_PANE_SIZE": os.environ.get("FBTODO_PANE_SIZE") or None,
1862
+ },
1863
+ },
1864
+ sys.stdout, ensure_ascii=False,
1865
+ )
1866
+ sys.stdout.write("\n")
1867
+ return 0
1868
+ if args.quiet:
1869
+ return 0
1870
+ if not records:
1871
+ print("no list pane, and no session to put one beside")
1872
+ return 0
1873
+ for rec in records:
1874
+ rule = (
1875
+ f"side={rec['side']} ({source_note(rec['side_source'], rec['window'])})"
1876
+ f" size={rec['size']} ({source_note(rec['size_source'], rec['window'])})"
1877
+ )
1878
+ if not rec["pane"]:
1879
+ tail = "no list pane yet — a keeper pass opens it there"
1880
+ elif rec["elsewhere"]:
1881
+ tail = f"in another window than its session — left alone, by design {rec['now']}"
1882
+ else:
1883
+ tail = rec["expected"] + (
1884
+ ", placed" if rec["placed"] else ", MISPLACED (a pass moves it back)"
1885
+ )
1886
+ tail += f" {rec['now']}"
1887
+ print(f"{rec['window']:<12} {rec['role']:<5} {rec['pane'] or '(none)':<6} {rule}")
1888
+ print(f"{'':<12} {'':<5} {'':<6} {tail} — {rec['anchor_note']}")
1889
+ bad = [
1890
+ rec["pane"] for rec in records
1891
+ if rec["pane"] and not rec["placed"] and not rec["elsewhere"]
1892
+ ]
1893
+ print(
1894
+ f"{len(records)} list pane(s), {len(bad)} misplaced"
1895
+ + (f": {' '.join(bad)} — tell the keeper to move them: fbtodo pane-watch --once" if bad else "")
1896
+ )
1897
+ return 0
1898
+
1899
+
1900
+ def cmd_pin(args) -> int:
1901
+ """Pin the list pane's side or size, per window.
1902
+
1903
+ The pane follows its session, but a window can have a mind of its own about where that
1904
+ is: a wide window reads better with the list beside the session, a tall one with it
1905
+ below. `fbtodo pin --side h --size 16` run in the window you mean (or with `--window`
1906
+ naming another, `main:2` or `@71`) files that under `<session>:<index>` in
1907
+ `fbtodo-pins.json` in the state directory, and the pane keeper enforces both halves — the side it
1908
+ splits on and puts a drifted pane back on, and the size it resizes back to.
1909
+
1910
+ `--role` says which list pane it is for: `local` (a session's), `nas` (the one a NAS
1911
+ ssh pulls in), or `both` (the default) for the window's shared answer. A window holding
1912
+ both needs the distinction — the two panes need not be the same size — so the role's own
1913
+ pin outranks the shared one for that pane, and only that one.
1914
+
1915
+ Bare `fbtodo pin` reports what this window is set to, per role, and where each half came
1916
+ from (a pin, the remembered size, FBTODO_SPLIT/FBTODO_PANE_SIZE, or the default);
1917
+ `--list` reports every pin; `--clear` drops the window's pin whole; `--side auto` and
1918
+ `--size 0` drop one half of it.
1919
+ """
1920
+ pins = load_pins()
1921
+ if args.list:
1922
+ if args.json:
1923
+ json.dump(pins, sys.stdout, ensure_ascii=False)
1924
+ sys.stdout.write("\n")
1925
+ return 0
1926
+ if not pins:
1927
+ print("no pins (set one with: fbtodo pin --side h --size 16)")
1928
+ return 0
1929
+ for key in sorted(pins):
1930
+ entry = pins[key] if isinstance(pins[key], dict) else {}
1931
+ state = "live" if window_key(key) else "no such window"
1932
+ shared = [f"{f}={entry[f]}" for f in ("side", "size") if entry.get(f)]
1933
+ print(f"{key:<16} {'both':<6} {' '.join(shared) or '-':<16} {state}")
1934
+ for one in ("local", "nas"):
1935
+ sub = entry.get(one)
1936
+ sub = sub if isinstance(sub, dict) else {}
1937
+ got = [f"{f}={sub[f]}" for f in ("side", "size") if sub.get(f)]
1938
+ if got:
1939
+ print(f"{key:<16} {one:<6} {' '.join(got):<16} {state}")
1940
+ return 0
1941
+ window = (getattr(args, "window", None) or "").strip()
1942
+ if not window:
1943
+ out = tmux_run("display-message", "-p", "#{window_id}")
1944
+ window = (out or "").strip()
1945
+ key = window_key(window)
1946
+ if not key:
1947
+ print(
1948
+ "fbtodo pin: no tmux window to pin (run it inside a pane, or name one with "
1949
+ "--window main:2)",
1950
+ file=sys.stderr,
1951
+ )
1952
+ return EX_CODES["nofile"]
1953
+ entry = dict(pins.get(key)) if isinstance(pins.get(key), dict) else {}
1954
+ role = getattr(args, "role", None) or "both"
1955
+ if args.clear:
1956
+ had = key in pins
1957
+ pins.pop(key, None)
1958
+ save_pins(pins)
1959
+ apply_pin(window, nas_host=args.nas_host)
1960
+ if not args.quiet:
1961
+ print(f"{key}: {'pin cleared' if had else 'nothing was pinned'}")
1962
+ return 0
1963
+ asked = args.side is not None or args.size is not None
1964
+ # `both` writes the window's shared halves — what every pin written before there was a
1965
+ # role is; `local`/`nas` write that role's own, which outranks the shared one for it.
1966
+ target = entry if role == "both" else dict(entry.get(role) or {})
1967
+ if getattr(args, "side", None):
1968
+ if args.side == "auto":
1969
+ target.pop("side", None)
1970
+ elif args.side in ("v", "h"):
1971
+ target["side"] = args.side
1972
+ else:
1973
+ print("fbtodo pin: --side takes v, h or auto", file=sys.stderr)
1974
+ return EX_CODES["usage"]
1975
+ if args.size is not None:
1976
+ if args.size <= 0:
1977
+ target.pop("size", None)
1978
+ else:
1979
+ target["size"] = args.size
1980
+ if role != "both":
1981
+ if target:
1982
+ entry[role] = target
1983
+ else:
1984
+ entry.pop(role, None)
1985
+ if asked:
1986
+ if entry:
1987
+ pins[key] = entry
1988
+ else:
1989
+ pins.pop(key, None)
1990
+ save_pins(pins)
1991
+ roles = ("local", "nas") if role == "both" else (role,)
1992
+ layouts = {one: pane_layout(window, one, pins) for one in roles}
1993
+ touched = list(dict.fromkeys(apply_pin(window, role, args.nas_host))) if asked else []
1994
+ first = layouts["local" if "local" in layouts else roles[0]]
1995
+ if args.json:
1996
+ json.dump(
1997
+ {
1998
+ "window": key, "role": role, "pin": entry, "layouts": layouts,
1999
+ "side": first["side"], "size": first["size"], "panes": touched,
2000
+ },
2001
+ sys.stdout, ensure_ascii=False,
2002
+ )
2003
+ sys.stdout.write("\n")
2004
+ return 0
2005
+ if args.quiet:
2006
+ return 0
2007
+ lines = [f"{key} ({'pinned' if entry else 'nothing pinned'})"]
2008
+ for one, layout in layouts.items():
2009
+ lines.append(
2010
+ f" {one:<5} side={layout['side']}"
2011
+ f" ({source_note(layout['side_source'], key)})"
2012
+ f" size={layout['size']}"
2013
+ f" ({source_note(layout['size_source'], key)})"
2014
+ )
2015
+ if touched:
2016
+ lines.append(f" list pane {', '.join(touched)} re-placed")
2017
+ print("\n".join(lines))
2018
+ return 0
2019
+
2020
+
2021
+ # ======================================================================== main
2022
+ def build_parser():
2023
+ ap = argparse.ArgumentParser(
2024
+ prog="fbtodo",
2025
+ description="Watch the Freebuff agent's todo list.",
2026
+ add_help=False,
2027
+ )
2028
+ ap.add_argument("command", nargs="?", default="pane",
2029
+ choices=["pane", "snap", "json", "bar", "daemon", "stop", "status",
2030
+ "prune", "nas", "pane-watch", "pin", "why", "ledger", "doctor",
2031
+ "push"])
2032
+ ap.add_argument(
2033
+ "--label-floor", type=float, default=None, metavar="SEC",
2034
+ help="estimates: a finished span under SEC is not evidence — it sets no pace and "
2035
+ "moves no memory (0 = keep every span; FBTODO_LABEL_FLOOR)",
2036
+ )
2037
+ ap.add_argument(
2038
+ "--blend-weight", type=float, default=None, metavar="W",
2039
+ help="estimates: 0..1, how much a WAITING step's wording counts against the list's "
2040
+ "pace (0 = pace only, 1 = wording only; FBTODO_BLEND_WEIGHT)",
2041
+ )
2042
+ ap.add_argument("--days", type=float, metavar="N",
2043
+ help="ledger: how far back to list (default 60, the retention window)")
2044
+ ap.add_argument("--limit", type=int, default=20, metavar="N",
2045
+ help="ledger: rows to print, newest first (default 20, 0 = all)")
2046
+ ap.add_argument("--model", metavar="NAME", help="ledger: only this model's steps")
2047
+ ap.add_argument("--max-records", type=int, help="prune: cap on kept task records")
2048
+ ap.add_argument("--max-age-days", type=float, help="prune: drop records older than this")
2049
+ ap.add_argument("--log-cap-kb", type=int, help="prune: truncate the daemon log above this")
2050
+ ap.add_argument("-h", "--help", action="store_true")
2051
+ ap.add_argument("-V", "--version", action="store_true")
2052
+ ap.add_argument("--once", action="store_true", help="pane: render once and exit")
2053
+ ap.add_argument("--tick", type=float, default=5.0,
2054
+ help="pane: repaint at least this often while nothing moves "
2055
+ "(1s while a step's clock is counting up, 1s for a store "
2056
+ "that is still being written)")
2057
+ ap.add_argument("--stale-after", type=float, default=60.0, metavar="MIN",
2058
+ help="pane: close after MIN minutes of no store activity (0 = never)")
2059
+ ap.add_argument("-i", "--interval", type=float, default=1.0,
2060
+ help="pane: seconds between polls (the clock repaints far more "
2061
+ "often than this; 5s for -s nas)")
2062
+ ap.add_argument(
2063
+ "-s", "--source", default="auto", metavar="SOURCE",
2064
+ help="where the list comes from: auto, cli, nas, desktop, or file:PATH (a state "
2065
+ "JSON on disk, the same shape `fbtodo push` writes)",
2066
+ )
2067
+ ap.add_argument(
2068
+ "--to", dest="push_to", metavar="PATH",
2069
+ help="push: write this state file instead of the live state (the list a `-s file:PATH` "
2070
+ "then reads back)",
2071
+ )
2072
+ ap.add_argument(
2073
+ "--nas-host", default=NAS_HOST, metavar="USER@HOST",
2074
+ help="nas: ssh target holding the freebuff store (FBTODO_NAS; required for -s nas)",
2075
+ )
2076
+ ap.add_argument(
2077
+ "--nas-root", default=NAS_ROOT, metavar="DIR",
2078
+ help="nas: remote projects directory (FBTODO_NAS_ROOT; required for -s nas)",
2079
+ )
2080
+ ap.add_argument(
2081
+ "--nas-project", default=NAS_PROJECT, metavar="NAME",
2082
+ help="nas: project name inside that directory (FBTODO_NAS_PROJECT; required for -s nas)",
2083
+ )
2084
+ ap.add_argument(
2085
+ "--fb-marker",
2086
+ default=os.environ.get("FBTODO_FB_MARKER") or "$HOME/.fb-session",
2087
+ help="nas: the host `fb` wrapper's session marker (pid, started, dir), read on the NAS",
2088
+ )
2089
+ ap.add_argument(
2090
+ "--wait",
2091
+ action="store_true",
2092
+ help="pane: stay open when the session goes away (a remote pane is closed with its ssh)",
2093
+ )
2094
+ ap.add_argument(
2095
+ "--goal-lines",
2096
+ type=int,
2097
+ default=3,
2098
+ metavar="N",
2099
+ help="pane: lines for the goal heading the list (0 hides it)",
2100
+ )
2101
+ ap.add_argument("--chat")
2102
+ ap.add_argument("--cli-root", default=DEFAULT_CLI_ROOT)
2103
+ ap.add_argument("-p", "--project")
2104
+ ap.add_argument("--db", default=DEFAULT_DB_GLOB)
2105
+ ap.add_argument("--state", default=DEFAULT_WORKSPACE_STATE)
2106
+ ap.add_argument("-t", "--thread")
2107
+ ap.add_argument("--last", action="store_true")
2108
+ ap.add_argument("-A", "--follow-app", action="store_true")
2109
+ # The desktop store holds every thread of a project, and the app can have several of
2110
+ # them running at once: `--threads` is how many the pane stacks (the one it follows
2111
+ # included, 0 for that one alone) and `--thread-live` how quiet a thread's own list may
2112
+ # be and still be one of them (0 = no window at all). Neither means anything to a
2113
+ # journal, a NAS session or a file, which have exactly one list by construction.
2114
+ ap.add_argument("--threads", type=int, default=DESKTOP_MAX_THREADS)
2115
+ ap.add_argument("--thread-live", type=int, default=DESKTOP_LIVE_MS // 60_000)
2116
+ ap.add_argument("-f", "--foreground", action="store_true", help="daemon: don't detach")
2117
+ ap.add_argument("--force", action="store_true", help="daemon: start even if one runs")
2118
+ ap.add_argument("--quiet", action="store_true")
2119
+ ap.add_argument("--json", action="store_true", help="status: JSON output")
2120
+ ap.add_argument("--no-daemon", action="store_true", help="pane: read stores directly")
2121
+ # --- the NAS pane watcher (`nas`): a local daemon because `fb` over there cannot
2122
+ # reach this Mac's tmux, so something here has to notice the session starting.
2123
+ ap.add_argument("--stop", action="store_true", help="nas: stop the pane watcher")
2124
+ ap.add_argument("--status", action="store_true", help="nas: the watcher, the session, and the pane")
2125
+ ap.add_argument("--poll", type=float,
2126
+ default=float(os.environ.get("FBTODO_NAS_POLL") or 5.0),
2127
+ help="nas: seconds between polls while you are at the keyboard")
2128
+ ap.add_argument("--poll-idle", type=float,
2129
+ default=float(os.environ.get("FBTODO_NAS_POLL_IDLE") or 60.0),
2130
+ help="nas: seconds between polls when no tmux client is attached")
2131
+
2132
+ ap.add_argument("--poll-live", type=float, default=2.5,
2133
+ help="nas: seconds between polls while a NAS session runs")
2134
+ ap.add_argument(
2135
+ "--notify-seconds", type=float,
2136
+ default=float(os.environ.get("FBTODO_NOTIFY_SECONDS") or 15.0),
2137
+ help="nas: ask the phone notifier this often while a session runs (0 = never; "
2138
+ "it is skipped when ~/.config/freebuff-notify/todo-bell.py is missing)",
2139
+ )
2140
+ ap.add_argument(
2141
+ "--pause-seconds", type=float,
2142
+ default=float(os.environ.get("FBTODO_PAUSE_SECONDS") or 30.0),
2143
+ help="watcher: ask the stall watch this often whether the session has stopped "
2144
+ "mid-task, local sessions only (0 = never; it is skipped when "
2145
+ "~/.config/freebuff-notify/pause-bell.py is missing)",
2146
+ )
2147
+ ap.add_argument(
2148
+ "--pane-seconds", type=float,
2149
+ default=float(os.environ.get("FBTODO_PANE_SECONDS") or 3.0),
2150
+ help="watcher: this often, put the local todo pane back if it was killed while "
2151
+ "the session runs (0 = never; FBTODO_NO_PANE=1 disables it too)",
2152
+ )
2153
+ ap.add_argument(
2154
+ "--pane-bell-seconds", type=float,
2155
+ default=float(os.environ.get("FBTODO_PANE_BELL_SECONDS") or 60.0),
2156
+ help="watcher: ask the pane watch this often whether a session has no todo pane "
2157
+ "or the keeper is gone (0 = never; it is skipped when "
2158
+ "~/.config/freebuff-notify/pane-bell.py is missing)",
2159
+ )
2160
+ ap.add_argument(
2161
+ "--ask-seconds", type=float,
2162
+ default=float(os.environ.get("FBTODO_ASK_SECONDS") or 3.0),
2163
+ help="watcher: look for a freebuff pane waiting on a question this often, "
2164
+ "local and NAS alike (0 = never; it is skipped when "
2165
+ "~/.config/freebuff-notify/ask-bell.py is missing)",
2166
+ )
2167
+ # --- per-window pins (`pin`): where the list pane goes in THIS window, and how big
2168
+ ap.add_argument("--side", choices=["v", "h", "auto"],
2169
+ help="pin: v puts the list below the session, h beside it, auto unpins the side")
2170
+ ap.add_argument("--size", type=int, metavar="N",
2171
+ help="pin: keep the list pane N lines (0 unpins the size)")
2172
+ ap.add_argument("--role", choices=["local", "nas", "both"], default="both",
2173
+ help="pin: which list pane the pin is for — a session's (`local`), the "
2174
+ "one a NAS ssh pulls in (`nas`), or the window's shared answer "
2175
+ "for any of them (`both`, the default)")
2176
+ ap.add_argument("--window", metavar="TARGET",
2177
+ help="pin, why: the tmux window to act on (default: the pane you are in)")
2178
+ ap.add_argument("--list", action="store_true", help="pin: every pin, and whether its window exists")
2179
+ ap.add_argument("--clear", action="store_true", help="pin: drop this window's pin")
2180
+ ap.add_argument("--dry-run", action="store_true", help="nas: say what it would do, change nothing")
2181
+ ap.add_argument("--idle-exit", type=float, default=30.0, metavar="MIN",
2182
+ help="nas: quit after MIN of no session and no client (0 = run forever)")
2183
+ ap.add_argument("--instance-pid", type=int, help="daemon: watch this pid (internal)")
2184
+ ap.add_argument("--watch-pid", type=int, help="treat this pid as the instance (internal)")
2185
+ ap.add_argument("--instance-of", type=int, metavar="PID",
2186
+ help="follow the freebuff process launched by PID (the shell that started it)")
2187
+ ap.add_argument("--cwd", help="daemon: directory to serve")
2188
+ return ap
2189
+
2190
+
2191
+ def main(argv=None) -> int:
2192
+ # The state root is chosen at import but ACTED ON only here: importing the package must
2193
+ # not create a directory or move the legacy store (see `init_state_root`). Before the
2194
+ # parser is built, so a flag whose default is a path defaults to the real one.
2195
+ init_state_root()
2196
+ ap = build_parser()
2197
+ try:
2198
+ args, extra = ap.parse_known_args(argv)
2199
+ except SystemExit:
2200
+ return EX_CODES["usage"]
2201
+ if extra:
2202
+ print(f"unexpected arguments: {' '.join(extra)}", file=sys.stderr)
2203
+ return EX_CODES["usage"]
2204
+ if args.help:
2205
+ print(__doc__)
2206
+ return 0
2207
+ if args.version:
2208
+ print(VERSION)
2209
+ return 0
2210
+ # The two estimate knobs, applied through the one table every reader asks (see
2211
+ # `set_estimate_knobs`): the env vars are read at import, and a flag beats them, as the
2212
+ # precedence promises.
2213
+ set_estimate_knobs(args.label_floor, args.blend_weight)
2214
+ # `-s` is a word or a `file:PATH`, so the word set is checked here rather than by
2215
+ # argparse's choices: a typo is the caller's usage error (64), and a `file:` prefix
2216
+ # must reach `sources_for` instead of being rejected before it.
2217
+ if args.source not in ("auto", "cli", "nas", "desktop") and not source_path(args.source):
2218
+ print(f"fbtodo: unknown source {args.source!r} — expected auto, cli, nas, desktop, "
2219
+ "or file:PATH", file=sys.stderr)
2220
+ return EX_CODES["ex_usage"]
2221
+ if args.source == "nas" and args.interval <= 1.0:
2222
+ # One ssh per poll, and the pane no longer needs a poll to move its own numbers:
2223
+ # the clock and the "N ago" repaint locally (see the pane loop), so the store is
2224
+ # reached every 5s — half the ssh traffic of the old 2.5s — while the pane still
2225
+ # looks alive every second.
2226
+ args.interval = 5.0
2227
+ os.makedirs(SCRATCH, mode=0o700, exist_ok=True)
2228
+ # A `file:PATH` state is an ingress like push, so the same caps apply and a payload over
2229
+ # one is the same data error (65), not a state the pane is asked to draw. A file that is
2230
+ # simply absent is left to the source's own "no state file" answer.
2231
+ spath = source_path(args.source)
2232
+ if spath:
2233
+ code = check_file_ingress(state_file_in(spath))
2234
+ if code is not None:
2235
+ return code
2236
+
2237
+ if args.command == "push":
2238
+ return cmd_push(args)
2239
+ if args.command == "pane":
2240
+ return cmd_pane(args)
2241
+ if args.command == "snap":
2242
+ return cmd_snap(args)
2243
+ if args.command == "json":
2244
+ return cmd_json(args)
2245
+ if args.command == "bar":
2246
+ return cmd_bar(args)
2247
+ if args.command == "daemon":
2248
+ return cmd_daemon(args)
2249
+ if args.command == "stop":
2250
+ return cmd_stop(args)
2251
+ if args.command == "status":
2252
+ return cmd_status(args)
2253
+ if args.command == "doctor":
2254
+ return cmd_doctor(args)
2255
+ if args.command == "prune":
2256
+ return cmd_prune(args)
2257
+ if args.command == "ledger":
2258
+ return cmd_ledger(args)
2259
+ if args.command == "nas":
2260
+ return cmd_nas(args)
2261
+ if args.command == "pane-watch":
2262
+ return cmd_pane_watch(args)
2263
+ if args.command == "pin":
2264
+ return cmd_pin(args)
2265
+ if args.command == "why":
2266
+ return cmd_why(args)
2267
+ return EX_CODES["usage"]
2268
+
2269
+
2270
+ if __name__ == "__main__":
2271
+ sys.exit(main())