cctally 1.52.1 → 1.53.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.53.0] - 2026-06-21
9
+
10
+ ### Added
11
+ - **Dashboard conversation outline — per-subagent cost, a resizable column, a nested-subagent tree, and a compaction landmark.** Each subagent thread in the outline now shows its cost beside its label (a display-only, summed-cost-once figure that covers every thread, including older ones with no captured spawn metadata). A thin resize divider between the reading column and the outline lets you drag the outline wider or narrower — keyboard-resizable too (`←`/`→` step, `Home`/`End` to the extremes, with a labeled `separator` accessibility surface) — and the width persists across reloads. When a subagent spawns its own sub-subagents, the outline nests those children indented beneath their parent thread (a session with no such nesting looks exactly as before). And a conversation compaction (the auto-summarize point) is now a navigable outline landmark with its own "Jump to" chip and `m`/`M` reader keys. The outline's "Jump to" chips also change: a primary click now jumps to the most-recent occurrence of that landmark family, while shift-click steps to the previous one.
12
+ - **Dashboard conversation reader — opens at the bottom with two-way scrolling, remembers where you were reading, and jumps straight to the last prompt or error.** A long conversation now opens on its newest turns (single-request tail load, with live-tail already following) instead of at the top; a short conversation that fits one page still opens at the top so it reads from the start. The reader window pages in both directions — scrolling down loads newer turns and scrolling up prepends older ones, anchored so the turn you're reading stays put rather than jumping — and a backward (scroll-up) page never breaks the "follow the live session" state. Switching away from a conversation and returning later restores you to the turn you were last reading (anchored to the turn, so it survives a resized window or a grown transcript; a deep-link or jump target still wins, and a vanished turn falls back to the bottom). Two new reader keys jump directly to the most-recent occurrence: `a` to the last prompt and `L` to the last error. `Latest ↓` / `End` now reset to the newest page in a single request (instant even on a very long session) rather than paging all the way forward.
13
+ - **`cctally doctor` — new conversation-rollup consistency check.** `doctor` (and the dashboard's doctor panel) now run a `data.conversation_sessions_rollup` check that compares the materialized `conversation_sessions` browse-rail rollup against the distinct session count in the conversation cache, warning only when they disagree in a quiescent cache — an in-flight sync, reingest, or backfill is detected (via a non-blocking cache-lock probe and the pending-flag set) and never warns — so a stale or half-built rollup that would skew the conversation list's filters and counts is surfaced instead of passing silently.
14
+ - **Dashboard conversation reader — open-at-bottom and backward pagination on the reader API.** `GET /api/conversation/<id>` now pages with `before=<id>` (the page of turns immediately *before* a cursor) and `tail=1` (the last page in a single request, so a client can land on the newest turns without paging forward through the whole session) alongside the existing forward `after=<id>`; the three cursors are mutually exclusive (more than one is a `400`) and `limit` is clamped `1`–`1000` (default `500`). The `page` object gains additive `prev_before`/`has_prev` (older turns remain to page back to) beside `next_after`/`has_more`, each computed from the page's actual position so a short backward page at the head still reports more forward; a stale cursor yields an empty page rather than re-serving the head or tail. Existing no-cursor and `after` responses are byte-stable except for the two additive `page` keys (backend contract only — the frontend lights this up in a later session).
15
+ - **Dashboard conversation search — `kind=title` title search and server-side filtered search.** `GET /api/conversation/search` adds a `kind=title` facet that searches the AI-generated session titles and returns one session-level hit per matching session (anchored to the session's first turn, with `match_kinds:["title"]` and the matched title as the snippet); its `total` counts only sessions with surviving turns, so the count never lies, and it falls back to a `LIKE` scan when the host lacks FTS5. The search route also now accepts the same browse filters as the conversation list (`date_from`/`date_to`/`projects`/`cost_min`/`cost_max`/`rebuild_min`), applied as a session-scope restriction across every kind, with the same brief project/cost/rebuild degradation (a `filter_degraded` flag, date-axis only) while the per-session rollup is still indexing; a no-filter request is byte-stable with the prior output. The cross-session-only `title` facet is rejected with a `400` (never a `500`) on the in-conversation `find` route. Backend contract only — surfaced in a later frontend session.
16
+ - **Dashboard conversation search — `kind=files` file-path search.** `GET /api/conversation/search` adds a `kind=files` facet that searches the write-class file paths each session edited or created (files touched by an `Edit`/`MultiEdit`/`Write`/`NotebookEdit` tool call — read-only `Read` and shell `Bash` are excluded so the axis answers "which sessions modified this file"), returning one hit per distinct `(session, file path)` anchored to that path's most-recent touch (`match_kinds:["file"]`, snippet = the file path) and a `total` that is the distinct `(session, file path)` count; a query with no leading separator is treated as a case-insensitive path prefix that is genuinely index-assisted — it rides the `COLLATE NOCASE` path index as a key-range search (the index is `NOCASE` precisely because the default `LIKE` is case-insensitive and a binary-collated index can't serve it) — while a leading-separator query is a full substring scan over the modest touch table (a leading wildcard can never use an index), and the facet composes with the same browse filters as the other kinds and is rejected with a `400` on the in-conversation `find` route. The axis is backed by a re-derivable `conversation_file_touches` table (cache migration `019`) backfilled once on the first sync after upgrade. Backend contract only — surfaced in a later frontend session.
17
+ - **Dashboard conversation reader — long Bash output collapses by default, and a key to search the conversation list from inside a reader.** A Bash tool card whose output runs long (more than 20 rendered lines) now opens collapsed with a `show N lines` hint so it no longer buries the next turn — short output stays open, and the `[`/`]` collapse-all and a per-card click still override either way. The new `f` key focuses the conversation-list search input even while a conversation is open, so you can look up another session without pressing `Esc` to leave the reader first (`/` stays reader-aware and opens the in-conversation find bar).
18
+ - **Dashboard conversation find bar — `⌘F` / `Ctrl+F`, regex + case-sensitive search, a focus trap, and live results.** The in-conversation find bar now opens with `⌘F` / `Ctrl+F` as well as `/` (the browser's native find bar is suppressed only inside the Conversations workspace, and only when no modal or text input is up — it still works on the dashboard panels), and two toggles beside the input — `.*` (regular expression) and `Aa` (case-sensitive), both remembered across reloads — drive a real server-side match over the full transcript corpus (prose, tool commands/results/stderr, and thinking, the same content the search facets cover); an invalid regex shows an announced "invalid regex" hint rather than failing silently. The prose underline now honors the case toggle (and is suppressed in regex mode, where the match count and jump still work fully). The bar traps keyboard focus while open (`Tab`/`Shift+Tab` cycle its controls, `Esc` exits to the transcript), and its results now live-refresh as the open conversation grows — keeping your place by jumping back to the same match across the refresh, and resetting to the first match only when the one you were on disappeared.
19
+ - **Dashboard conversation rail — sort the browse list, persist your filters + sort, and apply filters to search.** A new **Sort** control in the rail header orders the conversation list by Recent (default), Oldest, Cost (high→low), Messages (high→low), or Project (A→Z, with un-labeled sessions last); the chosen sort and the browse filters now persist across reloads instead of resetting every session. The Browse filters also apply to full-text **search** now — the same filter set is shared, so the Filters button stays enabled with a search needle active, the active-filter chips stay visible, and the chosen date/project/cost/rebuild axes narrow the search results (file-path and AI-title results included) rather than only the browse list. While a brand-new or `--no-sync` dashboard is still building its per-session rollup, the Cost/Project sorts briefly fall back to Recent order (and the rollup-only filter axes to date-only), each with a muted "while indexing" note, self-correcting after the first full sync. The `GET /api/conversations` route gains a `sort` param (`recent`/`oldest`/`cost`/`messages`/`project`, lenient on unknown values) and an additive `page.sort_degraded` flag.
20
+ - **Dashboard conversation search — search across sessions by AI-title or file path, surfaced in the rail.** The cross-session search chip row gains two structural facets after a subtle separator — **Title** (searches the AI-generated session titles) and **Files** (searches the write-class file paths each session edited or created) — joining the existing All / Prompts / Assistant / Tools / Thinking content kinds; both stay available even while the search index is still backfilling (neither rides the split index). A Title hit shows the matched title and opens that session at its first turn; a Files hit leads with the file path (with the session title secondary) and opens the session at that path's most-recent touch, so you can jump from "which session modified `bin/cctally`" straight into the conversation. The new hits carry `title` / `file` match badges, and both facets narrow to your active browse filters like every other kind. (This lights up the `kind=title` / `kind=files` backend facets shipped earlier in the conversation viewer's search route.)
21
+
22
+ ### Changed
23
+ - **Dashboard conversation viewer — backend performance and tech-debt hardening (no visible change).** The per-sync browse-rail rollup no longer runs a full transcript assembly for every touched session — the cache-rebuild count now comes from a dedicated lightweight query that shares the exact cache-failure rule with the reader — so the rollup maintenance that runs under the cache lock on every sync no longer does work proportional to sessions × full-assembly; the in-conversation find skips assembling a transcript entirely when a query matches nothing; cross-session search batches its badge and snippet lookups into a couple of bounded queries instead of issuing up to ~200 per-result round-trips; and a dead, always-empty `search_aux` column is removed via cache migration `016`. The rendered conversation output is byte-for-byte unchanged.
24
+
25
+ ### Fixed
26
+ - **Dashboard conversation reader — a cluster of small reading and accessibility refinements.** The header `Errors` focus-mode badge now counts error *turns* (matching the "Jump to" error chip and exactly what clicking the filter steps between) rather than the raw total of error events, so it can no longer read higher than the number of places the filter lands. A WebSearch results-count chip no longer shows green when the search errored (it takes a neutral/error style, with the `· error` marker alongside). The browse list's "Load more" button disables and shows a loading label while a page is fetching, matching the search results list. The Find control uses an inline icon (matching the other reader controls), the `· N hidden ·` focus-mode marker carries a screen-reader label naming the hidden-turn count, the load-full affordance disables when there's no addressable session, the diff result disclosure names the snippet's line count instead of a fixed string, and the ExitPlanMode outcome badge no longer mis-reads a free-text user response that merely mentions "approve"/"reject" as an approval or rejection.
27
+ - **Dashboard conversation viewer — a subagent whose result exceeds 16 KB no longer drops its nested subagent into a detached, unlinked card.** When a subagent returned more than ~16 KB of text, the `agentId:` trailer that links a nested (grandchild) subagent back to the thread that spawned it could fall past the point where the result is clipped for storage, so the grandchild rendered as a flat, top-level card disconnected from its parent. The link is now captured at ingest time from the full, pre-clip result (with the read-time parse kept as a fallback), so deeply-nested subagents render correctly regardless of result size; existing transcripts are re-derived once by a flag-only cache migration (`017_arm_nested_agent_reingest`), and because the cache is fully re-derivable there is nothing to do on upgrade.
28
+ - **Dashboard conversation viewer — full-payload reads now bound and flag truncation honestly.** A tool input large enough to exceed the payload cap is now actually clipped to that cap rather than served over-cap with only a truncation flag set (the bound is measured in the same serialization the response uses, so it holds for non-ASCII text too), and a tool result whose `stderr` stream is itself truncated now carries an explicit `stderr_truncated` flag — so a consumer of the read-full-payload route can tell a clipped stream from a complete one. No change to the rendered transcript.
29
+ - **Dashboard — the Recent Sessions table no longer spills past its panel card at narrow two-column widths (≈1100–1600px).** Its columns (Started · Dur · Model · Project · Cost) sum wider than the card at those widths, so the Cost column used to bleed past the card and the viewport edge; the table now scrolls horizontally inside the card instead, keeping every column full-width (no truncation) with the scrollbar shown only when the table can't fit. Wider layouts (≥~1300px, including the centered ≥1600px cap) are unchanged — the table fits with no scrollbar (#208).
30
+ - **Dashboard — the Daily heatmap no longer clips the digits of larger costs at narrow widths.** A full three-digit cost such as `$212.83` overflowed the day cell's clip box and lost its leading/trailing glyphs (rendering as "212.8"); each cell now sizes its cost text to the cell's width given the value's length, so the whole amount (cents included) always fits — wide cells keep the original 16px size and only cells that would otherwise overflow shrink, fluidly, at any width (#208).
31
+ - **`record-credit` — a week with an already-applied credit is now refused before the confirm prompt, not after it.** Re-running `record-credit` on a week that already has a fully-recorded credit is refused by default (exit 2, naming the recorded pre-credit value and pointing at `--force`). Previously, on an interactive terminal, the command printed the full preview and asked `Proceed? [y/N]` first, then refused only after you answered `y` — so you were prompted to confirm an action that was never going to apply. The refusal now fires before the prompt, so the "already recorded — pass `--force`" message appears immediately on both interactive and non-interactive reruns (#212).
32
+ - **Dashboard conversation find bar — `Esc` from a find-bar button no longer closes the whole reader, and a Files search result no longer prints its path twice.** Pressing `Esc` while focus was on one of the find bar's buttons (Close, the `.*`/`Aa` toggles, or the prev/next arrows) closed the find bar *and* tore the entire reader down back to the conversation list — only `Esc` from the text input behaved correctly. `Esc` is now handled at the bar level, so it closes only the find bar from any of its controls and restores keyboard focus to the transcript (instead of dropping it to the page body). Separately, a `Files` search hit in the conversation rail showed the file path both as its prominent primary line and again, redundantly, in the snippet row beneath; the duplicate snippet row is now suppressed for file hits (every other result kind keeps its snippet).
33
+
8
34
  ## [1.52.1] - 2026-06-20
9
35
 
10
36
  ### Fixed
@@ -185,8 +185,8 @@ _CONV_INSERT_SQL = (
185
185
  " timestamp_utc,entry_type,text,blocks_json,model,msg_id,"
186
186
  " req_id,cwd,git_branch,is_sidechain,source_tool_use_id,"
187
187
  " stop_reason,attribution_skill,attribution_plugin,"
188
- " search_tool,search_thinking,search_aux)"
189
- " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
188
+ " search_tool,search_thinking)"
189
+ " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
190
190
  )
191
191
 
192
192
  # #193: last non-null write wins (ai-title carries no timestamp; see spec S1). NO
@@ -206,19 +206,19 @@ def _conv_row_tuple(m, path_str):
206
206
  """Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
207
207
 
208
208
  The #177 enrichment fields (stop_reason / attribution_skill /
209
- attribution_plugin / search_tool / search_thinking / search_aux) are
210
- TAIL-APPENDED after source_tool_use_id — same order as the SQL column list —
211
- so both ingest paths (fused per-file walk + backfill_conversation_messages)
212
- carry them through this one tuple. #177 S6: ``search_aux`` is always written
213
- as ``''`` (documented-dead); the split ``search_tool``/``search_thinking``
214
- columns carry the non-prose index."""
209
+ attribution_plugin / search_tool / search_thinking) are TAIL-APPENDED after
210
+ source_tool_use_id — same order as the SQL column list — so both ingest paths
211
+ (fused per-file walk + backfill_conversation_messages) carry them through this
212
+ one tuple. #217 S1 / U7a: the documented-dead ``search_aux`` column is gone
213
+ from the live schema (dropped by migration 016); the split
214
+ ``search_tool``/``search_thinking`` columns carry the non-prose index."""
215
215
  return (
216
216
  m.session_id, m.uuid, m.parent_uuid, path_str, m.byte_offset,
217
217
  m.timestamp_utc, m.entry_type, m.text, m.blocks_json, m.model,
218
218
  m.msg_id, m.req_id, m.cwd, m.git_branch, m.is_sidechain,
219
219
  m.source_tool_use_id,
220
220
  m.stop_reason, m.attribution_skill, m.attribution_plugin,
221
- m.search_tool, m.search_thinking, m.search_aux,
221
+ m.search_tool, m.search_thinking,
222
222
  )
223
223
 
224
224
 
@@ -595,6 +595,9 @@ _TARGETED_DECLINE_FLAGS = (
595
595
  "conversation_promote_command_args_pending",
596
596
  "conversation_sessions_backfill_pending",
597
597
  "conversation_queued_prompt_reingest_pending", # migration 014
598
+ "conversation_reingest_nested_agent_pending", # migration 017
599
+ "conversation_title_fts_backfill_pending", # migration 018 (P1-2: HERE ONLY)
600
+ "conversation_reingest_file_touches_pending", # migration 019 (P1-2: HERE ONLY)
598
601
  )
599
602
 
600
603
 
@@ -731,7 +734,20 @@ def sync_cache(
731
734
  # while-busy user prompts persisted as queued_command attachments); the
732
735
  # same offset-0 walk re-derives them through the current parser, so drop
733
736
  # that flag here too — MISSING this site re-arms the flag on every
734
- # cache-sync --rebuild.
737
+ # cache-sync --rebuild. #217 S1 migration 017 sets the DISTINCT
738
+ # conversation_reingest_nested_agent_pending flag (to land the
739
+ # ingest-time structured agent_id stamp on >16 KB nested-subagent
740
+ # grandchildren); the same offset-0 walk re-derives it through the
741
+ # current parser, so drop that flag here as well. #217 S2 migrations
742
+ # 018/019 set conversation_title_fts_backfill_pending (title FTS) and
743
+ # conversation_reingest_file_touches_pending (+ its
744
+ # conversation_file_touches_cursor); the offset-0 walk re-derives both
745
+ # the title FTS and the file-touch axis, so drop them too (#219 S2.3).
746
+ # NOTE: unlike the flags above, dropping the 018/019 keys here is
747
+ # COSMETIC — their consumers run on the just-wiped (empty) tables and
748
+ # self-clear before the offset-0 walk repopulates, so leaving them
749
+ # caused no redundant expensive pass and no re-arming. We add them for
750
+ # consistency with the documented convention only.
735
751
  conn.execute(
736
752
  "DELETE FROM cache_meta WHERE key IN "
737
753
  "('conversation_reingest_pending',"
@@ -739,6 +755,10 @@ def sync_cache(
739
755
  " 'conversation_reingest_enrichment_pending',"
740
756
  " 'conversation_media_reingest_pending',"
741
757
  " 'conversation_queued_prompt_reingest_pending',"
758
+ " 'conversation_reingest_nested_agent_pending',"
759
+ " 'conversation_title_fts_backfill_pending',"
760
+ " 'conversation_reingest_file_touches_pending',"
761
+ " 'conversation_file_touches_cursor',"
742
762
  " 'conversation_reingest_cursor',"
743
763
  " 'conversation_reingest_cursor_gen')")
744
764
  # #177 S6: a rebuild repopulates search_tool/search_thinking via the
@@ -885,9 +905,14 @@ def sync_cache(
885
905
  # for a distinct flag. Migration 014 uses ANOTHER distinct flag
886
906
  # ``conversation_queued_prompt_reingest_pending`` to land queued-while-
887
907
  # busy user prompts (queued_command attachments the parser now promotes
888
- # to HUMAN); same offset-0 re-parse, same distinct-flag reason. We
889
- # trigger the SAME clear + offset-0 backfill on ANY of these flags and
890
- # clear them ALL atomically here under the held flock.
908
+ # to HUMAN); same offset-0 re-parse, same distinct-flag reason. #217 S1
909
+ # migration 017 uses ANOTHER distinct flag
910
+ # ``conversation_reingest_nested_agent_pending`` to land the ingest-time
911
+ # structured agent_id stamp on >16 KB nested-subagent grandchildren
912
+ # (whose agentId: trailer was clipped past the 16 KB cap); same offset-0
913
+ # re-parse, same distinct-flag reason. We trigger the SAME clear +
914
+ # offset-0 backfill on ANY of these flags and clear them ALL atomically
915
+ # here under the held flock.
891
916
  try:
892
917
  _reingest = conn.execute(
893
918
  "SELECT 1 FROM cache_meta WHERE key IN "
@@ -895,7 +920,8 @@ def sync_cache(
895
920
  " 'conversation_source_tool_use_reingest_pending',"
896
921
  " 'conversation_reingest_enrichment_pending',"
897
922
  " 'conversation_media_reingest_pending',"
898
- " 'conversation_queued_prompt_reingest_pending')"
923
+ " 'conversation_queued_prompt_reingest_pending',"
924
+ " 'conversation_reingest_nested_agent_pending')"
899
925
  ).fetchone() is not None
900
926
  except sqlite3.OperationalError:
901
927
  _reingest = False
@@ -931,6 +957,21 @@ def sync_cache(
931
957
  # HUMAN(text=args); the split-FTS UPDATE triggers re-index the args.
932
958
  _consume_promote_command_args(conn)
933
959
 
960
+ # #217 S2 / E7: consume the migration-018 title-FTS backfill under the
961
+ # SAME held flock. An FTS5 'rebuild' re-derives the external-content
962
+ # title index from conversation_ai_titles (P1-7) — idempotent under
963
+ # the 012-then-018 both-pending ordering, and a cheap clear-only
964
+ # no-op on a no-FTS5 build (P1-6). Touches ONLY the title index, never
965
+ # conversation_messages (P1-2).
966
+ _consume_title_fts(conn)
967
+
968
+ # #217 S2 / I-3: consume the migration-019 file-touches backfill under
969
+ # the SAME held flock. Derives conversation_file_touches from existing
970
+ # blocks_json history (cursor-resumable; idempotent via INSERT OR
971
+ # IGNORE). Touches ONLY conversation_file_touches, never
972
+ # conversation_messages (P1-2).
973
+ _consume_file_touches(conn)
974
+
934
975
  if targeted:
935
976
  paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
936
977
  else:
@@ -1293,6 +1334,16 @@ def sync_cache(
1293
1334
  # (parallel to the cost path's lifecycle).
1294
1335
  if conv_rows:
1295
1336
  conn.executemany(_CONV_INSERT_SQL, conv_rows)
1337
+ # #217 S2 / I-3: derive this tick's file touches, scoped to the
1338
+ # just-ingested rows' PHYSICAL keys (cr[3]=source_path,
1339
+ # cr[4]=byte_offset per _conv_row_tuple). Cheap (proportional to
1340
+ # new bytes); decoupled from the INSERT OR IGNORE rowcount —
1341
+ # _fill_file_touches reads conversation_messages by physical key,
1342
+ # so an already-present (rowcount-0) row still gets its touches.
1343
+ # Lands in the SAME per-file write transaction as the message
1344
+ # rows, so they commit atomically.
1345
+ _fill_file_touches(
1346
+ conn, scope=[(cr[3], cr[4]) for cr in conv_rows])
1296
1347
  # #193: ai-title upserts for this file, in file order (last wins).
1297
1348
  # Committed atomically with the session_files cursor below.
1298
1349
  if ai_rows:
@@ -1486,6 +1537,7 @@ _REINGEST_FLAG_KEYS = (
1486
1537
  "conversation_reingest_enrichment_pending",
1487
1538
  "conversation_media_reingest_pending", # #177 S4 (migration 009)
1488
1539
  "conversation_queued_prompt_reingest_pending", # migration 014
1540
+ "conversation_reingest_nested_agent_pending", # #217 S1 (migration 017)
1489
1541
  )
1490
1542
 
1491
1543
 
@@ -1550,10 +1602,25 @@ def _resumable_reingest_conversation_messages(conn):
1550
1602
  conn.commit()
1551
1603
  continue
1552
1604
  try:
1605
+ # #217 S2 / I-3 (P1-4): conversation_file_touches is derived state
1606
+ # keyed by conversation_messages.id, and this per-source reingest
1607
+ # DELETEs + re-inserts the file's message rows (bumping autoincrement
1608
+ # ids). Delete the file's touches BEFORE the message delete (resolving
1609
+ # the ids while they still exist), then refill from the reinserted rows
1610
+ # AFTER — all in this one atomic transaction, so a crash leaves no stale
1611
+ # or duplicate anchors.
1612
+ conn.execute(
1613
+ "DELETE FROM conversation_file_touches WHERE message_id IN "
1614
+ "(SELECT id FROM conversation_messages WHERE source_path=?)",
1615
+ (path_str,))
1553
1616
  conn.execute("DELETE FROM conversation_messages WHERE source_path=?",
1554
1617
  (path_str,))
1555
1618
  if rows:
1556
1619
  conn.executemany(_CONV_INSERT_SQL, rows)
1620
+ # Refill scoped to this file's just-reinserted physical keys
1621
+ # (col 3=source_path, col 4=byte_offset per _conv_row_tuple).
1622
+ _fill_file_touches(
1623
+ conn, scope=[(r[3], r[4]) for r in rows])
1557
1624
  _set_cache_meta(conn, "conversation_reingest_cursor", path_str)
1558
1625
  conn.commit()
1559
1626
  except BaseException:
@@ -1568,6 +1635,7 @@ def _resumable_reingest_conversation_messages(conn):
1568
1635
  " 'conversation_reingest_enrichment_pending',"
1569
1636
  " 'conversation_media_reingest_pending',"
1570
1637
  " 'conversation_queued_prompt_reingest_pending',"
1638
+ " 'conversation_reingest_nested_agent_pending',"
1571
1639
  " 'conversation_reingest_cursor',"
1572
1640
  " 'conversation_reingest_cursor_gen')")
1573
1641
  conn.commit()
@@ -1669,9 +1737,10 @@ def _fill_conversation_sessions_filter_columns(conn, session_ids):
1669
1737
  _project_label / _session_cost_map the rail's per-page Python path used), so
1670
1738
  a filtered/displayed value equals what the live rail produced. cost is
1671
1739
  rounded to 6dp to match list_conversations' per-row rounding.
1672
- cache_rebuild_count is a per-session assemble via the query kernel's
1673
- single-source-of-truth helper (whole-session propertyrecompute, never
1674
- increment).
1740
+ cache_rebuild_count is a per-session lightweight rebuild-count via the
1741
+ query kernel's single-source-of-truth helper (no full assembly U1; the
1742
+ rule is shared with the reader path) — a whole-session property
1743
+ (recompute, never increment).
1675
1744
 
1676
1745
  No-op when the columns are absent (a pre-migration-015 cache.db being
1677
1746
  re-derived before its 015 ALTER lands), so an early/partial sync never
@@ -1758,6 +1827,153 @@ def _consume_search_split(conn) -> None:
1758
1827
  conn.commit()
1759
1828
 
1760
1829
 
1830
+ def _consume_title_fts(conn) -> None:
1831
+ """#217 S2 / E7: flock-held consumer for ``conversation_title_fts_backfill_pending``
1832
+ (set by cache migration 018). Populates the external-content title FTS over
1833
+ ``conversation_ai_titles`` from existing history.
1834
+
1835
+ Uses the FTS5 ``'rebuild'`` command (the established consumer idiom —
1836
+ ``_apply_cache_schema``'s recovery rebuild + ``_consume_search_split`` —
1837
+ NOT blind row inserts, P1-7): ``'rebuild'`` re-derives the whole index from
1838
+ the content table and is IDEMPOTENT even if migration 012's
1839
+ ``ai_titles_backfill_pending`` ran first and already populated the index via
1840
+ the conv_title_fts_ai trigger (the 012-then-018 both-pending upgrade
1841
+ ordering) — re-running yields the same rows, no duplicates or conflict.
1842
+
1843
+ FTS5-unavailable (``fts5_unavailable`` set, P1-6): there is no usable vtable
1844
+ to rebuild and a ``'rebuild'`` would error on the absent fts5 module, so just
1845
+ clear the flag — ``kind=title`` degrades to a LIKE scan over
1846
+ conversation_ai_titles. Touches ONLY the title index (never
1847
+ conversation_messages — P1-2: this is NOT a message reingest); the flag is
1848
+ dropped LAST so a crash mid-rebuild re-runs cleanly on the next sync. A fresh
1849
+ install never sets the flag, so this is a cheap no-op there."""
1850
+ if conn.execute(
1851
+ "SELECT 1 FROM cache_meta "
1852
+ "WHERE key='conversation_title_fts_backfill_pending'"
1853
+ ).fetchone() is None:
1854
+ return
1855
+ fts_off = conn.execute(
1856
+ "SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
1857
+ ).fetchone() is not None
1858
+ if not fts_off:
1859
+ # The title FTS may not exist YET even on an FTS5-capable build: a pre-S6
1860
+ # install whose conversation_fts is still the legacy (text) shape makes
1861
+ # _apply_cache_schema early-return at its legacy_present guard BEFORE it
1862
+ # creates conversation_title_fts. When migrations 010 + 018 are both
1863
+ # pending in that open, _consume_search_split swaps only the MESSAGE FTS
1864
+ # (never the title FTS), so a blind 'rebuild' here would raise
1865
+ # "no such table: conversation_title_fts" — fts5_unavailable is NOT set
1866
+ # (FTS5 IS available), so the fts_off guard above does not protect it.
1867
+ # Swallow that OperationalError and RETURN before the flag-clear so the
1868
+ # flag survives: the NEXT open (message FTS now split → legacy_present
1869
+ # False → _apply_cache_schema creates the title FTS) re-runs this consumer
1870
+ # and completes the backfill. Match the message-FTS path's resilience.
1871
+ try:
1872
+ conn.execute(
1873
+ "INSERT INTO conversation_title_fts(conversation_title_fts) "
1874
+ "VALUES('rebuild')")
1875
+ except sqlite3.OperationalError:
1876
+ return # title FTS not yet created (legacy-shape pre-swap); leave
1877
+ # the flag set, retry on the next open
1878
+ conn.execute(
1879
+ "DELETE FROM cache_meta WHERE key='conversation_title_fts_backfill_pending'")
1880
+ conn.commit()
1881
+
1882
+
1883
+ _FILE_TOUCH_INSERT_SQL = (
1884
+ "INSERT OR IGNORE INTO conversation_file_touches"
1885
+ "(message_id, session_id, uuid, file_path, tool) VALUES(?,?,?,?,?)")
1886
+
1887
+
1888
+ def _fill_file_touches(conn, scope=None) -> None:
1889
+ """#217 S2 / I-3: derive ``conversation_file_touches`` rows from
1890
+ ``conversation_messages.blocks_json`` for the in-scope message rows.
1891
+
1892
+ ``scope`` is an iterable of ``(source_path, byte_offset)`` physical keys, or
1893
+ ``None`` for ALL rows (the backfill). We read FROM ``conversation_messages``
1894
+ (the source of truth) and resolve ``message_id`` from the row's own ``id``.
1895
+
1896
+ P1-3 (load-bearing): scope by the PHYSICAL key ``(source_path, byte_offset)``,
1897
+ NEVER by ``uuid``. ``conversation_messages.uuid`` is NOT unique (only
1898
+ ``(source_path, byte_offset)`` is; the uuid index is ``(session_id, uuid)``),
1899
+ and resume/replay rows legitimately share a ``(session_id, uuid)`` — a
1900
+ ``WHERE uuid=?`` fill would touch unrelated physical rows.
1901
+
1902
+ Decoupled from the message-insert rowcount ("dedup must not gate side
1903
+ effects"): a no-op INSERT OR IGNORE of an already-present message row (rowcount
1904
+ 0) still has its touches derived here, because we read the row by physical key
1905
+ rather than from the insert's lastrowid/rowcount.
1906
+
1907
+ Cheap at steady state: scoped to the rows ingested this tick (proportional to
1908
+ new bytes), never re-parsing the whole session per tick. ``INSERT OR IGNORE``
1909
+ on ``UNIQUE(message_id, file_path, tool)`` makes it idempotent, and a row's
1910
+ ``blocks_json`` is immutable, so accumulate-via-IGNORE needs no per-tick DELETE.
1911
+ The caller owns the commit (this helper never commits)."""
1912
+ def _emit(rows):
1913
+ for mid, sid, uuid_, bj in rows:
1914
+ if not sid:
1915
+ continue # a touch row's session_id is NOT NULL; skip null-session rows
1916
+ try:
1917
+ blocks = json.loads(bj) if bj else []
1918
+ except (TypeError, ValueError):
1919
+ blocks = []
1920
+ for fp, tool in _lib_conversation._derive_file_touches(blocks):
1921
+ conn.execute(_FILE_TOUCH_INSERT_SQL, (mid, sid, uuid_, fp, tool))
1922
+
1923
+ if scope is None:
1924
+ # Backfill: cursor-resumable 500-row batches keyed on the message rowid.
1925
+ # Resume from the stored cursor so an interrupt skips already-derived
1926
+ # batches (the fill is also idempotent via INSERT OR IGNORE, so a restart
1927
+ # from 0 would be correct but redundant).
1928
+ row = conn.execute(
1929
+ "SELECT value FROM cache_meta "
1930
+ "WHERE key='conversation_file_touches_cursor'").fetchone()
1931
+ last_id = int(row[0]) if row and row[0] is not None else 0
1932
+ while True:
1933
+ batch = conn.execute(
1934
+ "SELECT id, session_id, uuid, blocks_json FROM conversation_messages "
1935
+ "WHERE id > ? ORDER BY id LIMIT 500",
1936
+ (last_id,)).fetchall()
1937
+ if not batch:
1938
+ break
1939
+ _emit(batch)
1940
+ last_id = batch[-1][0]
1941
+ _cctally_db_sib._set_cache_meta(
1942
+ conn, "conversation_file_touches_cursor", str(last_id))
1943
+ conn.commit()
1944
+ return
1945
+ for sp, off in scope:
1946
+ rows = conn.execute(
1947
+ "SELECT id, session_id, uuid, blocks_json FROM conversation_messages "
1948
+ "WHERE source_path=? AND byte_offset=?", (sp, off)).fetchall()
1949
+ _emit(rows)
1950
+
1951
+
1952
+ def _consume_file_touches(conn) -> None:
1953
+ """#217 S2 / I-3: flock-held consumer for
1954
+ ``conversation_reingest_file_touches_pending`` (set by cache migration 019).
1955
+ Derives ``conversation_file_touches`` from ALL existing ``blocks_json`` history
1956
+ via ``_fill_file_touches(conn, scope=None)`` (cursor-resumable, 500-row
1957
+ batches), then clears the flag + cursor.
1958
+
1959
+ Touches ONLY ``conversation_file_touches`` (never ``conversation_messages`` —
1960
+ P1-2: this is NOT a message reingest). The fill is idempotent (INSERT OR
1961
+ IGNORE on the UNIQUE key), so an interrupted backfill resumes cleanly on the
1962
+ next locked sync. A fresh install never sets the flag, so this is a cheap
1963
+ no-op there."""
1964
+ if conn.execute(
1965
+ "SELECT 1 FROM cache_meta "
1966
+ "WHERE key='conversation_reingest_file_touches_pending'"
1967
+ ).fetchone() is None:
1968
+ return
1969
+ _fill_file_touches(conn, scope=None)
1970
+ conn.execute(
1971
+ "DELETE FROM cache_meta WHERE key IN "
1972
+ "('conversation_reingest_file_touches_pending',"
1973
+ " 'conversation_file_touches_cursor')")
1974
+ conn.commit()
1975
+
1976
+
1761
1977
  def _consume_promote_command_args(conn) -> None:
1762
1978
  """#188 bug 4: flock-held consumer for ``conversation_promote_command_args_pending``
1763
1979
  (set by cache migration 011). Cursor-resumable walk of
@@ -147,7 +147,7 @@ What stays in bin/cctally:
147
147
  c.ORIGINAL_SYS_ARGV = list(sys.argv)``.
148
148
  - ``eprint``, ``now_utc_iso``, ``parse_iso_datetime``, ``open_db``,
149
149
  ``load_config``, ``save_config``, ``_now_utc``, ``_command_as_of``,
150
- ``format_display_dt``, ``resolve_display_tz``,
150
+ ``format_display_dt``,
151
151
  ``normalize_display_tz_value``, ``_render_migration_error_banner``,
152
152
  ``_aggregate_daily``, ``_aggregate_monthly``, ``_aggregate_weekly``,
153
153
  ``_calculate_entry_cost``, ``_canonical_5h_window_key``,
@@ -305,7 +305,6 @@ from _cctally_core import (
305
305
  )
306
306
  from _lib_display_tz import (
307
307
  format_display_dt,
308
- resolve_display_tz,
309
308
  normalize_display_tz_value,
310
309
  _compute_display_block,
311
310
  )
@@ -5284,10 +5283,22 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
5284
5283
  return vals[0] if vals else default
5285
5284
 
5286
5285
 
5287
- # #177 S6: valid kind facets for the conversation search / find routes. Kept in
5288
- # lockstep with ``_lib_conversation_query._SEARCH_KINDS`` (the kernel re-raises
5289
- # ValueError on an unknown kind; the handlers reject with a 400 before the call).
5290
- _CONV_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
5286
+ # #177 S6 / #217 S2: valid kind facets for the conversation routes. Kept in
5287
+ # lockstep with the kernel (``_lib_conversation_query._SEARCH_KINDS`` /
5288
+ # ``_FIND_KINDS``; the kernel re-raises ValueError on an unknown kind, and the
5289
+ # handlers reject with a 400 BEFORE the call — ``_run_conversation_query``
5290
+ # collapses every kernel exception to a 500, so a per-route 4xx must be decided
5291
+ # in the handler, not via try/except around the kernel).
5292
+ #
5293
+ # P1-1 (load-bearing kind-validation SPLIT): the cross-session search route
5294
+ # accepts ``title`` and ``files``; the in-conversation ``/find`` route does NOT —
5295
+ # its kernel (``find_in_conversation``) indexes ``_FIND_KIND_COLUMNS[kind]``,
5296
+ # which has no ``title``/``files`` entry, so accepting them there would be a 500
5297
+ # KeyError. Two distinct tuples keep ``/find?kind=title`` and ``/find?kind=files``
5298
+ # a clean 400.
5299
+ _CONV_SEARCH_KINDS = (
5300
+ "all", "prompts", "assistant", "tools", "thinking", "title", "files")
5301
+ _CONV_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
5291
5302
 
5292
5303
 
5293
5304
  class _BadConversationFilter(Exception):
@@ -7451,15 +7462,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7451
7462
  """Lazy-load the pure conversation query kernel (Plan 2, §3)."""
7452
7463
  return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
7453
7464
 
7454
- def _parse_search_kind(self, q):
7455
- """Read + validate the ``kind`` facet shared by the conversation search
7456
- and find routes (#177 S6). Returns the kind on success, or ``None`` after
7457
- having ALREADY sent a 400 — callers just ``return`` on ``None``. Kept in
7458
- lockstep with the kernel's ``_SEARCH_KINDS`` via ``_CONV_SEARCH_KINDS``
7459
- (the kernel module is resolved lazily per-request, so the handler keeps a
7460
- literal tuple rather than reaching across that import edge for a nit)."""
7465
+ def _parse_search_kind(self, q, valid=_CONV_SEARCH_KINDS):
7466
+ """Read + validate the ``kind`` facet for a conversation route (#177 S6 /
7467
+ #217 S2). Returns the kind on success, or ``None`` after having ALREADY
7468
+ sent a 400 — callers just ``return`` on ``None``.
7469
+
7470
+ ``valid`` is the per-route kind set (P1-1 split): the cross-session search
7471
+ route passes ``_CONV_SEARCH_KINDS`` (includes ``title``), the
7472
+ in-conversation ``/find`` route passes ``_CONV_FIND_KINDS`` (excludes
7473
+ ``title``/``files``), so ``/find?kind=title`` is a 400 here — never a 500
7474
+ KeyError downstream in ``find_in_conversation``. Kept in lockstep with the
7475
+ kernel's ``_SEARCH_KINDS`` / ``_FIND_KINDS`` (the kernel module is
7476
+ resolved lazily per-request, so the handler keeps literal tuples rather
7477
+ than reaching across that import edge for a nit)."""
7461
7478
  kind = _qs_str(q, "kind", "all")
7462
- if kind not in _CONV_SEARCH_KINDS:
7479
+ if kind not in valid:
7463
7480
  self._respond_json(400, {"error": f"unknown kind: {kind}"})
7464
7481
  return None
7465
7482
  return kind
@@ -7615,15 +7632,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7615
7632
  """``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
7616
7633
 
7617
7634
  The id is percent-decoded so clients that encode reserved chars
7618
- round-trip. Unknown id → 404. ``after``/``limit`` page the items.
7635
+ round-trip. Unknown id → 404. ``after``/``before``/``tail``/``limit``
7636
+ page the items; ``after``/``before``/``tail`` are mutually exclusive
7637
+ (>1 supplied → 400). ``tail=1`` opens at the bottom; ``before=<id>``
7638
+ pages backward (#217 S2 / U4).
7619
7639
  """
7620
7640
  if not self._require_transcripts_allowed():
7621
7641
  return
7622
7642
  import urllib.parse as _u
7623
7643
  # ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
7624
- # so the cursor params (?after=/?limit=) live ONLY on the raw ``self.path``.
7625
- # Sibling handlers read ``self.path`` directly — the detail route must too,
7626
- # or every request re-serves the head and pagination is dead.
7644
+ # so the cursor params (?after=/?before=/?tail=/?limit=) live ONLY on the
7645
+ # raw ``self.path``. Sibling handlers read ``self.path`` directly — the
7646
+ # detail route must too, or every request re-serves the head and
7647
+ # pagination is dead.
7627
7648
  query_str = self.path.partition("?")[2]
7628
7649
  session_id = _u.unquote(path[len("/api/conversation/"):])
7629
7650
  if not session_id:
@@ -7631,10 +7652,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7631
7652
  return
7632
7653
  q = _u.parse_qs(query_str)
7633
7654
  after = _qs_str(q, "after", None)
7655
+ before = _qs_str(q, "before", None)
7656
+ tail = _qs_str(q, "tail", None) in ("1", "true", "yes")
7634
7657
  limit = _qs_int(q, "limit", 500)
7658
+ # Mutual-exclusion 400 (#217 S2 / U4). The kernel ALSO raises ValueError
7659
+ # on >1 cursor as its own invariant, but ``_run_conversation_query``
7660
+ # collapses every kernel exception to a 500, so the 400 must be decided
7661
+ # HERE, before the kernel call — this explicit pre-call check is the
7662
+ # authoritative backstop for the handler path.
7663
+ if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
7664
+ self.send_error(400, "after/before/tail are mutually exclusive")
7665
+ return
7635
7666
  ok, body = self._run_conversation_query(
7636
7667
  lambda conn: self._conversation_query().get_conversation(
7637
- conn, session_id, after=after, limit=limit),
7668
+ conn, session_id, after=after, before=before, tail=tail,
7669
+ limit=limit),
7638
7670
  "/api/conversation")
7639
7671
  if not ok:
7640
7672
  return
@@ -7755,6 +7787,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7755
7787
  FTS/LIKE search (spec §3.3). Matched BEFORE the ``<id>`` reader in
7756
7788
  ``do_GET``. ``kind`` (#177 S6) is validated to ``_CONV_SEARCH_KINDS``
7757
7789
  (else 400) before the kernel call.
7790
+
7791
+ #217 S2 / Filtered-search: the browse filters (date/project/cost/rebuild)
7792
+ are parsed by the SAME ``_parse_conversation_filters`` the browse rail uses
7793
+ (malformed → 400 already sent) and threaded into the kernel, applied as a
7794
+ session-scope restriction across every kind. The 400s (bad kind, bad
7795
+ filter) are decided HERE, before the kernel call — ``_run_conversation_query``
7796
+ collapses kernel exceptions to a 500.
7758
7797
  """
7759
7798
  if not self._require_transcripts_allowed():
7760
7799
  return
@@ -7766,9 +7805,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7766
7805
  kind = self._parse_search_kind(q)
7767
7806
  if kind is None:
7768
7807
  return
7808
+ filters = self._parse_conversation_filters(q)
7809
+ if filters is None:
7810
+ return # a 400 has already been sent
7769
7811
  ok, body = self._run_conversation_query(
7770
7812
  lambda conn: self._conversation_query().search_conversations(
7771
- conn, query, limit=limit, offset=offset, kind=kind),
7813
+ conn, query, limit=limit, offset=offset, kind=kind, **filters),
7772
7814
  "/api/conversation/search")
7773
7815
  if not ok:
7774
7816
  return
@@ -7840,9 +7882,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7840
7882
  find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
7841
7883
  privacy gate as the sibling routes; unknown id → 404; an invalid ``kind``
7842
7884
  → 400. Matched BEFORE the ``<id>`` reader catch-all in ``do_GET``.
7885
+
7886
+ P1-1: validates against ``_CONV_FIND_KINDS`` (NOT the search set), so the
7887
+ cross-session-only ``kind=title``/``files`` return 400 here, never a 500.
7888
+
7889
+ #217 S4 / I-1.2: ``regex``/``case`` are truthy params. An invalid regex
7890
+ is PRE-COMPILED here, BEFORE dispatching to the kernel — exactly as the
7891
+ detail route pre-validates ``after/before/tail`` — because
7892
+ ``_run_conversation_query`` collapses every kernel exception to a 500, so
7893
+ a ``re.error`` from the kernel's ``re.compile`` would otherwise leak as a
7894
+ 500 instead of the actionable 400 the client maps to "invalid regex".
7843
7895
  """
7844
7896
  if not self._require_transcripts_allowed():
7845
7897
  return
7898
+ import re as _re
7846
7899
  import urllib.parse as _u
7847
7900
  session_id = _u.unquote(path[len("/api/conversation/"):-len("/find")])
7848
7901
  if not session_id:
@@ -7850,12 +7903,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7850
7903
  return
7851
7904
  q = _u.parse_qs(self.path.partition("?")[2])
7852
7905
  query = _qs_str(q, "q", "")
7853
- kind = self._parse_search_kind(q)
7906
+ kind = self._parse_search_kind(q, valid=_CONV_FIND_KINDS)
7854
7907
  if kind is None:
7855
7908
  return
7909
+ regex = _qs_str(q, "regex", None) in ("1", "true", "yes")
7910
+ case = _qs_str(q, "case", None) in ("1", "true", "yes")
7911
+ # Pre-validate the regex HERE (Codex P1): the kernel compiles the same
7912
+ # pattern, but its ``re.error`` would be swallowed into the generic 500
7913
+ # envelope below. Compiling first turns a bad pattern into a clean 400.
7914
+ if regex:
7915
+ try:
7916
+ _re.compile(query, 0 if case else _re.IGNORECASE)
7917
+ except _re.error as e:
7918
+ self._respond_json(400, {"error": f"invalid regex: {e}"})
7919
+ return
7856
7920
  ok, body = self._run_conversation_query(
7857
7921
  lambda conn: self._conversation_query().find_in_conversation(
7858
- conn, session_id, query, kind=kind),
7922
+ conn, session_id, query, kind=kind, regex=regex, case=case),
7859
7923
  "/api/conversation/find")
7860
7924
  if not ok:
7861
7925
  return
@@ -8472,11 +8536,12 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8472
8536
  _c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
8473
8537
  _c_boot._UPDATE_WORKER = UpdateWorker()
8474
8538
 
8475
- # Resolve display tz (--tz overrides config.display.tz). The dashboard's
8476
- # envelope-emitted display block (Tasks 11-12) reads this; for now,
8477
- # stash on args so downstream selectors can pick it up uniformly.
8539
+ # Load config for the bind-host + expose-transcripts resolution below. (#217
8540
+ # S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
8541
+ # write nothing in the dashboard ever read it back; the envelope display
8542
+ # block it was speculatively staged for was never wired. Each report
8543
+ # subcommand resolves + reads its own ``args._resolved_tz`` independently.)
8478
8544
  config = load_config()
8479
- args._resolved_tz = resolve_display_tz(args, config)
8480
8545
 
8481
8546
  # Resolve bind host: --host flag > config.dashboard.bind > argparse default.
8482
8547
  # `--host` defaults to None (Task 2) so we can distinguish "user explicitly