cctally 1.72.0 → 1.74.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
@@ -43,7 +43,7 @@ import socket
43
43
  import sqlite3
44
44
  import sys
45
45
 
46
- from _cctally_cache import sync_cache
46
+ from _cctally_cache import sync_cache, sync_codex_cache, _codex_provider_roots
47
47
 
48
48
  # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
49
49
  # below, so moved here with the events handler (spec §4.1 / §6).
@@ -113,10 +113,309 @@ def _cached_file_sigs(conn, paths):
113
113
  return out
114
114
 
115
115
 
116
+ def _codex_cached_file_sigs(conn, paths):
117
+ """{path: size_bytes} from ``codex_session_files`` for the given paths — the
118
+ Codex analogue of ``_cached_file_sigs`` (spec §5.2). Size-only, matching the
119
+ watch kernel's size-only signature and ``sync_codex_cache``'s size-only
120
+ delta. Paths with no row are absent → treated as changed. Baselines the
121
+ Codex live-tail watch against the cache's own committed cursor so growth
122
+ during an ingest is re-detected next cycle (the per-path committed-cursor
123
+ invariant — the S6 physical mutation sequence is at most an extra
124
+ certificate, never this cursor's replacement)."""
125
+ out = {}
126
+ if not paths:
127
+ return out
128
+ placeholders = ",".join("?" for _ in paths)
129
+ try:
130
+ rows = conn.execute(
131
+ f"SELECT path, size_bytes FROM codex_session_files "
132
+ f"WHERE path IN ({placeholders})", list(paths)).fetchall()
133
+ except sqlite3.OperationalError:
134
+ return out
135
+ for p, size in rows:
136
+ out[p] = size
137
+ return out
138
+
139
+
140
+ def _codex_all_committed_sizes(conn):
141
+ """{path: size_bytes} for EVERY tracked Codex file — fed to the frontier as
142
+ both the ``known_paths`` diff set and the ``committed_sizes`` growth
143
+ baseline. A plain SELECT (no lock), so it never widens the SSE lock scope."""
144
+ try:
145
+ rows = conn.execute(
146
+ "SELECT path, size_bytes FROM codex_session_files").fetchall()
147
+ except sqlite3.OperationalError:
148
+ return {}
149
+ return {p: size for (p, size) in rows}
150
+
151
+
152
+ def _codex_classified_paths(conn, paths):
153
+ """Of ``paths``, those whose targeted ingest now carries a conversation key
154
+ (``codex_session_files.last_conversation_key`` non-null) — i.e. classified
155
+ (child or non-child). The driver reaps these from the frontier's pending set
156
+ (§5.4): a child has already widened the file set; a non-child needs no
157
+ further attention. Paths still unclassified (incomplete session_meta / a
158
+ dirty first ingest) stay pending and are retried."""
159
+ if not paths:
160
+ return set()
161
+ placeholders = ",".join("?" for _ in paths)
162
+ try:
163
+ rows = conn.execute(
164
+ f"SELECT path FROM codex_session_files "
165
+ f"WHERE path IN ({placeholders}) AND last_conversation_key IS NOT NULL",
166
+ list(paths)).fetchall()
167
+ except sqlite3.OperationalError:
168
+ return set()
169
+ return {r[0] for r in rows}
170
+
171
+
172
+ def _codex_walk_root_for_conversation(conn, conversation_key):
173
+ """The configured ``walk_root`` of the conversation's OWN provider root (the
174
+ frontier's scope — §5.4). Resolves the conversation's ``source_root_key`` via
175
+ its thread facts, then matches it to a currently-configured provider root.
176
+ ``None`` when the conversation has no thread row or its root is no longer
177
+ configured (the frontier is then skipped — DB re-resolve alone still runs)."""
178
+ cq = _conversation_query_impl_codex()
179
+ thread = cq._thread_facts(conn, conversation_key)
180
+ if thread is None:
181
+ return None
182
+ source_root_key = thread[3]
183
+ for root in _codex_provider_roots():
184
+ if root.source_root_key == source_root_key:
185
+ return str(root.walk_root)
186
+ return None
187
+
188
+
189
+ def _conversation_query_impl_codex():
190
+ """Lazy-load the Codex conversation query kernel (source-path resolver +
191
+ existence probe + thread facts for the live-tail file set)."""
192
+ return sys.modules["cctally"]._load_sibling("_lib_codex_conversation_query")
193
+
194
+
195
+ def _conversation_dispatch_impl():
196
+ """Lazy-load the provider-neutral dispatch kernel (the SSE preflight)."""
197
+ return sys.modules["cctally"]._load_sibling("_lib_conversation_dispatch")
198
+
199
+
116
200
  def _conversation_query_impl():
117
201
  """Lazy-load the pure conversation query kernel (Plan 2, §3)."""
118
202
  return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
119
203
 
204
+
205
+ # ── #294 S7 — dual-form conversation routes (spec §2) ─────────────────────────
206
+ # Entity routes qualify lexically (an id beginning ``v1.`` opts into the neutral
207
+ # envelope contract); the three collection routes qualify by an explicit strict
208
+ # ``?source={claude,codex}``. Absence of qualification keeps today's Claude path
209
+ # byte-identical (the legacy code never touches the resolver). This is C1/C2.
210
+
211
+ # Every conversation param recognized across the three collection routes (§2.2).
212
+ # A qualified request rejects (400) any RECOGNIZED param not in that route's
213
+ # accepted whitelist; a GENUINELY unknown param (not in this set) is ignored,
214
+ # matching legacy leniency. Entity-only params (after/before/tail/scope/regex/…)
215
+ # are deliberately absent — they are meaningless on a collection route, so they
216
+ # fall to "genuinely unknown → ignored".
217
+ _RECOGNIZED_CONVERSATION_PARAMS = (
218
+ "source", "project_key", "model", "limit", "cursor", "q", "kind",
219
+ "sort", "offset", "date_from", "date_to", "projects",
220
+ "cost_min", "cost_max", "rebuild_min", "models",
221
+ )
222
+ _QUALIFIED_BROWSE_ACCEPTED = ("source", "project_key", "model", "limit", "cursor")
223
+ _QUALIFIED_SEARCH_ACCEPTED = ("source", "q", "kind", "limit", "cursor")
224
+ _QUALIFIED_FACETS_ACCEPTED = ("source",)
225
+ # A raw browse cursor is a conversation key — printable + URL-safe by construction
226
+ # (§2.2). Syntactic-only validation: reject whitespace/control/empty; echo raw.
227
+ import re as _re_qs # noqa: E402 — module-level compile for the browse-cursor check
228
+ _BROWSE_CURSOR_RE = _re_qs.compile(r"\A[!-~]+\Z")
229
+
230
+
231
+ def _resolve_effective_speed():
232
+ """§2.4: resolve ``auto`` → a concrete speed ONCE at the route I/O boundary,
233
+ via the SAME chokepoint the Codex reporting commands use. Search never prices;
234
+ detail/outline/export thread it down (Claude ignores it — cost is materialized)."""
235
+ return sys.modules["cctally"]._resolve_codex_speed("auto")
236
+
237
+
238
+ def _conversation_dispatch():
239
+ """Lazy-load the provider-neutral dispatch kernel (the S7 entity ops)."""
240
+ return _conversation_dispatch_impl()
241
+
242
+
243
+ def _parse_source_param(handler, qs_raw):
244
+ """Strict ``?source=`` parse (§2.2). Returns ``(qualified, source)``:
245
+
246
+ - ``(False, None)`` — the param is absent → the caller runs the legacy path
247
+ byte-identically;
248
+ - ``(True, "claude"|"codex")`` — exactly one literal value;
249
+
250
+ or ``None`` after having ALREADY sent a 400 (blank, duplicated, ``all``, or any
251
+ other unknown value)."""
252
+ import urllib.parse as _u
253
+ vals = _u.parse_qs(qs_raw, keep_blank_values=True).get("source")
254
+ if vals is None:
255
+ return (False, None)
256
+ if len(vals) != 1 or vals[0] not in ("claude", "codex"):
257
+ handler._respond_json(400, {"error": f"invalid source: {vals}"})
258
+ return None
259
+ return (True, vals[0])
260
+
261
+
262
+ def _validate_qualified_params(handler, qs_raw, accepted):
263
+ """§2.2 strict qualified-param validation. Rejects (400) any RECOGNIZED
264
+ conversation param not in ``accepted``, and any accepted param that appears
265
+ more than once. Genuinely-unknown params (outside
266
+ ``_RECOGNIZED_CONVERSATION_PARAMS``) are ignored. Returns the
267
+ ``keep_blank_values`` parse map on success, or ``None`` after a 400."""
268
+ import urllib.parse as _u
269
+ parsed = _u.parse_qs(qs_raw, keep_blank_values=True)
270
+ accepted_set = set(accepted)
271
+ for name, vals in parsed.items():
272
+ if name in _RECOGNIZED_CONVERSATION_PARAMS and name not in accepted_set:
273
+ handler._respond_json(400, {"error": f"unexpected param: {name}"})
274
+ return None
275
+ if name in accepted_set and len(vals) != 1:
276
+ handler._respond_json(400, {"error": f"duplicate param: {name}"})
277
+ return None
278
+ return parsed
279
+
280
+
281
+ def _parse_qualified_limit(handler, parsed):
282
+ """Strict qualified ``limit`` (§2.2): a base-10 integer 1..500. Absent → the
283
+ kernel default (50); malformed or out of range → 400. Returns ``(ok, limit)``
284
+ (``ok=False`` means a 400 was already sent)."""
285
+ vals = parsed.get("limit")
286
+ if vals is None:
287
+ return (True, 50)
288
+ raw = vals[0]
289
+ if not (raw.isascii() and raw.isdigit()): # strict base-10, no sign/space
290
+ handler._respond_json(400, {"error": f"bad limit: {raw}"})
291
+ return (False, None)
292
+ n = int(raw)
293
+ if not (1 <= n <= 500):
294
+ handler._respond_json(400, {"error": f"limit out of range: {n}"})
295
+ return (False, None)
296
+ return (True, n)
297
+
298
+
299
+ def _parse_qualified_browse_cursor(handler, parsed):
300
+ """The browse ``cursor`` is a raw conversation key — passed verbatim both
301
+ directions (§2.2). Syntactic-only: blank → no cursor; a non-printable /
302
+ whitespace value → 400. Returns ``(ok, cursor)``."""
303
+ vals = parsed.get("cursor")
304
+ if vals is None or vals[0] == "":
305
+ return (True, None)
306
+ cur = vals[0]
307
+ if not _BROWSE_CURSOR_RE.match(cur):
308
+ handler._respond_json(400, {"error": "malformed cursor"})
309
+ return (False, None)
310
+ return (True, cur)
311
+
312
+
313
+ def _respond_qualified_json(handler, env):
314
+ """Map a neutral entity envelope's ``status`` → the §2.3 HTTP JSON transport:
315
+ ``ok`` / ``normalization_pending`` → 200; ``gone`` → 410; ``validation_error``
316
+ → 400; anything else (``not_found`` + unknown) → 404. Body is the envelope."""
317
+ status = (env or {}).get("status")
318
+ if status in ("ok", "normalization_pending"):
319
+ handler._respond_json(200, env)
320
+ elif status == "gone":
321
+ handler._respond_json(410, env)
322
+ elif status == "validation_error":
323
+ handler._respond_json(400, env)
324
+ else:
325
+ handler._respond_json(404, env)
326
+
327
+
328
+ def _serve_qualified_entity(handler, dispatch_call, log_label):
329
+ """Run a neutral entity dispatch through the shared 500-envelope scaffold and
330
+ map its status to the §2.3 JSON transport. For the JSON entity legs (detail,
331
+ outline, prompts, find, payload); export/anon-map/media serve non-JSON or a
332
+ bespoke existence probe, so they do not route through here."""
333
+ ok, env = handler._run_conversation_query(dispatch_call, log_label)
334
+ if not ok:
335
+ return
336
+ _respond_qualified_json(handler, env)
337
+
338
+
339
+ def _handle_qualified_browse(handler, qs_raw, source):
340
+ """``GET /api/conversations?source=…`` (§2.2). Strict param whitelist; the
341
+ neutral browse envelope served verbatim (codex ``normalization_pending`` is a
342
+ legitimate 200 empty state)."""
343
+ parsed = _validate_qualified_params(handler, qs_raw, _QUALIFIED_BROWSE_ACCEPTED)
344
+ if parsed is None:
345
+ return
346
+ ok, limit = _parse_qualified_limit(handler, parsed)
347
+ if not ok:
348
+ return
349
+ ok, cursor = _parse_qualified_browse_cursor(handler, parsed)
350
+ if not ok:
351
+ return
352
+ project_key = (parsed.get("project_key", [None])[0] or None)
353
+ model = (parsed.get("model", [None])[0] or None)
354
+ speed = _resolve_effective_speed()
355
+ disp = _conversation_dispatch()
356
+ ok, body = handler._run_conversation_query(
357
+ lambda conn: disp.neutral_browse(
358
+ conn, source=source, effective_speed=speed,
359
+ project_key=project_key, model=model, limit=limit, cursor=cursor),
360
+ "/api/conversations")
361
+ if not ok:
362
+ return
363
+ handler._respond_json(200, body)
364
+
365
+
366
+ def _handle_qualified_facets(handler, qs_raw, source):
367
+ """``GET /api/conversations/facets?source=…`` (§2.2). Accepts ``source`` only;
368
+ serves the status-tagged facets-only envelope (pending → empty facet lists)."""
369
+ parsed = _validate_qualified_params(handler, qs_raw, _QUALIFIED_FACETS_ACCEPTED)
370
+ if parsed is None:
371
+ return
372
+ speed = _resolve_effective_speed()
373
+ disp = _conversation_dispatch()
374
+ ok, body = handler._run_conversation_query(
375
+ lambda conn: disp.neutral_browse(
376
+ conn, source=source, effective_speed=speed),
377
+ "/api/conversations/facets")
378
+ if not ok:
379
+ return
380
+ facets = (body or {}).get("facets") or {"projects": [], "models": []}
381
+ handler._respond_json(
382
+ 200, {"status": (body or {}).get("status"), "facets": facets})
383
+
384
+
385
+ def _handle_qualified_search(handler, qs_raw, source):
386
+ """``GET /api/conversation/search?source=…`` (§2.2). Strict whitelist; the
387
+ search ``cursor`` must decode as base64url (else 400); the neutral search
388
+ envelope (with the codec'd ``page.cursor``) is served verbatim."""
389
+ parsed = _validate_qualified_params(handler, qs_raw, _QUALIFIED_SEARCH_ACCEPTED)
390
+ if parsed is None:
391
+ return
392
+ query = (parsed.get("q", [""])[0] or "")
393
+ kind = (parsed.get("kind", ["all"])[0] or "all")
394
+ if kind not in _CONV_SEARCH_KINDS:
395
+ handler._respond_json(400, {"error": f"unknown kind: {kind}"})
396
+ return
397
+ ok, limit = _parse_qualified_limit(handler, parsed)
398
+ if not ok:
399
+ return
400
+ cursor = (parsed.get("cursor", [None])[0] or None)
401
+ disp = _conversation_dispatch()
402
+ if cursor is not None:
403
+ try:
404
+ disp.decode_search_cursor(cursor)
405
+ except disp.InvalidSearchCursor:
406
+ handler._respond_json(400, {"error": "invalid cursor"})
407
+ return
408
+ speed = _resolve_effective_speed()
409
+ ok, body = handler._run_conversation_query(
410
+ lambda conn: disp.neutral_search(
411
+ conn, query, source=source, kind=kind,
412
+ effective_speed=speed, limit=limit, cursor=cursor),
413
+ "/api/conversation/search")
414
+ if not ok:
415
+ return
416
+ handler._respond_json(200, body)
417
+
418
+
120
419
  def _parse_search_kind_impl(handler, q, valid=_CONV_SEARCH_KINDS):
121
420
  """Read + validate the ``kind`` facet for a conversation route (#177 S6 /
122
421
  #217 S2). Returns the kind on success, or ``None`` after having ALREADY
@@ -263,7 +562,14 @@ def _handle_get_conversations_impl(handler) -> None:
263
562
  if not handler._require_transcripts_allowed():
264
563
  return
265
564
  import urllib.parse as _u
266
- q = _u.parse_qs(handler.path.partition("?")[2])
565
+ qs_raw = handler.path.partition("?")[2]
566
+ parsed_src = _parse_source_param(handler, qs_raw)
567
+ if parsed_src is None:
568
+ return # a 400 has already been sent
569
+ qualified, source = parsed_src
570
+ if qualified:
571
+ return _handle_qualified_browse(handler, qs_raw, source)
572
+ q = _u.parse_qs(qs_raw)
267
573
  sort = _qs_str(q, "sort", "recent")
268
574
  limit = _qs_int(q, "limit", 50)
269
575
  offset = _qs_int(q, "offset", 0)
@@ -292,6 +598,13 @@ def _handle_get_conversations_facets_impl(handler) -> None:
292
598
  """
293
599
  if not handler._require_transcripts_allowed():
294
600
  return
601
+ qs_raw = handler.path.partition("?")[2]
602
+ parsed_src = _parse_source_param(handler, qs_raw)
603
+ if parsed_src is None:
604
+ return # a 400 has already been sent
605
+ qualified, source = parsed_src
606
+ if qualified:
607
+ return _handle_qualified_facets(handler, qs_raw, source)
295
608
  ok, body = handler._run_conversation_query(
296
609
  lambda conn: handler._conversation_query().list_conversation_facets(conn),
297
610
  "/api/conversations/facets")
@@ -334,6 +647,17 @@ def _handle_get_conversation_detail_impl(handler, path: str) -> None:
334
647
  if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
335
648
  handler.send_error(400, "after/before/tail are mutually exclusive")
336
649
  return
650
+ if session_id.startswith("v1."):
651
+ # Qualified (v1.) → neutral detail envelope (§2.1 / §2.3).
652
+ speed = _resolve_effective_speed()
653
+ disp = _conversation_dispatch()
654
+ _serve_qualified_entity(
655
+ handler,
656
+ lambda conn: disp.neutral_detail(
657
+ conn, session_id, effective_speed=speed,
658
+ after=after, before=before, tail=tail, limit=limit),
659
+ "/api/conversation")
660
+ return
337
661
  ok, body = handler._run_conversation_query(
338
662
  lambda conn: handler._conversation_query().get_conversation(
339
663
  conn, session_id, after=after, before=before, tail=tail,
@@ -346,23 +670,9 @@ def _handle_get_conversation_detail_impl(handler, path: str) -> None:
346
670
  return
347
671
  handler._respond_json(200, body)
348
672
 
349
- def _handle_get_conversation_events_impl(handler, path: str) -> None:
350
- """``GET /api/conversation/<id>/events`` per-conversation live-tail
351
- SSE (spec §2). Fail-closed behind the same transcript privacy gate as
352
- the other conversation routes. Watches only this session's file(s);
353
- emits ``event: tail`` on growth, ``: keep-alive`` when idle. Passive
354
- (no ingest, no emit) under ``--no-sync``."""
355
- if not handler._require_transcripts_allowed():
356
- return
357
- import time as _time
358
- import urllib.parse as _u
359
- watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
360
- cq = handler._conversation_query()
361
- session_id = _u.unquote(path[len("/api/conversation/"):-len("/events")])
362
- if not session_id:
363
- handler.send_error(404, "conversation not found")
364
- return
365
-
673
+ def _send_sse_headers(handler) -> None:
674
+ """The exact SSE header set both the bare and qualified live-tail streams
675
+ commit (kept in one place so bare stream bytes stay byte-identical)."""
366
676
  handler.send_response(200)
367
677
  handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
368
678
  handler.send_header("Cache-Control", "no-cache")
@@ -370,8 +680,110 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
370
680
  handler.send_header("X-Accel-Buffering", "no")
371
681
  handler.end_headers()
372
682
 
373
- passive = bool(type(handler).no_sync)
374
683
 
684
+ def _run_conversation_events_stream(
685
+ handler, conn, *, passive, tail_data, resolve, ingest, cached_sigs,
686
+ discovery_step=None,
687
+ ) -> None:
688
+ """Shared per-conversation live-tail SSE loop (spec §5.2), driving both the
689
+ bare (``sessionId``) and qualified (``conversationKey``) streams from ONE
690
+ body so their vocabulary — ``event: ready`` once, ``event: tail`` on
691
+ committed growth, ``: keep-alive`` when idle — stays in lockstep and the bare
692
+ stream bytes are byte-identical to today (only ``tail_data`` differs).
693
+
694
+ ``resolve()`` → the watched file list; ``ingest(changed)`` → the targeted
695
+ ingest (Claude ``sync_cache`` / Codex ``sync_codex_cache``) whose stats carry
696
+ ``targeted_clean``; ``cached_sigs(paths)`` → ``{path: committed cursor}`` (the
697
+ per-path committed-cursor baseline, so growth during an ingest is re-detected
698
+ next cycle); ``discovery_step(files, seen) → (files, emitted)`` is the
699
+ ~10-cycle Codex child-discovery frontier (``None`` for Claude — a bare or
700
+ qualified-Claude stream never runs it). The cache lock is held ONLY inside
701
+ ``ingest`` / ``discovery_step``, never across a sleep (the #297 WAL
702
+ discipline)."""
703
+ import time as _time
704
+ watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
705
+ tail_frame = (
706
+ "event: tail\ndata: " + json.dumps(tail_data) + "\n\n").encode("utf-8")
707
+
708
+ if passive:
709
+ # Frozen-data contract: no ingest, no emit. Keep-alive only.
710
+ while True:
711
+ _time.sleep(_LIVE_TAIL_KEEPALIVE)
712
+ handler.wfile.write(b": keep-alive\n\n")
713
+ handler.wfile.flush()
714
+
715
+ # #278 Theme B: signal that this connection is ACTIVELY live-tailing (not
716
+ # degraded to keep-alive). The client sets `live` only on this 'ready'.
717
+ handler.wfile.write(b"event: ready\ndata: {}\n\n")
718
+ handler.wfile.flush()
719
+
720
+ files = resolve()
721
+ # Best-effort connect ingest for immediacy, then baseline `seen` from the
722
+ # cache's own committed cursor so any pre-connect growth the connect-ingest
723
+ # declined is still caught on cycle 1.
724
+ try:
725
+ if files:
726
+ ingest(files)
727
+ except sqlite3.DatabaseError:
728
+ pass
729
+ seen = cached_sigs(files)
730
+
731
+ idle = 0.0
732
+ cycles = 0
733
+ while True:
734
+ _time.sleep(_LIVE_TAIL_POLL_INTERVAL)
735
+ cycles += 1
736
+ changed = watch.changed_paths(files, seen)
737
+ if changed:
738
+ _time.sleep(_LIVE_TAIL_DEBOUNCE)
739
+ new_seen, emitted = watch.watch_step(
740
+ files, seen, ingest_fn=ingest,
741
+ committed_sig_fn=lambda p: cached_sigs([p]).get(p))
742
+ seen = new_seen
743
+ if emitted:
744
+ handler.wfile.write(tail_frame)
745
+ handler.wfile.flush()
746
+ idle = 0.0
747
+ # A brand-new child file's FIRST content was just ingested, so
748
+ # the conversation's source-path set may have grown. Re-resolve
749
+ # now (vs waiting up to _LIVE_TAIL_FILE_RESET_EVERY cycles) so the
750
+ # new thread live-tails promptly. A new path seeds seen=None (cur
751
+ # lacks a row) → changed_paths flags it next cycle → it emits.
752
+ new_files = resolve()
753
+ if set(new_files) != set(files):
754
+ files = new_files
755
+ cur = cached_sigs(files)
756
+ for p in files:
757
+ seen.setdefault(p, cur.get(p))
758
+ continue
759
+ idle += _LIVE_TAIL_POLL_INTERVAL
760
+ if idle >= _LIVE_TAIL_KEEPALIVE:
761
+ handler.wfile.write(b": keep-alive\n\n")
762
+ handler.wfile.flush()
763
+ idle = 0.0
764
+ if cycles % _LIVE_TAIL_FILE_RESET_EVERY == 0:
765
+ # Layer 1 (both providers): DB re-resolve so a child ingested by ANY
766
+ # other writer joins the watch immediately.
767
+ files = resolve()
768
+ seen = {p: s for p, s in seen.items() if p in set(files)}
769
+ # Layer 2 (Codex only): bounded filesystem child discovery for
770
+ # brand-new files no table yet knows (§5.4). A widened file set emits
771
+ # a `tail` — the client refetches detail, whose children list
772
+ # surfaces the new child (no new frame type).
773
+ if discovery_step is not None:
774
+ files, disc_emitted = discovery_step(files, seen)
775
+ if disc_emitted:
776
+ handler.wfile.write(tail_frame)
777
+ handler.wfile.flush()
778
+ idle = 0.0
779
+
780
+
781
+ def _bare_conversation_events(handler, session_id: str) -> None:
782
+ """Bare legacy Claude live-tail — today's no-preflight, ``sessionId``-framed
783
+ behavior, byte-identical (spec §5.2 reserves this for bare streams)."""
784
+ cq = handler._conversation_query()
785
+ _send_sse_headers(handler)
786
+ passive = bool(type(handler).no_sync)
375
787
  try:
376
788
  conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
377
789
  except (sqlite3.DatabaseError, OSError):
@@ -387,89 +799,177 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
387
799
  return sync_cache(conn, only_paths=set(changed))
388
800
 
389
801
  try:
390
- if passive:
391
- # Frozen-data contract: no ingest, no emit. Keep-alive only.
392
- while True:
393
- _time.sleep(_LIVE_TAIL_KEEPALIVE)
394
- handler.wfile.write(b": keep-alive\n\n")
395
- handler.wfile.flush()
802
+ _run_conversation_events_stream(
803
+ handler, conn, passive=passive,
804
+ tail_data={"sessionId": session_id},
805
+ resolve=_resolve, ingest=_ingest,
806
+ cached_sigs=lambda paths: _cached_file_sigs(conn, paths))
807
+ except (BrokenPipeError, ConnectionResetError,
808
+ ConnectionAbortedError, socket.timeout):
809
+ # #279 S1 F3: a stalled send past the handler timeout raises
810
+ # socket.timeout — treat as a client disconnect, not an error.
811
+ pass
812
+ except Exception as exc: # noqa: BLE001
813
+ # #279 S5 F6.2 (spec §8): headers are already committed, so route the
814
+ # operator signal through the _lib_log chokepoint + a clean close.
815
+ handler.log_error("api/conversation/events stream failed: %r", exc)
816
+ finally:
817
+ if conn is not None:
818
+ conn.close()
819
+
396
820
 
397
- # #278 Theme B: signal that this connection is ACTIVELY live-tailing
398
- # (not degraded to keep-alive). The client sets `live` only on this, so
399
- # passive/cache-open-failed streams fall back to the memo-backed global
400
- # tick instead of stranding updates (Codex F4). Passive branch above
401
- # emits only ': keep-alive', so 'ready' is unambiguous.
402
- handler.wfile.write(b"event: ready\ndata: {}\n\n")
403
- handler.wfile.flush()
404
-
405
- files = _resolve()
406
- # Best-effort connect ingest for immediacy, then baseline `seen`
407
- # from the cache's own offsets (session_files) so any pre-connect
408
- # growth the connect-ingest declined is still caught on cycle 1.
821
+ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
822
+ """Build the ~10-cycle Codex child-discovery frontier step (spec §5.4), or
823
+ ``None`` when the conversation's own root is not currently configured (the
824
+ frontier is then skipped — the DB re-resolve of layer 1 still runs). The
825
+ frontier is constructed ONCE per SSE connection so its directory index,
826
+ pending-candidate set, and rotation cursors accumulate across cycles."""
827
+ walk_root = _codex_walk_root_for_conversation(conn, conversation_key)
828
+ if walk_root is None:
829
+ return None
830
+ fw = sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
831
+ frontier = fw.CodexChildFrontier(walk_root)
832
+
833
+ def _discovery(files, seen):
834
+ # Diff brand-new *.jsonl against every tracked Codex file; the same
835
+ # {path: size} feeds both the frontier's known-set and its committed-size
836
+ # growth baseline (a plain SELECT — no lock across the sleep).
837
+ known = _codex_all_committed_sizes(conn)
838
+ to_ingest = frontier.cycle(
839
+ known_paths=set(known.keys()), committed_sizes=known)
840
+ if not to_ingest:
841
+ return files, False
409
842
  try:
410
- if files:
411
- sync_cache(conn, only_paths=set(files))
843
+ sync_codex_cache(conn, only_paths=set(to_ingest))
412
844
  except sqlite3.DatabaseError:
845
+ return files, False
846
+ # Reap the now-classified candidates (child → already widened; non-child
847
+ # → done). Unclassified ones stay pending and are retried next cycle.
848
+ frontier.reap(_codex_classified_paths(conn, to_ingest))
849
+ new_files = cq_codex.codex_conversation_source_paths(conn, conversation_key)
850
+ if set(new_files) == set(files):
851
+ return files, False
852
+ cur = _codex_cached_file_sigs(conn, new_files)
853
+ for p in new_files:
854
+ seen.setdefault(p, cur.get(p))
855
+ return new_files, True
856
+
857
+ return _discovery
858
+
859
+
860
+ def _qualified_conversation_events(handler, key: str) -> None:
861
+ """Qualified (``v1.``) live-tail (spec §5.2): a neutral preflight — resolve →
862
+ normalization authority (Codex) → existence — answered as plain JSON per
863
+ §2.3 BEFORE any SSE bytes; only on ``ok`` are SSE headers committed and the
864
+ ``conversationKey``-framed stream entered. A qualified Claude key reuses the
865
+ existing Claude ingestion/watch mechanics internally; a Codex key uses
866
+ targeted ingest + the directory-frontier child discovery."""
867
+ disp = _conversation_dispatch_impl()
868
+ try:
869
+ conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
870
+ except (sqlite3.DatabaseError, OSError):
871
+ conn = None
872
+
873
+ if conn is None:
874
+ # Cache unavailable — existence is unknowable, so we cannot preflight.
875
+ # Degrade to a passive keep-alive stream (the client's backstop tick
876
+ # still surfaces turns), mirroring the bare path's conn-failure fallback.
877
+ _send_sse_headers(handler)
878
+ try:
879
+ _run_conversation_events_stream(
880
+ handler, None, passive=True, tail_data={"conversationKey": key},
881
+ resolve=lambda: [], ingest=lambda c: None,
882
+ cached_sigs=lambda paths: {})
883
+ except (BrokenPipeError, ConnectionResetError,
884
+ ConnectionAbortedError, socket.timeout):
413
885
  pass
414
- seen = _cached_file_sigs(conn, files)
886
+ except Exception as exc: # noqa: BLE001
887
+ handler.log_error("api/conversation/events stream failed: %r", exc)
888
+ return
415
889
 
416
- idle = 0.0
417
- cycles = 0
418
- while True:
419
- _time.sleep(_LIVE_TAIL_POLL_INTERVAL)
420
- cycles += 1
421
- changed = watch.changed_paths(files, seen)
422
- if changed:
423
- _time.sleep(_LIVE_TAIL_DEBOUNCE)
424
- new_seen, emitted = watch.watch_step(
425
- files, seen, ingest_fn=_ingest,
426
- committed_sig_fn=lambda p: _cached_file_sigs(conn, [p]).get(p))
427
- seen = new_seen
428
- if emitted:
429
- handler.wfile.write(
430
- ("event: tail\ndata: "
431
- + json.dumps({"sessionId": session_id})
432
- + "\n\n").encode("utf-8"))
433
- handler.wfile.flush()
434
- idle = 0.0
435
- # §6 P2-H — a brand-new subagent file's FIRST content was
436
- # just ingested by this emitting cycle, so the session's
437
- # source-path set may have grown. Re-resolve it now (vs
438
- # waiting up to _LIVE_TAIL_FILE_RESET_EVERY cycles) so the
439
- # new thread (incl. a skill invoked inside it) live-tails
440
- # promptly. A new path seeds seen=None (cur lacks a row),
441
- # so changed_paths flags it next cycle → it ingests + emits.
442
- # setdefault never disturbs an existing cursor.
443
- new_files = _resolve()
444
- if set(new_files) != set(files):
445
- files = new_files
446
- cur = _cached_file_sigs(conn, files)
447
- for p in files:
448
- seen.setdefault(p, cur.get(p))
449
- continue
450
- idle += _LIVE_TAIL_POLL_INTERVAL
451
- if idle >= _LIVE_TAIL_KEEPALIVE:
452
- handler.wfile.write(b": keep-alive\n\n")
453
- handler.wfile.flush()
454
- idle = 0.0
455
- if cycles % _LIVE_TAIL_FILE_RESET_EVERY == 0:
456
- files = _resolve()
457
- seen = {p: s for p, s in seen.items() if p in set(files)}
890
+ try:
891
+ preflight = disp.neutral_events_preflight(conn, key)
892
+ except Exception as exc: # noqa: BLE001
893
+ # A preflight failure is a pre-headers error, so a JSON 500 is still
894
+ # possible (unlike a mid-stream failure).
895
+ handler.log_error("api/conversation/events preflight failed: %r", exc)
896
+ handler._respond_json(500, {"error": "internal error"})
897
+ conn.close()
898
+ return
899
+ status = preflight.get("status")
900
+ if status == "normalization_pending":
901
+ # A legitimate empty state (migration 025 not yet stamped) — 200 JSON
902
+ # envelope, never a 200-SSE stream with nothing to say (§2.3).
903
+ handler._respond_json(200, preflight)
904
+ conn.close()
905
+ return
906
+ if status != "ok":
907
+ # not_found (unresolvable ref or no rows) → 404 JSON (§2.3).
908
+ handler._respond_json(404, preflight)
909
+ conn.close()
910
+ return
911
+ source = preflight["source"]
912
+ native = preflight["native_key"]
913
+
914
+ # ok commit SSE headers and enter the qualified watch loop.
915
+ _send_sse_headers(handler)
916
+ passive = bool(type(handler).no_sync)
917
+ if source == "codex":
918
+ cq_codex = _conversation_query_impl_codex()
919
+
920
+ def _resolve():
921
+ return cq_codex.codex_conversation_source_paths(conn, key)
922
+
923
+ def _ingest(changed):
924
+ return sync_codex_cache(conn, only_paths=set(changed))
925
+
926
+ cached = lambda paths: _codex_cached_file_sigs(conn, paths)
927
+ discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
928
+ else: # claude — reuse the Claude mechanics, speak qualified frames.
929
+ cq = handler._conversation_query()
930
+
931
+ def _resolve():
932
+ return cq.session_source_paths(conn, native)
933
+
934
+ def _ingest(changed):
935
+ return sync_cache(conn, only_paths=set(changed))
936
+
937
+ cached = lambda paths: _cached_file_sigs(conn, paths)
938
+ discovery = None
939
+
940
+ try:
941
+ _run_conversation_events_stream(
942
+ handler, conn, passive=passive,
943
+ tail_data={"conversationKey": key},
944
+ resolve=_resolve, ingest=_ingest, cached_sigs=cached,
945
+ discovery_step=discovery)
458
946
  except (BrokenPipeError, ConnectionResetError,
459
947
  ConnectionAbortedError, socket.timeout):
460
- # #279 S1 F3: a stalled send past the handler timeout raises
461
- # socket.timeout inside the SSE loop — treat it as a client
462
- # disconnect (same as the other peer-gone classes), not an error.
463
- pass # client disconnect is normal
948
+ pass
464
949
  except Exception as exc: # noqa: BLE001
465
- # #279 S5 F6.2 (spec §8): a genuine bug mid-stream used to kill the
466
- # live-tail SSE silently via handle_error. Headers are already
467
- # committed — route the operator signal through the _lib_log chokepoint
468
- # (handler.log_error) + a deliberate clean close via the finally below.
469
950
  handler.log_error("api/conversation/events stream failed: %r", exc)
470
951
  finally:
471
- if conn is not None:
472
- conn.close()
952
+ conn.close()
953
+
954
+
955
+ def _handle_get_conversation_events_impl(handler, path: str) -> None:
956
+ """``GET /api/conversation/<id>/events`` — per-conversation live-tail SSE
957
+ (spec §5.2). The transcript privacy gate is the first act, source-
958
+ independent. A ``v1.`` key gets the neutral preflight + qualified
959
+ (``conversationKey``) frames for BOTH providers; every other id keeps
960
+ today's no-preflight, ``sessionId``-framed bare behavior byte-identical."""
961
+ if not handler._require_transcripts_allowed():
962
+ return
963
+ import urllib.parse as _u
964
+ key = _u.unquote(path[len("/api/conversation/"):-len("/events")])
965
+ if not key:
966
+ handler.send_error(404, "conversation not found")
967
+ return
968
+ if key.startswith("v1."):
969
+ _qualified_conversation_events(handler, key)
970
+ else:
971
+ _bare_conversation_events(handler, key)
972
+
473
973
 
474
974
  def _handle_get_conversation_search_impl(handler) -> None:
475
975
  """``GET /api/conversation/search?q=...&kind=...`` — cross-session
@@ -487,7 +987,14 @@ def _handle_get_conversation_search_impl(handler) -> None:
487
987
  if not handler._require_transcripts_allowed():
488
988
  return
489
989
  import urllib.parse as _u
490
- q = _u.parse_qs(handler.path.partition("?")[2])
990
+ qs_raw = handler.path.partition("?")[2]
991
+ parsed_src = _parse_source_param(handler, qs_raw)
992
+ if parsed_src is None:
993
+ return # a 400 has already been sent
994
+ qualified, source = parsed_src
995
+ if qualified:
996
+ return _handle_qualified_search(handler, qs_raw, source)
997
+ q = _u.parse_qs(qs_raw)
491
998
  query = _qs_str(q, "q", "")
492
999
  limit = _qs_int(q, "limit", 50)
493
1000
  offset = _qs_int(q, "offset", 0)
@@ -524,6 +1031,21 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
524
1031
  session_id = _u.unquote(
525
1032
  path[len("/api/conversation/"):-len("/payload")])
526
1033
  q = _u.parse_qs(handler.path.partition("?")[2])
1034
+ if session_id.startswith("v1."):
1035
+ # Qualified payload readback (§3.4): Codex uses block_key + which={call,
1036
+ # output}; a v1.claude key keeps the tool_use_id + which={input,result}
1037
+ # selector. neutral_payload picks per the resolved source; gone → 410.
1038
+ which_q = _qs_str(q, "which", "")
1039
+ tool_use_id_q = _qs_str(q, "tool_use_id", "") or None
1040
+ block_key_q = _qs_str(q, "block_key", "") or None
1041
+ disp = _conversation_dispatch()
1042
+ _serve_qualified_entity(
1043
+ handler,
1044
+ lambda conn: disp.neutral_payload(
1045
+ conn, session_id, which=which_q,
1046
+ tool_use_id=tool_use_id_q, block_key=block_key_q),
1047
+ "/api/conversation/payload")
1048
+ return
527
1049
  tool_use_id = _qs_str(q, "tool_use_id", "")
528
1050
  which = _qs_str(q, "which", "result")
529
1051
  if not session_id or which not in ("result", "input") or not tool_use_id:
@@ -556,6 +1078,15 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
556
1078
  if not session_id:
557
1079
  handler.send_error(404, "conversation not found")
558
1080
  return
1081
+ if session_id.startswith("v1."):
1082
+ speed = _resolve_effective_speed()
1083
+ disp = _conversation_dispatch()
1084
+ _serve_qualified_entity(
1085
+ handler,
1086
+ lambda conn: disp.neutral_outline(
1087
+ conn, session_id, effective_speed=speed),
1088
+ "/api/conversation/outline")
1089
+ return
559
1090
  ok, body = handler._run_conversation_query(
560
1091
  lambda conn: handler._conversation_query().get_conversation_outline(conn, session_id),
561
1092
  "/api/conversation/outline")
@@ -580,6 +1111,13 @@ def _handle_get_conversation_prompts_impl(handler, path: str) -> None:
580
1111
  if not session_id:
581
1112
  handler.send_error(404, "conversation not found")
582
1113
  return
1114
+ if session_id.startswith("v1."):
1115
+ disp = _conversation_dispatch()
1116
+ _serve_qualified_entity(
1117
+ handler,
1118
+ lambda conn: disp.neutral_prompts(conn, session_id),
1119
+ "/api/conversation/prompts")
1120
+ return
583
1121
  ok, body = handler._run_conversation_query(
584
1122
  lambda conn: handler._conversation_query().get_conversation_prompts(conn, session_id),
585
1123
  "/api/conversation/prompts")
@@ -633,6 +1171,48 @@ def _handle_get_conversation_export_impl(handler, path: str) -> None:
633
1171
  handler.send_error(404, "conversation not found")
634
1172
  return
635
1173
 
1174
+ if session_id.startswith("v1."):
1175
+ # Qualified export (§2.3 markdown leg / §3.6 provider-aware anon):
1176
+ # neutral_export serves the raw markdown member; ``anonymize=1`` scrubs
1177
+ # with the QUALIFIED provider-aware plan (byte-parity with the CLI export).
1178
+ speed = _resolve_effective_speed()
1179
+ disp = _conversation_dispatch()
1180
+ cref = disp.resolve_conversation_ref(session_id)
1181
+
1182
+ def _q_kernel(conn):
1183
+ env = disp.neutral_export(
1184
+ conn, session_id, scope=scope, effective_speed=speed)
1185
+ if env.get("status") != "ok" or not anonymize:
1186
+ return env
1187
+ cq = handler._conversation_query()
1188
+ anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
1189
+ srcs = {cref.source} if cref else set()
1190
+ plan = cq.build_anon_plan_for_sources(
1191
+ conn, home_dir=_os.path.expanduser("~"), sources=srcs)
1192
+ return {**env, "markdown": anon.scrub_text(env["markdown"], plan)}
1193
+
1194
+ ok, env = handler._run_conversation_query(
1195
+ _q_kernel, "/api/conversation/export")
1196
+ if not ok:
1197
+ return
1198
+ status = (env or {}).get("status")
1199
+ if status == "ok":
1200
+ data = env["markdown"].encode("utf-8")
1201
+ handler.send_response(200)
1202
+ handler.send_header("Content-Type", "text/markdown; charset=utf-8")
1203
+ handler.send_header("Content-Length", str(len(data)))
1204
+ handler.end_headers()
1205
+ handler.wfile.write(data)
1206
+ return
1207
+ if status == "validation_error":
1208
+ handler._respond_json(400, env)
1209
+ return
1210
+ if status == "normalization_pending":
1211
+ handler._respond_json(200, env)
1212
+ return
1213
+ handler._respond_json(404, env) # not_found
1214
+ return
1215
+
636
1216
  def _kernel(conn):
637
1217
  cq = handler._conversation_query()
638
1218
  md = cq.get_conversation_export(conn, session_id, scope)
@@ -678,6 +1258,40 @@ def _handle_get_conversation_anon_map_impl(handler, path: str) -> None:
678
1258
  return
679
1259
  anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
680
1260
 
1261
+ if session_id.startswith("v1."):
1262
+ # Qualified anon-map (§3.6): existence probe against the ref's OWN
1263
+ # provider tables, then the provider-aware plan (never the legacy builder).
1264
+ disp = _conversation_dispatch()
1265
+ cref = disp.resolve_conversation_ref(session_id)
1266
+ if cref is None:
1267
+ handler._respond_json(
1268
+ 404, {"status": "not_found", "conversation_key": session_id})
1269
+ return
1270
+
1271
+ def _q_kernel(conn):
1272
+ cq = handler._conversation_query()
1273
+ if cref.source == "codex":
1274
+ exists = _conversation_query_impl_codex().codex_conversation_exists(
1275
+ conn, cref.conversation_key)
1276
+ else:
1277
+ exists = cq.conversation_exists(conn, cref.native_key)
1278
+ if not exists:
1279
+ return None
1280
+ plan = cq.build_anon_plan_for_sources(
1281
+ conn, home_dir=_os.path.expanduser("~"), sources={cref.source})
1282
+ return anon.plan_to_wire(plan)
1283
+
1284
+ ok, body = handler._run_conversation_query(
1285
+ _q_kernel, "/api/conversation/anon-map")
1286
+ if not ok:
1287
+ return
1288
+ if body is None:
1289
+ handler._respond_json(
1290
+ 404, {"status": "not_found", "conversation_key": session_id})
1291
+ return
1292
+ handler._respond_json(200, body)
1293
+ return
1294
+
681
1295
  def _kernel(conn):
682
1296
  cq = handler._conversation_query()
683
1297
  if not cq.conversation_exists(conn, session_id):
@@ -734,6 +1348,14 @@ def _handle_get_conversation_find_impl(handler, path: str) -> None:
734
1348
  except _re.error as e:
735
1349
  handler._respond_json(400, {"error": f"invalid regex: {e}"})
736
1350
  return
1351
+ if session_id.startswith("v1."):
1352
+ disp = _conversation_dispatch()
1353
+ _serve_qualified_entity(
1354
+ handler,
1355
+ lambda conn: disp.neutral_find(
1356
+ conn, session_id, query, kind=kind, regex=regex, case=case),
1357
+ "/api/conversation/find")
1358
+ return
737
1359
  ok, body = handler._run_conversation_query(
738
1360
  lambda conn: handler._conversation_query().find_in_conversation(
739
1361
  conn, session_id, query, kind=kind, regex=regex, case=case),
@@ -773,6 +1395,21 @@ def _handle_get_conversation_media_impl(handler, path: str) -> None:
773
1395
  return
774
1396
  import urllib.parse as _u
775
1397
  session_id = _u.unquote(path[len("/api/conversation/"):-len("/media")])
1398
+ if session_id.startswith("v1."):
1399
+ # §3.5 / §2.3: Codex media is capability-gated (explicit 404, never a
1400
+ # silent zero-fill). A v1.claude key serves via its native Claude session
1401
+ # (the existing byte-serving mechanics); an unresolvable v1 → neutral 404.
1402
+ disp = _conversation_dispatch()
1403
+ cref = disp.resolve_conversation_ref(session_id)
1404
+ if cref is None:
1405
+ handler._respond_json(
1406
+ 404, {"status": "not_found", "conversation_key": session_id})
1407
+ return
1408
+ if cref.source == "codex":
1409
+ handler._respond_json(
1410
+ 404, {"status": "capability_unsupported", "source": "codex"})
1411
+ return
1412
+ session_id = cref.native_key
776
1413
  q = _u.parse_qs(handler.path.partition("?")[2])
777
1414
  tool_use_id = _qs_str(q, "tool_use_id", "")
778
1415
  uuid = _qs_str(q, "uuid", "")