From 52859f3785fb06b37e4eb4709e35a73e3523aea9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 02:27:16 +0200 Subject: [PATCH 01/11] feat(cli): route continue to interactive harnesses Problem: archived local sessions could produce a context image but could not reopen their original Claude Code or Codex harness. What changed: resolve typed, origin-specific resume routes; print a shell-safe interactive command by default, and require --exec for process execution. Unsupported origins fail explicitly instead of receiving a guessed command. Compatibility: continue no longer emits the legacy context-image payload; use read for archive context and continue for harness reopening. Ref polylogue-37t.8. Co-Authored-By: Claude --- docs/cli-reference.md | 9 +-- docs/plans/topology-target.yaml | 6 +- docs/topology-status.md | 6 +- polylogue/archive/resume_routing.py | 77 ++++++++++++++++++++++ polylogue/cli/query_verbs.py | 69 +++++++------------ tests/unit/cli/test_continue_absorption.py | 18 ++--- tests/unit/cli/test_resume_routing.py | 43 ++++++++++++ 7 files changed, 160 insertions(+), 68 deletions(-) create mode 100644 polylogue/archive/resume_routing.py create mode 100644 tests/unit/cli/test_resume_routing.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 83cbf1fc0a..2818e4d43d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -474,16 +474,17 @@ Commands: ```text Usage: polylogue continue [OPTIONS] - Compile a successor-agent continuation report for one matched session. + Print or explicitly execute a verified harness-resume command. Examples: polylogue find id:abc then continue - polylogue find id:abc then continue --format json - polylogue --latest continue --to clipboard - polylogue find 'repo:polylogue near:id:abc' then continue --to file --out handoff.md + polylogue find id:abc then continue + polylogue find id:abc then continue --exec polylogue continue --candidates --repo /workspace/polylogue --recent polylogue/cli/query_verbs.py Options: + --exec Run the verified interactive resume command + instead of printing it. --to [terminal|stdout|browser|clipboard|file] Output destination. [default: terminal] --out PATH File path for --to file. diff --git a/docs/plans/topology-target.yaml b/docs/plans/topology-target.yaml index 28f452b101..797b915a75 100644 --- a/docs/plans/topology-target.yaml +++ b/docs/plans/topology-target.yaml @@ -470,6 +470,10 @@ files: target: polylogue/archive/raw_payload/streams.py owner: archive-raw-payload reason: archive-domain semantics + - path: polylogue/archive/resume_routing.py + loc: 70 + target: polylogue/archive/resume_routing.py + owner: stable - path: polylogue/archive/revision_authority.py loc: 158 target: polylogue/archive/revision_authority.py @@ -922,7 +926,7 @@ files: target: polylogue/cli/query_stats.py owner: stable - path: polylogue/cli/query_verbs.py - loc: 2691 + loc: 2718 target: polylogue/cli/query_verbs.py owner: stable - path: polylogue/cli/read_view_handlers.py diff --git a/docs/topology-status.md b/docs/topology-status.md index 0974080f5d..dccb5d281e 100644 --- a/docs/topology-status.md +++ b/docs/topology-status.md @@ -28,12 +28,12 @@ Generated by `devtools render topology-status`. Reads `docs/plans/topology-targe ### Summary -- **Stable** (no move scoped): 774 +- **Stable** (no move scoped): 775 - **Kernel** (polylogue/ root): 10 - **Primitives** (storage-root): 18 - **TBD** (cell needs explicit assignment): 6 -- **Total declared**: 927 -- **Realized polylogue/**/*.py**: 927 files declared +- **Total declared**: 928 +- **Realized polylogue/**/*.py**: 928 files declared ### TBD cells (require explicit routing) diff --git a/polylogue/archive/resume_routing.py b/polylogue/archive/resume_routing.py new file mode 100644 index 0000000000..8e3455f13b --- /dev/null +++ b/polylogue/archive/resume_routing.py @@ -0,0 +1,77 @@ +"""Safe, explicit harness-resume commands for archived local sessions. + +Only origins with a documented native resume contract receive a command. The +route is deliberately a value object: callers can print it for a human or +execute it behind an explicit opt-in without guessing for unsupported exports. +""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass +from typing import Literal + +from polylogue.archive.session.domain_models import Session +from polylogue.core.enums import Origin +from polylogue.core.web_urls import native_id_from_session_id + +ResumeRouteStatus = Literal["supported", "unsupported"] + + +@dataclass(frozen=True, slots=True) +class ResumeRoute: + """One resolved resume route, safe to render or execute verbatim.""" + + status: ResumeRouteStatus + origin: str + native_session_id: str + argv: tuple[str, ...] = () + cwd: str | None = None + detail: str | None = None + + @property + def command(self) -> str | None: + """Shell-safe interactive command, prefixed with its known cwd.""" + + if not self.argv: + return None + command = shlex.join(self.argv) + return f"cd {shlex.quote(self.cwd)} && {command}" if self.cwd else command + + +def route_resume(session: Session) -> ResumeRoute: + """Map a local harness session to its interactive resume command. + + ``codex exec resume`` is intentionally not selected here: ``continue`` is + human-facing and must reopen the interactive TUI. It remains the correct + headless alternative for automation. + """ + + origin = str(session.origin) + native_id = native_id_from_session_id(session.id) + cwd = next((path for path in session.working_directories if path), None) + if native_id is None: + return ResumeRoute( + status="unsupported", + origin=origin, + native_session_id="", + cwd=cwd, + detail="The archive session id has no native harness session id.", + ) + commands: dict[Origin, tuple[str, ...]] = { + Origin.CLAUDE_CODE_SESSION: ("claude", "--resume", native_id), + Origin.CODEX_SESSION: ("codex", "resume", native_id), + } + argv = commands.get(session.origin) + if argv is None: + return ResumeRoute( + status="unsupported", + origin=origin, + native_session_id=native_id, + cwd=cwd, + detail=f"No verified interactive resume command for origin {origin!r}.", + ) + return ResumeRoute(status="supported", origin=origin, native_session_id=native_id, argv=argv, cwd=cwd) + + +__all__ = ["ResumeRoute", "ResumeRouteStatus", "route_resume"] diff --git a/polylogue/cli/query_verbs.py b/polylogue/cli/query_verbs.py index bc7dea9eb7..250f5802cf 100644 --- a/polylogue/cli/query_verbs.py +++ b/polylogue/cli/query_verbs.py @@ -8,6 +8,7 @@ import json import shlex +import subprocess from collections.abc import Awaitable, Sequence from typing import TYPE_CHECKING, Any, TypeVar, cast @@ -1318,6 +1319,9 @@ def read_verb( @click.command("continue") +@click.option( + "--exec", "execute", is_flag=True, help="Run the verified interactive resume command instead of printing it." +) @click.option( "--to", "destination", @@ -1371,15 +1375,15 @@ def continue_verb( recent_files: tuple[str, ...], candidate_limit: int, output_format: str | None, + execute: bool, ) -> None: - """Compile a successor-agent continuation report for one matched session. + """Print or explicitly execute a verified harness-resume command. \b Examples: polylogue find id:abc then continue - polylogue find id:abc then continue --format json - polylogue --latest continue --to clipboard - polylogue find 'repo:polylogue near:id:abc' then continue --to file --out handoff.md + polylogue find id:abc then continue + polylogue find id:abc then continue --exec polylogue continue --candidates --repo /workspace/polylogue --recent polylogue/cli/query_verbs.py """ env: AppEnv = ctx.obj @@ -1390,9 +1394,12 @@ def continue_verb( destination=destination, format=output_format or request.params.get("output_format") or "default", candidates=candidates, + execute=execute, ): return if candidates: + if execute: + raise click.UsageError("continue --exec cannot be combined with --candidates.") if destination not in ("terminal", "stdout") or out_path is not None: raise click.UsageError("continue --candidates writes to terminal/stdout; omit --to/--out.") if not repo_path: @@ -1417,51 +1424,19 @@ def continue_verb( session = run_coroutine_sync(env.polylogue.get_session(session_id)) if session is None: raise click.UsageError(f"Session not found: {session_id}") - root_format = request.params.get("output_format") - effective_format = ( - output_format if output_format is not None else root_format if isinstance(root_format, str) else None - ) - if effective_format == "json": - if destination not in ("terminal", "stdout", "file"): - raise click.UsageError("continue --format json supports terminal, stdout, or file destinations only.") - if destination == "file" and not out_path: - raise click.UsageError("continue --format json --to file requires --out.") - from pathlib import Path - - from polylogue.context.compiler import ContextSpec + from polylogue.archive.resume_routing import route_resume - image = run_coroutine_sync( - env.polylogue.compile_context( - ContextSpec( - purpose="continue", - seed_refs=(f"session:{session_id}",), - read_views=("messages",), - unit_queries=_successor_context_unit_queries(session_id), - ) - ) - ) - rendered = serialize_surface_payload(image, exclude_none=True) - if destination == "file": - assert out_path is not None - Path(out_path).write_text(rendered + "\n", encoding="utf-8") - else: - click.echo(rendered) + route = route_resume(session) + if route.status != "supported" or route.command is None: + raise click.UsageError(route.detail or "This session cannot be resumed by a verified local harness command.") + if execute: + subprocess.run(route.argv, cwd=route.cwd, check=False) return - if effective_format not in (None, "markdown"): - raise click.UsageError("continue supports markdown output by default or --format json.") - from polylogue.context.compiler import ContextSpec - - image = run_coroutine_sync( - env.polylogue.compile_context( - ContextSpec( - purpose="continue", - seed_refs=(f"session:{session_id}",), - read_views=("messages",), - unit_queries=_successor_context_unit_queries(session_id), - ) - ) - ) - _deliver_content(env, _render_context_image_markdown(image), destination=destination, out_path=out_path) + if destination not in ("terminal", "stdout") or out_path is not None: + raise click.UsageError("continue prints its command to terminal/stdout; omit --to/--out.") + if output_format is not None or request.params.get("output_format") is not None: + raise click.UsageError("continue emits a shell command; --format/--json are not supported.") + click.echo(route.command) @click.command("delete") diff --git a/tests/unit/cli/test_continue_absorption.py b/tests/unit/cli/test_continue_absorption.py index 3ad261bdaa..06e20b3c48 100644 --- a/tests/unit/cli/test_continue_absorption.py +++ b/tests/unit/cli/test_continue_absorption.py @@ -41,34 +41,26 @@ def _seed_continuation_session(db_path: Path) -> None: _rebuild_archive_session_insights(archive) -def test_continue_json_by_root_format_renders_context_image(cli_workspace: dict[str, Path]) -> None: +def test_continue_emits_interactive_resume_command(cli_workspace: dict[str, Path]) -> None: _seed_continuation_session(cli_workspace["db_path"]) result = CliRunner().invoke( cli, - ["--format", "json", "--id", NID_CONTINUE_ROOT, "continue"], + ["--id", NID_CONTINUE_ROOT, "continue"], catch_exceptions=False, ) assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["spec"]["purpose"] == "continue" - assert payload["spec"]["seed_refs"] == [f"session:{NID_CONTINUE_ROOT}"] - assert {segment["kind"] for segment in payload["segments"]} == {"query_unit", "read_view"} - assert all(segment["kind"] != "recovery" for segment in payload["segments"]) - message_segments = [segment for segment in payload["segments"] if segment["payload_kind"] == "messages"] - assert message_segments - assert "CLI Continue" in message_segments[0]["markdown"] + assert result.output == "codex resume ext-cli-continue-root\n" -def test_continue_plain_names_successor_context_sections(cli_workspace: dict[str, Path]) -> None: +def test_continue_defaults_to_printing_not_executing(cli_workspace: dict[str, Path]) -> None: _seed_continuation_session(cli_workspace["db_path"]) result = CliRunner().invoke(cli, ["--id", NID_CONTINUE_ROOT, "continue"], catch_exceptions=False) assert result.exit_code == 0 - assert "# Query: run" in result.output - assert "# Messages: CLI Continue" in result.output + assert result.output == "codex resume ext-cli-continue-root\n" def test_continue_missing_session_exits_with_clear_message(cli_workspace: dict[str, Path]) -> None: diff --git a/tests/unit/cli/test_resume_routing.py b/tests/unit/cli/test_resume_routing.py new file mode 100644 index 0000000000..fb2bff8565 --- /dev/null +++ b/tests/unit/cli/test_resume_routing.py @@ -0,0 +1,43 @@ +"""Route archived local sessions back to their owning interactive harness.""" + +from __future__ import annotations + +from polylogue.archive.message.messages import MessageCollection +from polylogue.archive.resume_routing import route_resume +from polylogue.archive.session.domain_models import Session +from polylogue.core.enums import Origin +from polylogue.types import SessionId + + +def _session(origin: Origin, native_id: str = "native-session") -> Session: + return Session( + id=SessionId(f"{origin}:{native_id}"), + origin=origin, + title="resume fixture", + messages=MessageCollection(messages=[]), + working_directories=("/workspace/polylogue",), + ) + + +def test_routes_claude_code_to_interactive_resume_command() -> None: + route = route_resume(_session(Origin.CLAUDE_CODE_SESSION, "claude-native")) + + assert route.status == "supported" + assert route.argv == ("claude", "--resume", "claude-native") + assert route.command == "cd /workspace/polylogue && claude --resume claude-native" + + +def test_routes_codex_to_interactive_resume_command_not_headless_exec() -> None: + route = route_resume(_session(Origin.CODEX_SESSION, "codex-native")) + + assert route.status == "supported" + assert route.argv == ("codex", "resume", "codex-native") + assert "exec" not in route.argv + + +def test_refuses_to_guess_for_unsupported_origin() -> None: + route = route_resume(_session(Origin.CHATGPT_EXPORT)) + + assert route.status == "unsupported" + assert route.command is None + assert route.detail is not None From d0822099c5865e94eecdb7a518f662c9c1d943e8 Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 02:53:24 +0200 Subject: [PATCH 02/11] feat(daemon): expose hot UDS transport Problem: CLI clients had no persistent local transport to the warm daemon. What changed: start an AF_UNIX HTTP listener alongside the browser TCP API, expose the archive/schema/build identity in health, and add a storage-free stdlib client probe. Ref polylogue-20d.1. Co-Authored-By: Claude --- docs/plans/topology-target.yaml | 16 ++++-- docs/topology-status.md | 6 +-- polylogue/cli/daemon_client.py | 64 ++++++++++++++++++++++++ polylogue/daemon/cli.py | 19 ++++++- polylogue/daemon/http.py | 12 ++++- polylogue/daemon/uds.py | 75 ++++++++++++++++++++++++++++ tests/unit/cli/test_daemon_client.py | 18 +++++++ 7 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 polylogue/cli/daemon_client.py create mode 100644 polylogue/daemon/uds.py create mode 100644 tests/unit/cli/test_daemon_client.py diff --git a/docs/plans/topology-target.yaml b/docs/plans/topology-target.yaml index 797b915a75..8cc18e1784 100644 --- a/docs/plans/topology-target.yaml +++ b/docs/plans/topology-target.yaml @@ -471,7 +471,7 @@ files: owner: archive-raw-payload reason: archive-domain semantics - path: polylogue/archive/resume_routing.py - loc: 70 + loc: 77 target: polylogue/archive/resume_routing.py owner: stable - path: polylogue/archive/revision_authority.py @@ -865,6 +865,10 @@ files: loc: 82 target: polylogue/cli/convergence_feedback.py owner: stable + - path: polylogue/cli/daemon_client.py + loc: 62 + target: polylogue/cli/daemon_client.py + owner: stable - path: polylogue/cli/help_markdown.py loc: 59 target: polylogue/cli/help_markdown.py @@ -926,7 +930,7 @@ files: target: polylogue/cli/query_stats.py owner: stable - path: polylogue/cli/query_verbs.py - loc: 2718 + loc: 2720 target: polylogue/cli/query_verbs.py owner: stable - path: polylogue/cli/read_view_handlers.py @@ -1291,7 +1295,7 @@ files: target: polylogue/daemon/catchup_status.py owner: stable - path: polylogue/daemon/cli.py - loc: 1919 + loc: 1936 target: polylogue/daemon/cli.py owner: stable - path: polylogue/daemon/compare.py @@ -1367,7 +1371,7 @@ files: target: polylogue/daemon/healthz.py owner: stable - path: polylogue/daemon/http.py - loc: 4545 + loc: 4553 target: polylogue/daemon/http.py owner: stable - path: polylogue/daemon/lifecycle.py @@ -1459,6 +1463,10 @@ files: loc: 236 target: polylogue/daemon/topology_http.py owner: stable + - path: polylogue/daemon/uds.py + loc: 71 + target: polylogue/daemon/uds.py + owner: stable - path: polylogue/daemon/user_state_http.py loc: 636 target: polylogue/daemon/user_state_http.py diff --git a/docs/topology-status.md b/docs/topology-status.md index dccb5d281e..461126eaf0 100644 --- a/docs/topology-status.md +++ b/docs/topology-status.md @@ -28,12 +28,12 @@ Generated by `devtools render topology-status`. Reads `docs/plans/topology-targe ### Summary -- **Stable** (no move scoped): 775 +- **Stable** (no move scoped): 777 - **Kernel** (polylogue/ root): 10 - **Primitives** (storage-root): 18 - **TBD** (cell needs explicit assignment): 6 -- **Total declared**: 928 -- **Realized polylogue/**/*.py**: 928 files declared +- **Total declared**: 930 +- **Realized polylogue/**/*.py**: 930 files declared ### TBD cells (require explicit routing) diff --git a/polylogue/cli/daemon_client.py b/polylogue/cli/daemon_client.py new file mode 100644 index 0000000000..ebc6fea933 --- /dev/null +++ b/polylogue/cli/daemon_client.py @@ -0,0 +1,64 @@ +"""Minimal stdlib UDS client for the hot daemon read path. + +This module deliberately has no archive/storage imports: importing it must be +cheap enough to decide whether the daemon can answer before direct CLI setup. +""" + +from __future__ import annotations + +import http.client +import json +import socket +from pathlib import Path +from typing import Any + + +class _UnixHTTPConnection(http.client.HTTPConnection): + def __init__(self, socket_path: Path, timeout: float) -> None: + super().__init__("localhost", timeout=timeout) + self.socket_path = socket_path + + def connect(self) -> None: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect(str(self.socket_path)) + + +class DaemonClient: + def __init__(self, socket_path: Path, *, timeout_s: float = 0.1) -> None: + self.socket_path = socket_path + self.timeout_s = timeout_s + + def request_json(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, Any] | None: + if not self.socket_path.exists(): + return None + connection = _UnixHTTPConnection(self.socket_path, self.timeout_s) + raw = json.dumps(body, separators=(",", ":")).encode() if body is not None else None + try: + connection.request( + method, path, body=raw, headers={"Host": "127.0.0.1", "Content-Type": "application/json"} + ) + response = connection.getresponse() + if response.status != 200: + return None + payload = json.loads(response.read().decode()) + return payload if isinstance(payload, dict) else None + except (OSError, TimeoutError, ValueError, http.client.HTTPException): + return None + finally: + connection.close() + + def probe(self, *, archive_root: str, index_schema_version: int, daemon_version: str) -> dict[str, Any] | None: + health = self.request_json("GET", "/api/health") + if health is None: + return None + if health.get("archive_root") != archive_root: + return None + if health.get("index_schema_version") != index_schema_version: + return None + if health.get("daemon_version") != daemon_version: + return None + return health + + +__all__ = ["DaemonClient"] diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index fca955e33b..aa1a561c56 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -15,7 +15,7 @@ from datetime import UTC, datetime from http.server import ThreadingHTTPServer from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click @@ -1171,6 +1171,8 @@ async def run_daemon_services( api_server: ThreadingHTTPServer | None = None api_server_task: asyncio.Task[None] | None = None + uds_server: Any | None = None + uds_server_task: asyncio.Task[None] | None = None server: BrowserCaptureHTTPServer | None = None server_task: asyncio.Task[None] | None = None watcher: LiveWatcher | None = None @@ -1226,6 +1228,16 @@ async def run_daemon_services( ) api_server_task = asyncio.create_task(asyncio.to_thread(api_server.serve_forever, 0.5)) tasks.append(api_server_task) + from polylogue.daemon.uds import DaemonAPIUnixHTTPServer, daemon_socket_path + + uds_server = DaemonAPIUnixHTTPServer( + daemon_socket_path(), + DaemonAPIHandler, + auth_token=api_auth_token, + write_bridge=DaemonWriteThreadBridge(write_coordinator, asyncio.get_running_loop()), + ) + uds_server_task = asyncio.create_task(asyncio.to_thread(uds_server.serve_forever, 0.5)) + tasks.append(uds_server_task) if lifecycle_events_enabled: await _emit_daemon_lifecycle_event( "component_started", @@ -1397,6 +1409,8 @@ async def run_daemon_services( await _shutdown_server_if_serving(server, server_task, label="browser-capture") if api_server is not None: await _shutdown_server_if_serving(api_server, api_server_task, label="api") + if uds_server is not None: + await _shutdown_server_if_serving(uds_server, uds_server_task, label="uds") # Cancel all component tasks. for task in tasks: @@ -1455,6 +1469,9 @@ async def run_daemon_services( if api_server is not None: with contextlib.suppress(Exception): api_server.server_close() + if uds_server is not None: + with contextlib.suppress(Exception): + uds_server.server_close() if cleanup_task is not None: for _ in range(cleanup_cancel_requests): cleanup_task.cancel() diff --git a/polylogue/daemon/http.py b/polylogue/daemon/http.py index 0e2e5505bf..569b06a2d7 100644 --- a/polylogue/daemon/http.py +++ b/polylogue/daemon/http.py @@ -1176,8 +1176,8 @@ def _web_credentials(self) -> WebCredentialRegistry: @property def _client_host(self) -> str: """Extract client IP from the request.""" - # The client_address is (host, port) from the underlying socket. - return str(self.client_address[0]) + client_address: object = self.client_address + return str(client_address[0]) if isinstance(client_address, tuple) else "127.0.0.1" def _web_credential_token(self) -> str | None: return read_web_credential_cookie(self.headers.get("Cookie", "")) @@ -2091,7 +2091,10 @@ def _handle_health_check(self) -> None: ) def _handle_health(self) -> None: + from polylogue.config import load_polylogue_config from polylogue.paths import active_index_db_path + from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION + from polylogue.version import POLYLOGUE_VERSION, VERSION_INFO dbp = active_index_db_path() db_size = dbp.stat().st_size if dbp.exists() else 0 @@ -2124,6 +2127,11 @@ def _handle_health(self) -> None: "blob_dir_size_bytes": 0, "quick_check": "pass" if quick_check_ok else "error", "quick_check_age_s": None, + "archive_root": str(load_polylogue_config().archive_root), + "index_schema_version": INDEX_SCHEMA_VERSION, + "daemon_version": POLYLOGUE_VERSION, + "commit": VERSION_INFO.commit, + "started_at": getattr(self.server, "started_at", None), } self._send_json(HTTPStatus.OK, overview) diff --git a/polylogue/daemon/uds.py b/polylogue/daemon/uds.py new file mode 100644 index 0000000000..46439d1cfe --- /dev/null +++ b/polylogue/daemon/uds.py @@ -0,0 +1,75 @@ +"""Unix-domain transport for the daemon's existing HTTP handler.""" + +from __future__ import annotations + +import os +import socketserver +import threading +from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from typing import Any + +from polylogue.daemon.http import ( + _ARCHIVE_QUERY_MAX_QUEUED, + _ARCHIVE_QUERY_MAX_WORKERS, + _StandaloneWriteRuntime, +) +from polylogue.daemon.web_auth import WebCredentialRegistry +from polylogue.daemon.write_coordinator import DaemonWriteThreadBridge + + +def daemon_socket_path(runtime_dir: str | None = None) -> Path: + """Return the per-user UDS path without creating it.""" + + return Path(runtime_dir or os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock" + + +class DaemonAPIUnixHTTPServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + """AF_UNIX peer for :class:`DaemonAPIHTTPServer`; routing stays identical.""" + + daemon_threads = True + + def __init__( + self, + socket_path: Path, + handler_class: type[BaseHTTPRequestHandler], + *, + auth_token: str | None = None, + write_bridge: DaemonWriteThreadBridge | None = None, + ) -> None: + socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with __import__("contextlib").suppress(FileNotFoundError): + socket_path.unlink() + super().__init__(str(socket_path), handler_class) + self.socket_path = socket_path + self.auth_token = auth_token + self.api_host = "127.0.0.1" + self.web_credentials = WebCredentialRegistry() + self._owned_write_runtime: _StandaloneWriteRuntime | None = None + if write_bridge is None: + self._owned_write_runtime = _StandaloneWriteRuntime() + write_bridge = self._owned_write_runtime.bridge + self.write_bridge = write_bridge + self.archive_query_executor = ThreadPoolExecutor( + max_workers=_ARCHIVE_QUERY_MAX_WORKERS, thread_name_prefix="archive-query" + ) + self.archive_query_admission = threading.BoundedSemaphore( + _ARCHIVE_QUERY_MAX_WORKERS + _ARCHIVE_QUERY_MAX_QUEUED + ) + self.coordination_cache: dict[tuple[str, int], Any] = {} + self.coordination_cache_lock = threading.Lock() + self.coordination_cache_condition = threading.Condition(self.coordination_cache_lock) + self.coordination_cache_building: set[tuple[str, int]] = set() + + def server_close(self) -> None: + self.archive_query_executor.shutdown(wait=False, cancel_futures=True) + if self._owned_write_runtime is not None: + self._owned_write_runtime.close() + self._owned_write_runtime = None + super().server_close() + with __import__("contextlib").suppress(FileNotFoundError): + self.socket_path.unlink() + + +__all__ = ["DaemonAPIUnixHTTPServer", "daemon_socket_path"] diff --git a/tests/unit/cli/test_daemon_client.py b/tests/unit/cli/test_daemon_client.py new file mode 100644 index 0000000000..18b973c1c0 --- /dev/null +++ b/tests/unit/cli/test_daemon_client.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import subprocess +import sys + + +def test_daemon_client_import_does_not_load_storage() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import polylogue.cli.daemon_client; assert 'polylogue.storage' not in sys.modules", + ], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr From a28012b708e0dca558ba2c01ce7b3049858ed61c Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 02:55:59 +0200 Subject: [PATCH 03/11] perf(cli): use daemon UDS for session pages Route eligible session listing and search through the authenticated local UDS after a strict archive/schema/version probe. Any unavailable or mismatched daemon falls back directly, without the former fork-per-request TCP path. Ref polylogue-20d.1. Co-Authored-By: Claude --- polylogue/cli/archive_query.py | 40 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index ed826539fb..d7646478bc 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -6,6 +6,7 @@ import io import json import multiprocessing +import os import re import webbrowser from collections.abc import Callable, Iterable, Mapping, Sequence @@ -1058,22 +1059,33 @@ def _daemon_session_query_params( def _fetch_daemon_sessions_payload(config: Config, query_params: Mapping[str, object]) -> dict[str, object] | None: - daemon_url_value = getattr(config, "daemon_url", "http://127.0.0.1:8766") - if not isinstance(daemon_url_value, str) or not daemon_url_value.startswith(("http://", "https://")): + if os.environ.get("POLYLOGUE_NO_DAEMON", "").lower() in {"1", "true", "yes", "on", "off"}: return None - daemon_url = daemon_url_value.rstrip("/") - auth_token = getattr(config, "api_auth_token", None) - auth_header = auth_token if isinstance(auth_token, str) and auth_token else None - expected_archive_root = archive_file_set_root_for_paths( - archive_root_path=config.archive_root, - db_anchor=config.db_path, - ) - return _fetch_daemon_sessions_payload_with_deadline( - daemon_url, - auth_header, - dict(query_params), - expected_archive_root=expected_archive_root, + from polylogue.cli.daemon_client import DaemonClient + from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION + from polylogue.version import POLYLOGUE_VERSION + + archive_root = archive_file_set_root_for_paths(archive_root_path=config.archive_root, db_anchor=config.db_path) + socket_path = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock" + client = DaemonClient(socket_path) + if ( + client.probe( + archive_root=str(config.archive_root), + index_schema_version=INDEX_SCHEMA_VERSION, + daemon_version=POLYLOGUE_VERSION, + ) + is None + ): + return None + payload = client.request_json( + "GET", "/api/sessions?" + urlencode(tuple(_daemon_query_pairs(query_params)), doseq=True) ) + if payload is not None: + return payload + # Keep the direct archive path as the safe degraded-mode fallback. Do not + # fall back to the legacy TCP/fork shortcut: it defeats the hot-process goal. + _ = archive_root + return None def _fetch_daemon_sessions_payload_with_deadline( From 3bc58d0fe7f8e1b004675e93c9c2247760e6669c Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:06:53 +0200 Subject: [PATCH 04/11] feat(daemon): accept root requests over UDS Problem: the UDS session-page shortcut still reconstructed a limited GET\nquery and offered no explicit client opt-out.\n\nWhat changed: add a read-only POST /api/cli/query envelope that receives\nthe canonical root-request parameter map and reuses daemon query lowering.\nAdd --no-daemon, POLYLOGUE_NO_DAEMON, and POLYLOGUE_DAEMON=off\ndegraded-mode escapes, plus daemon start identity timestamps.\n\nRef polylogue-20d.1 --- polylogue/cli/archive_query.py | 25 +++++++-- polylogue/cli/click_app.py | 1 + polylogue/cli/click_option_groups.py | 6 ++ polylogue/cli/daemon_client.py | 9 +++ polylogue/daemon/http.py | 56 +++++++++++++++++++ polylogue/daemon/route_contracts.py | 9 +++ polylogue/daemon/uds.py | 2 + tests/unit/cli/test_daemon_client.py | 23 ++++++++ .../unit/daemon/test_daemon_http_contracts.py | 28 ++++++++++ 9 files changed, 153 insertions(+), 6 deletions(-) diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index d7646478bc..a46583b7b3 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -926,7 +926,9 @@ def _try_emit_daemon_session_page( ): return False daemon_params = _daemon_session_query_params(request, params, limit=limit, offset=offset) - payload = _fetch_daemon_sessions_payload(config, daemon_params) + daemon_request_params = request.query_params() + daemon_request_params.update({key: value for key, value in daemon_params.items() if key != "query"}) + payload = _fetch_daemon_sessions_payload(config, daemon_request_params, disabled=bool(params.get("no_daemon"))) if payload is None: return False if isinstance(payload.get("hits"), list): @@ -1058,8 +1060,21 @@ def _daemon_session_query_params( return query_params -def _fetch_daemon_sessions_payload(config: Config, query_params: Mapping[str, object]) -> dict[str, object] | None: - if os.environ.get("POLYLOGUE_NO_DAEMON", "").lower() in {"1", "true", "yes", "on", "off"}: +def _daemon_disabled(*, flag: bool = False) -> bool: + if flag: + return True + if os.environ.get("POLYLOGUE_NO_DAEMON", "").lower() in {"1", "true", "yes", "on"}: + return True + return os.environ.get("POLYLOGUE_DAEMON", "").lower() == "off" + + +def _fetch_daemon_sessions_payload( + config: Config, + query_params: Mapping[str, object], + *, + disabled: bool = False, +) -> dict[str, object] | None: + if _daemon_disabled(flag=disabled): return None from polylogue.cli.daemon_client import DaemonClient from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION @@ -1077,9 +1092,7 @@ def _fetch_daemon_sessions_payload(config: Config, query_params: Mapping[str, ob is None ): return None - payload = client.request_json( - "GET", "/api/sessions?" + urlencode(tuple(_daemon_query_pairs(query_params)), doseq=True) - ) + payload = client.cli_query(dict(query_params)) if payload is not None: return payload # Keep the direct archive path as the safe degraded-mode fallback. Do not diff --git a/polylogue/cli/click_app.py b/polylogue/cli/click_app.py index 2a19c24438..1188237122 100644 --- a/polylogue/cli/click_app.py +++ b/polylogue/cli/click_app.py @@ -314,6 +314,7 @@ def cli( add_tag: tuple[str, ...], # Global plain: bool, + no_daemon: bool, verbose: bool, diagnose: bool, ) -> None: diff --git a/polylogue/cli/click_option_groups.py b/polylogue/cli/click_option_groups.py index df5b84eb5d..3a60689ad1 100644 --- a/polylogue/cli/click_option_groups.py +++ b/polylogue/cli/click_option_groups.py @@ -325,6 +325,12 @@ def _validate_origin_tokens( GLOBAL_OPTION_DECORATORS: tuple[Callable[[ClickCallable], ClickCallable], ...] = ( click.option("--plain", is_flag=True, help="Force non-interactive plain output"), + click.option( + "--no-daemon", + is_flag=True, + default=False, + help="Run archive reads in-process instead of using the local daemon.", + ), click.option("-v", "--verbose", is_flag=True, help="Verbose output"), click.option( "--diagnose", diff --git a/polylogue/cli/daemon_client.py b/polylogue/cli/daemon_client.py index ebc6fea933..c44e05c80d 100644 --- a/polylogue/cli/daemon_client.py +++ b/polylogue/cli/daemon_client.py @@ -10,6 +10,7 @@ import json import socket from pathlib import Path +from time import perf_counter from typing import Any @@ -28,12 +29,14 @@ class DaemonClient: def __init__(self, socket_path: Path, *, timeout_s: float = 0.1) -> None: self.socket_path = socket_path self.timeout_s = timeout_s + self.last_elapsed_ms: int | None = None def request_json(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, Any] | None: if not self.socket_path.exists(): return None connection = _UnixHTTPConnection(self.socket_path, self.timeout_s) raw = json.dumps(body, separators=(",", ":")).encode() if body is not None else None + started_at = perf_counter() try: connection.request( method, path, body=raw, headers={"Host": "127.0.0.1", "Content-Type": "application/json"} @@ -42,12 +45,18 @@ def request_json(self, method: str, path: str, body: dict[str, object] | None = if response.status != 200: return None payload = json.loads(response.read().decode()) + self.last_elapsed_ms = round((perf_counter() - started_at) * 1000) return payload if isinstance(payload, dict) else None except (OSError, TimeoutError, ValueError, http.client.HTTPException): return None finally: connection.close() + def cli_query(self, params: dict[str, object]) -> dict[str, Any] | None: + """Run one root-request parameter dictionary through the daemon.""" + + return self.request_json("POST", "/api/cli/query", {"params": params}) + def probe(self, *, archive_root: str, index_schema_version: int, daemon_version: str) -> dict[str, Any] | None: health = self.request_json("GET", "/api/health") if health is None: diff --git a/polylogue/daemon/http.py b/polylogue/daemon/http.py index 569b06a2d7..2371221e49 100644 --- a/polylogue/daemon/http.py +++ b/polylogue/daemon/http.py @@ -401,6 +401,12 @@ def _authenticated_post_routes() -> tuple[_StaticPostRoute, ...]: ) +def _cli_read_post_routes() -> tuple[_StaticPostRoute, ...]: + """Read-only POST routes whose request bodies carry CLI parameter maps.""" + + return (_StaticPostRoute("/api/cli/query", ("api", "cli", "query"), "_handle_cli_query"),) + + def implemented_daemon_route_patterns() -> tuple[tuple[RouteMethod, str], ...]: """Return route patterns implemented by daemon HTTP dispatch.""" @@ -420,6 +426,7 @@ def implemented_daemon_route_patterns() -> tuple[tuple[RouteMethod, str], ...]: routes.extend(("GET", route.pattern) for route in _parameterized_get_routes()) routes.extend(("POST", route.pattern) for route in _observability_post_routes()) routes.extend(("POST", route.pattern) for route in _authenticated_post_routes()) + routes.extend(("POST", route.pattern) for route in _cli_read_post_routes()) routes.extend(user_state_http.user_state_route_patterns()) return tuple(routes) @@ -1725,6 +1732,13 @@ def _do_post_impl(self) -> None: else: handler() return + cli_read_route = next((route for route in _cli_read_post_routes() if tuple(path) == route.segments), None) + if cli_read_route is not None: + if not self._check_auth("read"): + return + handler = cast(Callable[..., None], getattr(self, cli_read_route.handler_name)) + handler() + return if not self._check_auth("user_state"): return if not self._check_cross_origin(): @@ -4108,6 +4122,47 @@ def _handle_sources(self) -> None: # Handlers: reset # ------------------------------------------------------------------ + @daemon_safe_handler + def _handle_cli_query(self) -> None: + """Serve a root-request parameter map through the daemon query compiler. + + The CLI sends exactly :meth:`RootModeRequest.query_params` rather than + reconstructing a query string itself. This preserves the daemon as + the owner of structured-query lowering while keeping the transport + payload deliberately small and stdlib-friendly. + """ + + content_length = int(self.headers.get("Content-Length", 0)) + if content_length <= 0 or content_length > 65_536: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") + return + try: + body = json.loads(self.rfile.read(content_length)) + raw_params = body["params"] + if not isinstance(raw_params, dict): + raise TypeError("params must be an object") + from polylogue.cli.root_request import RootModeRequest, _expression_from_query_terms + + request = RootModeRequest.from_params(raw_params) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") + return + + params: dict[str, list[str]] = {} + for key, value in request.params.items(): + if value is None or value is False: + continue + if isinstance(value, tuple | list): + params[str(key)] = [str(item) for item in value] + elif value is True: + params[str(key)] = ["1"] + else: + params[str(key)] = [str(value)] + expression = _expression_from_query_terms(request.query_terms) + if expression: + params["query"] = [expression] + self._handle_list_sessions(params) + @daemon_safe_handler def _handle_reset(self) -> None: content_length = int(self.headers.get("Content-Length", 0)) @@ -4476,6 +4531,7 @@ def __init__( super().__init__(server_address, handler_class) self.auth_token = auth_token self.api_host = api_host + self.started_at = datetime.now(UTC).isoformat() self.web_credentials = web_credentials or WebCredentialRegistry() self._owned_write_runtime: _StandaloneWriteRuntime | None = None if write_bridge is None: diff --git a/polylogue/daemon/route_contracts.py b/polylogue/daemon/route_contracts.py index 0e5a8c923e..7dc2803e0d 100644 --- a/polylogue/daemon/route_contracts.py +++ b/polylogue/daemon/route_contracts.py @@ -201,6 +201,15 @@ class RouteContract: "credential_if_configured", "SearchEnvelope / SessionListResponse with route_state", ), + RouteContract( + "POST", + "/api/cli/query", + "read_query", + "private", + "credential_if_configured", + "SearchEnvelope / SessionListResponse with route_state", + "Local UDS-only root-request parameter envelope; daemon owns query compilation.", + ), RouteContract( "GET", "/api/facets", diff --git a/polylogue/daemon/uds.py b/polylogue/daemon/uds.py index 46439d1cfe..38987445b3 100644 --- a/polylogue/daemon/uds.py +++ b/polylogue/daemon/uds.py @@ -6,6 +6,7 @@ import socketserver import threading from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime from http.server import BaseHTTPRequestHandler from pathlib import Path from typing import Any @@ -45,6 +46,7 @@ def __init__( self.socket_path = socket_path self.auth_token = auth_token self.api_host = "127.0.0.1" + self.started_at = datetime.now(UTC).isoformat() self.web_credentials = WebCredentialRegistry() self._owned_write_runtime: _StandaloneWriteRuntime | None = None if write_bridge is None: diff --git a/tests/unit/cli/test_daemon_client.py b/tests/unit/cli/test_daemon_client.py index 18b973c1c0..f48a7a772a 100644 --- a/tests/unit/cli/test_daemon_client.py +++ b/tests/unit/cli/test_daemon_client.py @@ -3,6 +3,8 @@ import subprocess import sys +import pytest + def test_daemon_client_import_does_not_load_storage() -> None: result = subprocess.run( @@ -16,3 +18,24 @@ def test_daemon_client_import_does_not_load_storage() -> None: text=True, ) assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("environment", "expected"), + [ + ({"POLYLOGUE_NO_DAEMON": "1"}, True), + ({"POLYLOGUE_NO_DAEMON": "off"}, False), + ({"POLYLOGUE_DAEMON": "off"}, True), + ], +) +def test_daemon_escape_environment_is_explicit( + monkeypatch: pytest.MonkeyPatch, environment: dict[str, str], expected: bool +) -> None: + from polylogue.cli.archive_query import _daemon_disabled + + monkeypatch.delenv("POLYLOGUE_NO_DAEMON", raising=False) + monkeypatch.delenv("POLYLOGUE_DAEMON", raising=False) + for key, value in environment.items(): + monkeypatch.setenv(key, value) + + assert _daemon_disabled() is expected diff --git a/tests/unit/daemon/test_daemon_http_contracts.py b/tests/unit/daemon/test_daemon_http_contracts.py index 34f2fa3919..c860cfb150 100644 --- a/tests/unit/daemon/test_daemon_http_contracts.py +++ b/tests/unit/daemon/test_daemon_http_contracts.py @@ -185,6 +185,34 @@ def _archive_state_hash(archive_root: Path) -> str: return h.hexdigest() +def test_cli_query_post_forwards_root_request_to_daemon_compiler() -> None: + """The UDS-only envelope carries raw root flags, not a client-built SQL query.""" + + body = json.dumps( + { + "params": { + "query": ["repo:polylogue", "sqlite locking"], + "origin": "codex-session", + "limit": 3, + } + } + ).encode() + handler = _make_handler("POST", "/api/cli/query", body=body) + observed: dict[str, dict[str, list[str]]] = {} + + def capture(params: dict[str, list[str]]) -> None: + observed["params"] = params + + handler._handle_list_sessions = capture # type: ignore[method-assign] + handler._handle_cli_query() + + assert observed["params"] == { + "origin": ["codex-session"], + "limit": ["3"], + "query": ['repo:polylogue "sqlite locking"'], + } + + # --------------------------------------------------------------------------- # 1. Status envelope contracts # --------------------------------------------------------------------------- From 8244e09e711411d3ca4358722505a7660622497f Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:10:08 +0200 Subject: [PATCH 05/11] feat(cli): show recent sessions on bare TTY Problem: an interactive bare invocation stopped at status, leaving the\noperator to remember the next query before seeing where work ended.\n\nWhat changed: on a TTY, render archive readiness, five existing selector\nrows, and the next find/read/continue commands. The page prefers a\nconfig-matched UDS daemon and falls back to one direct summary query; no\narchive keeps Click help. Machine-mode and strict bare-word dispatch are\nunchanged.\n\nRef polylogue-jnj.13\nRef polylogue-20d.1 --- docs/cli-reference.md | 2 + docs/openapi/search.yaml | 7 +++ polylogue/cli/click_app.py | 86 ++++++++++++++++++++++++++++++ tests/unit/cli/test_bare_triage.py | 27 ++++++++++ 4 files changed, 122 insertions(+) create mode 100644 tests/unit/cli/test_bare_triage.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2818e4d43d..7cae19e412 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -141,6 +141,8 @@ Options: --set TEXT... Set metadata key value --add-tag TEXT Add tags (comma-separated) --plain Force non-interactive plain output + --no-daemon Run archive reads in-process instead of + using the local daemon. -v, --verbose Verbose output --diagnose Explain CLI parser decisions on stderr before running. Useful when query-first diff --git a/docs/openapi/search.yaml b/docs/openapi/search.yaml index 811a6b9637..7d8c985042 100644 --- a/docs/openapi/search.yaml +++ b/docs/openapi/search.yaml @@ -3080,6 +3080,13 @@ x-polylogue-route-contracts: stability: stable auth_policy: credential_if_configured response_contract: SearchEnvelope / SessionListResponse with route_state +- method: POST + pattern: /api/cli/query + kind: read_query + stability: private + auth_policy: credential_if_configured + response_contract: SearchEnvelope / SessionListResponse with route_state + notes: Local UDS-only root-request parameter envelope; daemon owns query compilation. - method: GET pattern: /api/facets kind: read_query diff --git a/polylogue/cli/click_app.py b/polylogue/cli/click_app.py index 1188237122..8f05225883 100644 --- a/polylogue/cli/click_app.py +++ b/polylogue/cli/click_app.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import sys from time import perf_counter from typing import TYPE_CHECKING @@ -30,6 +31,8 @@ from polylogue.version import POLYLOGUE_VERSION if TYPE_CHECKING: + from polylogue.cli.select import SelectSessionRow + from polylogue.config import Config from polylogue.ui import UI @@ -208,11 +211,94 @@ def _should_complete_then_connector(incomplete: str) -> bool: def _handle_query_mode(ctx: click.Context) -> None: """Handle query mode: display stats or perform search.""" + if ( + not ctx.meta.get("polylogue_query_terms") + and sys.stdin.isatty() + and sys.stdout.isatty() + and _show_bare_tty_triage(ctx, ctx.obj) + ): + return from polylogue.cli.query import handle_query_mode handle_query_mode(ctx, show_stats=_show_stats) +def _show_bare_tty_triage(ctx: click.Context, env: AppEnv) -> bool: + """Render the interactive no-argument landing surface. + + Returns ``False`` only when no archive exists, letting the caller render + ordinary Click help instead of presenting an empty archive as usable. + """ + + from polylogue.api.sync.bridge import run_coroutine_sync + from polylogue.cli.root_request import RootModeRequest + from polylogue.cli.select import select_session_rows + from polylogue.cli.shared.helpers import load_effective_config + from polylogue.paths import archive_file_set_root_for_paths + + config = load_effective_config(env) + archive_root = archive_file_set_root_for_paths(archive_root_path=config.archive_root, db_anchor=config.db_path) + if not (archive_root / "index.db").exists(): + click.echo(ctx.get_help()) + return True + + rows = _bare_tty_daemon_rows(config) + source = "daemon" if rows is not None else "direct" + if rows is None: + try: + rows = run_coroutine_sync(select_session_rows(env, RootModeRequest.from_params({}), limit=5)) + except Exception: + click.echo(ctx.get_help()) + return True + + click.echo(f"Archive: ready ({source})") + click.echo("Recent sessions:") + if rows: + for row in rows: + click.echo(f" {row.label}") + else: + click.echo(" No sessions yet.") + click.echo("Next: polylogue find … | polylogue read | polylogue continue ") + return True + + +def _bare_tty_daemon_rows(config: Config) -> list[SelectSessionRow] | None: + """Fetch the minimal recent-session page from a config-matched daemon.""" + + from pathlib import Path + + from polylogue.cli.daemon_client import DaemonClient + from polylogue.cli.select import SelectSessionRow + from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION + from polylogue.version import POLYLOGUE_VERSION + + client = DaemonClient(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock") + if ( + client.probe( + archive_root=str(config.archive_root), + index_schema_version=INDEX_SCHEMA_VERSION, + daemon_version=POLYLOGUE_VERSION, + ) + is None + ): + return None + payload = client.cli_query({"query": (), "limit": 5}) + items = payload.get("items") if payload is not None else None + if not isinstance(items, list): + return None + rows: list[SelectSessionRow] = [] + for item in items: + if not isinstance(item, dict): + continue + session_id = item.get("id") + origin = item.get("origin") + title = item.get("title") + if isinstance(session_id, str) and isinstance(origin, str) and isinstance(title, str): + date = item.get("date") + rows.append(SelectSessionRow(session_id, origin, title, date if isinstance(date, str) else None)) + return rows + + def _show_stats(env: AppEnv, *, verbose: bool = False) -> None: """Show fast status when daemon is reachable, otherwise archive summary.""" if not verbose: diff --git a/tests/unit/cli/test_bare_triage.py b/tests/unit/cli/test_bare_triage.py new file mode 100644 index 0000000000..7b5208c0c3 --- /dev/null +++ b/tests/unit/cli/test_bare_triage.py @@ -0,0 +1,27 @@ +"""TTY-only bare invocation triage contracts.""" + +from __future__ import annotations + +from pathlib import Path + +import click +import pytest + +from polylogue.cli.click_app import _show_bare_tty_triage, cli +from polylogue.cli.shared.types import AppEnv +from polylogue.config import Config + + +def test_bare_tty_triage_falls_back_to_click_help_without_archive( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """No archive is not presented as an empty, resumable archive.""" + + config = Config(archive_root=tmp_path / "archive", render_root=tmp_path / "render", sources=[]) + monkeypatch.setattr("polylogue.cli.shared.helpers.load_effective_config", lambda _env: config) + context = click.Context(cli, info_name="polylogue") + assert _show_bare_tty_triage(context, AppEnv(plain=True)) + + output = capsys.readouterr().out + assert "Usage: polylogue" in output + assert "Recent sessions:" not in output From e5f98d872bb92c4f022e8872efabf4210dcf8a9e Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:14:18 +0200 Subject: [PATCH 06/11] perf(cli): proxy facets and query units over UDS Problem: the hot daemon path covered ordinary session pages but facets and\nterminal query-unit reads still opened the archive in every CLI process.\n\nWhat changed: reuse daemon facet and query-unit contracts after the same\nUDS identity probe, then render their payloads through the existing CLI\nrenderers. Mutations, streaming, and no-IDF facets stay direct.\n\nRef polylogue-20d.1 --- polylogue/cli/archive_query.py | 80 ++++++++++++++++++++++++++++---- polylogue/cli/commands/facets.py | 66 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index a46583b7b3..91e1f282d0 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -320,6 +320,22 @@ def _execute_archive_query_stdout(env: AppEnv, request: RootModeRequest) -> None retrieval_lane=retrieval_lane, ): return + if _try_emit_daemon_unit_page( + config=config, + request=request, + params=params, + source=unit_source, + expression=unit_source_query, + limit=limit, + offset=page_offset, + output_format=output_format, + fields=fields, + stream=stream, + tags_to_add=tags_to_add, + metadata_to_set=metadata_to_set, + delete_matched=delete_matched, + ): + return if not index_db_path.exists(): if _emit_missing_archive_empty_read( params, @@ -925,10 +941,10 @@ def _try_emit_daemon_session_page( retrieval_lane=retrieval_lane, ): return False + if bool(params.get("no_daemon")): + return False daemon_params = _daemon_session_query_params(request, params, limit=limit, offset=offset) - daemon_request_params = request.query_params() - daemon_request_params.update({key: value for key, value in daemon_params.items() if key != "query"}) - payload = _fetch_daemon_sessions_payload(config, daemon_request_params, disabled=bool(params.get("no_daemon"))) + payload = _fetch_daemon_sessions_payload(config, daemon_params) if payload is None: return False if isinstance(payload.get("hits"), list): @@ -956,6 +972,48 @@ def _try_emit_daemon_session_page( return False +def _try_emit_daemon_unit_page( + *, + config: Config, + request: RootModeRequest, + params: dict[str, object], + source: QueryUnitSource | None, + expression: str, + limit: int, + offset: int, + output_format: str, + fields: str | None, + stream: bool, + tags_to_add: tuple[str, ...], + metadata_to_set: tuple[tuple[str, str], ...], + delete_matched: bool, +) -> bool: + """Render daemon query-unit envelopes with the existing CLI renderer.""" + + if source is None or stream or tags_to_add or metadata_to_set or delete_matched: + return False + daemon_params = _daemon_session_query_params(request, params, limit=limit, offset=offset) + daemon_params["expression"] = expression + payload = _fetch_daemon_payload( + config, + "/api/query-units?" + urlencode(tuple(_daemon_query_pairs(daemon_params)), doseq=True), + disabled=bool(params.get("no_daemon")), + ) + if payload is None: + return False + raw_items = payload.get("items") + if not isinstance(raw_items, list): + return False + items = [item for item in raw_items if isinstance(item, dict)] + if not items: + _emit_unit_no_results(payload, unit=source.unit, output_format=output_format) + text_line = ( + _aggregate_query_line if payload.get("mode") == "query-unit-aggregate" else _query_unit_text_line(source.unit) + ) + _emit_rows(payload, items, output_format=output_format, text_line=text_line, fields=fields) + return True + + def _resolve_single_query_ref(archive: ArchiveStore, query: str) -> str | None: """Resolve a singleton query token as a session ref before FTS fallback.""" if not _single_query_token_looks_like_ref(query): @@ -1073,6 +1131,16 @@ def _fetch_daemon_sessions_payload( query_params: Mapping[str, object], *, disabled: bool = False, +) -> dict[str, object] | None: + return _fetch_daemon_payload(config, "/api/cli/query", body={"params": dict(query_params)}, disabled=disabled) + + +def _fetch_daemon_payload( + config: Config, + path: str, + *, + body: dict[str, object] | None = None, + disabled: bool = False, ) -> dict[str, object] | None: if _daemon_disabled(flag=disabled): return None @@ -1080,7 +1148,6 @@ def _fetch_daemon_sessions_payload( from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION from polylogue.version import POLYLOGUE_VERSION - archive_root = archive_file_set_root_for_paths(archive_root_path=config.archive_root, db_anchor=config.db_path) socket_path = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock" client = DaemonClient(socket_path) if ( @@ -1092,12 +1159,9 @@ def _fetch_daemon_sessions_payload( is None ): return None - payload = client.cli_query(dict(query_params)) + payload = client.request_json("POST" if body is not None else "GET", path, body) if payload is not None: return payload - # Keep the direct archive path as the safe degraded-mode fallback. Do not - # fall back to the legacy TCP/fork shortcut: it defeats the hot-process goal. - _ = archive_root return None diff --git a/polylogue/cli/commands/facets.py b/polylogue/cli/commands/facets.py index 11bc4b5ad1..6cc7ba1ea0 100644 --- a/polylogue/cli/commands/facets.py +++ b/polylogue/cli/commands/facets.py @@ -2,6 +2,11 @@ from __future__ import annotations +import os +from pathlib import Path +from typing import TYPE_CHECKING +from urllib.parse import urlencode + import click from polylogue.api.sync.bridge import run_coroutine_sync @@ -9,6 +14,9 @@ from polylogue.cli.root_request import RootModeRequest from polylogue.cli.shared.types import AppEnv +if TYPE_CHECKING: + from polylogue.surfaces.payloads import FacetsResponse + @click.command("facets") @click.option( @@ -53,6 +61,17 @@ def facets_command( env = ctx.obj if isinstance(ctx.obj, AppEnv) else AppEnv() + daemon_response = _fetch_daemon_facets( + env, + query_text=query_text, + origin=origin, + include_deferred=include_deferred, + no_idf=no_idf, + ) + if daemon_response is not None: + emit_facets_response(daemon_response, output_format="json" if json_output else output_format) + return + spec = RootModeRequest.from_params( { "query": (query_text,) if query_text else (), @@ -61,3 +80,50 @@ def facets_command( ).query_spec() response = run_coroutine_sync(env.polylogue.facets(spec, include_idf=not no_idf, include_deferred=include_deferred)) emit_facets_response(response, output_format="json" if json_output else output_format) + + +def _fetch_daemon_facets( + env: AppEnv, + *, + query_text: str | None, + origin: str | None, + include_deferred: bool, + no_idf: bool, +) -> FacetsResponse | None: + """Use the config-matched UDS daemon for read-only facets when lossless.""" + + if no_idf or os.environ.get("POLYLOGUE_NO_DAEMON", "").lower() in {"1", "true", "yes", "on"}: + return None + if os.environ.get("POLYLOGUE_DAEMON", "").lower() == "off": + return None + from polylogue.cli.daemon_client import DaemonClient + from polylogue.cli.shared.helpers import load_effective_config + from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION + from polylogue.surfaces.payloads import FacetsResponse + from polylogue.version import POLYLOGUE_VERSION + + config = load_effective_config(env) + client = DaemonClient(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock") + if ( + client.probe( + archive_root=str(config.archive_root), + index_schema_version=INDEX_SCHEMA_VERSION, + daemon_version=POLYLOGUE_VERSION, + ) + is None + ): + return None + params: dict[str, str] = {} + if query_text: + params["query"] = query_text + if origin: + params["origin"] = origin + if include_deferred: + params["include_expensive"] = "1" + payload = client.request_json("GET", "/api/facets?" + urlencode(params)) + if payload is None: + return None + try: + return FacetsResponse.model_validate(payload) + except ValueError: + return None From 1ca0b5f2d417c315356110ab61a3425b64100415 Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:15:05 +0200 Subject: [PATCH 07/11] test(cli): cover daemon archive identity mismatch The UDS socket alone is not archive identity. Pin the /tmp resolved-config\ntrap so a daemon for another archive silently falls back before any read.\n\nRef polylogue-20d.1 --- tests/unit/cli/test_daemon_client.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/cli/test_daemon_client.py b/tests/unit/cli/test_daemon_client.py index f48a7a772a..836fd958f4 100644 --- a/tests/unit/cli/test_daemon_client.py +++ b/tests/unit/cli/test_daemon_client.py @@ -2,6 +2,7 @@ import subprocess import sys +from pathlib import Path import pytest @@ -39,3 +40,29 @@ def test_daemon_escape_environment_is_explicit( monkeypatch.setenv(key, value) assert _daemon_disabled() is expected + + +def test_daemon_probe_rejects_the_tmp_archive_config_trap(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A socket from a different resolved archive must never answer this CLI.""" + + from polylogue.cli.daemon_client import DaemonClient + + client = DaemonClient(tmp_path / "daemon.sock") + monkeypatch.setattr( + client, + "request_json", + lambda _method, _path: { + "archive_root": "/tmp", + "index_schema_version": 24, + "daemon_version": "0.1.0", + }, + ) + + assert ( + client.probe( + archive_root="/realm/archive", + index_schema_version=24, + daemon_version="0.1.0", + ) + is None + ) From c6c51fa97c8f7cd80cfd4e936e8cab88bc256c09 Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:18:29 +0200 Subject: [PATCH 08/11] test(daemon): exercise the UDS health probe Run the stdlib client against the production AF_UNIX server and make partial\nserver construction safe to close after a bind failure.\n\nRef polylogue-20d.1 --- polylogue/daemon/uds.py | 4 ++- tests/unit/cli/test_daemon_client.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/polylogue/daemon/uds.py b/polylogue/daemon/uds.py index 38987445b3..f415fe8285 100644 --- a/polylogue/daemon/uds.py +++ b/polylogue/daemon/uds.py @@ -65,7 +65,9 @@ def __init__( self.coordination_cache_building: set[tuple[str, int]] = set() def server_close(self) -> None: - self.archive_query_executor.shutdown(wait=False, cancel_futures=True) + executor = getattr(self, "archive_query_executor", None) + if executor is not None: + executor.shutdown(wait=False, cancel_futures=True) if self._owned_write_runtime is not None: self._owned_write_runtime.close() self._owned_write_runtime = None diff --git a/tests/unit/cli/test_daemon_client.py b/tests/unit/cli/test_daemon_client.py index 836fd958f4..32928dd905 100644 --- a/tests/unit/cli/test_daemon_client.py +++ b/tests/unit/cli/test_daemon_client.py @@ -2,6 +2,8 @@ import subprocess import sys +import threading +from os import getpid from pathlib import Path import pytest @@ -66,3 +68,45 @@ def test_daemon_probe_rejects_the_tmp_archive_config_trap(monkeypatch: pytest.Mo ) is None ) + + +def test_daemon_client_probes_the_production_uds_server(monkeypatch: pytest.MonkeyPatch) -> None: + """The stdlib client reaches the production AF_UNIX server, not a TCP substitute.""" + + from http import HTTPStatus + + from polylogue.cli.daemon_client import DaemonClient + from polylogue.daemon.http import DaemonAPIHandler + from polylogue.daemon.uds import DaemonAPIUnixHTTPServer + + def health(self: DaemonAPIHandler) -> None: + self._send_json( + HTTPStatus.OK, + { + "archive_root": "/realm/archive", + "index_schema_version": 24, + "daemon_version": "0.1.0", + "commit": "test", + "started_at": "2026-07-13T00:00:00+00:00", + }, + ) + + monkeypatch.setattr(DaemonAPIHandler, "_handle_health", health) + socket_path = Path("/realm/tmp") / f"polylogue-uds-{getpid()}.sock" + server = DaemonAPIUnixHTTPServer(socket_path, DaemonAPIHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = DaemonClient(socket_path) + assert ( + client.probe( + archive_root="/realm/archive", + index_schema_version=24, + daemon_version="0.1.0", + ) + is not None + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) From 8df19a57389c62f579a324a854c3e445dc4def19 Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:20:24 +0200 Subject: [PATCH 09/11] fix(cli): preserve daemon auth and opt-out routing Carry configured daemon credentials over the UDS transport, honor the root\n--no-daemon flag for facets and bare triage, and report daemon-served query\nlatency under --verbose.\n\nRef polylogue-20d.1 --- polylogue/cli/archive_query.py | 8 +++++++- polylogue/cli/click_app.py | 7 +++++-- polylogue/cli/commands/facets.py | 9 +++++++-- polylogue/cli/daemon_client.py | 10 ++++++---- tests/unit/cli/test_daemon_client.py | 3 ++- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index 91e1f282d0..953c89ad42 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -947,6 +947,9 @@ def _try_emit_daemon_session_page( payload = _fetch_daemon_sessions_payload(config, daemon_params) if payload is None: return False + elapsed_ms = payload.pop("_daemon_elapsed_ms", None) + if bool(params.get("verbose")) and isinstance(elapsed_ms, int): + click.echo(f"served-by: daemon (uds, {elapsed_ms}ms)", err=True) if isinstance(payload.get("hits"), list): _emit_daemon_search_payload( payload, @@ -1001,6 +1004,7 @@ def _try_emit_daemon_unit_page( ) if payload is None: return False + payload.pop("_daemon_elapsed_ms", None) raw_items = payload.get("items") if not isinstance(raw_items, list): return False @@ -1149,7 +1153,7 @@ def _fetch_daemon_payload( from polylogue.version import POLYLOGUE_VERSION socket_path = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock" - client = DaemonClient(socket_path) + client = DaemonClient(socket_path, auth_token=getattr(config, "api_auth_token", None)) if ( client.probe( archive_root=str(config.archive_root), @@ -1161,6 +1165,8 @@ def _fetch_daemon_payload( return None payload = client.request_json("POST" if body is not None else "GET", path, body) if payload is not None: + if client.last_elapsed_ms is not None: + payload["_daemon_elapsed_ms"] = client.last_elapsed_ms return payload return None diff --git a/polylogue/cli/click_app.py b/polylogue/cli/click_app.py index 8f05225883..3b25fa50f9 100644 --- a/polylogue/cli/click_app.py +++ b/polylogue/cli/click_app.py @@ -242,7 +242,7 @@ def _show_bare_tty_triage(ctx: click.Context, env: AppEnv) -> bool: click.echo(ctx.get_help()) return True - rows = _bare_tty_daemon_rows(config) + rows = None if bool(ctx.params.get("no_daemon")) else _bare_tty_daemon_rows(config) source = "daemon" if rows is not None else "direct" if rows is None: try: @@ -272,7 +272,10 @@ def _bare_tty_daemon_rows(config: Config) -> list[SelectSessionRow] | None: from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION from polylogue.version import POLYLOGUE_VERSION - client = DaemonClient(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock") + client = DaemonClient( + Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock", + auth_token=getattr(config, "api_auth_token", None), + ) if ( client.probe( archive_root=str(config.archive_root), diff --git a/polylogue/cli/commands/facets.py b/polylogue/cli/commands/facets.py index 6cc7ba1ea0..1b6946b1b4 100644 --- a/polylogue/cli/commands/facets.py +++ b/polylogue/cli/commands/facets.py @@ -67,6 +67,7 @@ def facets_command( origin=origin, include_deferred=include_deferred, no_idf=no_idf, + disabled=bool(ctx.parent and ctx.parent.params.get("no_daemon")), ) if daemon_response is not None: emit_facets_response(daemon_response, output_format="json" if json_output else output_format) @@ -89,10 +90,11 @@ def _fetch_daemon_facets( origin: str | None, include_deferred: bool, no_idf: bool, + disabled: bool, ) -> FacetsResponse | None: """Use the config-matched UDS daemon for read-only facets when lossless.""" - if no_idf or os.environ.get("POLYLOGUE_NO_DAEMON", "").lower() in {"1", "true", "yes", "on"}: + if disabled or no_idf or os.environ.get("POLYLOGUE_NO_DAEMON", "").lower() in {"1", "true", "yes", "on"}: return None if os.environ.get("POLYLOGUE_DAEMON", "").lower() == "off": return None @@ -103,7 +105,10 @@ def _fetch_daemon_facets( from polylogue.version import POLYLOGUE_VERSION config = load_effective_config(env) - client = DaemonClient(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock") + client = DaemonClient( + Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "polylogue" / "daemon.sock", + auth_token=getattr(config, "api_auth_token", None), + ) if ( client.probe( archive_root=str(config.archive_root), diff --git a/polylogue/cli/daemon_client.py b/polylogue/cli/daemon_client.py index c44e05c80d..feed41ed21 100644 --- a/polylogue/cli/daemon_client.py +++ b/polylogue/cli/daemon_client.py @@ -26,9 +26,10 @@ def connect(self) -> None: class DaemonClient: - def __init__(self, socket_path: Path, *, timeout_s: float = 0.1) -> None: + def __init__(self, socket_path: Path, *, timeout_s: float = 0.1, auth_token: str | None = None) -> None: self.socket_path = socket_path self.timeout_s = timeout_s + self.auth_token = auth_token self.last_elapsed_ms: int | None = None def request_json(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, Any] | None: @@ -38,9 +39,10 @@ def request_json(self, method: str, path: str, body: dict[str, object] | None = raw = json.dumps(body, separators=(",", ":")).encode() if body is not None else None started_at = perf_counter() try: - connection.request( - method, path, body=raw, headers={"Host": "127.0.0.1", "Content-Type": "application/json"} - ) + headers = {"Host": "127.0.0.1", "Content-Type": "application/json"} + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + connection.request(method, path, body=raw, headers=headers) response = connection.getresponse() if response.status != 200: return None diff --git a/tests/unit/cli/test_daemon_client.py b/tests/unit/cli/test_daemon_client.py index 32928dd905..3a776309f0 100644 --- a/tests/unit/cli/test_daemon_client.py +++ b/tests/unit/cli/test_daemon_client.py @@ -94,10 +94,11 @@ def health(self: DaemonAPIHandler) -> None: monkeypatch.setattr(DaemonAPIHandler, "_handle_health", health) socket_path = Path("/realm/tmp") / f"polylogue-uds-{getpid()}.sock" server = DaemonAPIUnixHTTPServer(socket_path, DaemonAPIHandler) + server.auth_token = "uds-test-token" thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - client = DaemonClient(socket_path) + client = DaemonClient(socket_path, auth_token="uds-test-token") assert ( client.probe( archive_root="/realm/archive", From f0ca1199dc0e674a554533e77a2063869ecde7de Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 07:21:03 +0200 Subject: [PATCH 10/11] docs(cli): describe continue as harness resume Align generated CLI help with the emitted interactive resume command.\n\nRef polylogue-37t.8 --- docs/cli-reference.md | 7 +++---- polylogue/cli/click_app.py | 2 +- polylogue/cli/query_verbs.py | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7cae19e412..5c51e862b9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -162,7 +162,7 @@ Commands: analyze Analyze matched sessions and named facet families. facets Show global or scoped archive facet families. delete Delete matched sessions. - continue Compile a successor-agent continuation report. + continue Print or execute the harness command that resumes a session. Use `find QUERY then ACTION`; `facets` is the direct archive aggregate command. @@ -479,7 +479,6 @@ Usage: polylogue continue [OPTIONS] Print or explicitly execute a verified harness-resume command. Examples: - polylogue find id:abc then continue polylogue find id:abc then continue polylogue find id:abc then continue --exec polylogue continue --candidates --repo /workspace/polylogue --recent polylogue/cli/query_verbs.py @@ -501,8 +500,8 @@ Options: --limit INTEGER Maximum continuation candidates to return. [default: 10] --json Shortcut for --format json. - -f, --format [json] Output format. JSON emits the shared - ContextImage payload. + -f, --format [json] Reserved for compatibility; continue emits a + shell command only. --help Show this message and exit. ``` diff --git a/polylogue/cli/click_app.py b/polylogue/cli/click_app.py index 3b25fa50f9..2211e4a32b 100644 --- a/polylogue/cli/click_app.py +++ b/polylogue/cli/click_app.py @@ -528,7 +528,7 @@ def find_help() -> None: _QUERY_VERB_HELP: dict[str, str] = { "analyze": "Analyze matched sessions and named facet families.", - "continue": "Compile a successor-agent continuation report.", + "continue": "Print or execute the harness command that resumes a session.", "delete": "Delete matched sessions.", "mark": "Mark selected sessions; review candidates under mark candidates.", "read": "Read matched sessions (route to view/destination).", diff --git a/polylogue/cli/query_verbs.py b/polylogue/cli/query_verbs.py index 250f5802cf..f3d7a35072 100644 --- a/polylogue/cli/query_verbs.py +++ b/polylogue/cli/query_verbs.py @@ -1362,7 +1362,7 @@ def read_verb( "output_format", type=click.Choice(["json"]), default=None, - help="Output format. JSON emits the shared ContextImage payload.", + help="Reserved for compatibility; continue emits a shell command only.", ) @click.pass_context def continue_verb( @@ -1381,7 +1381,6 @@ def continue_verb( \b Examples: - polylogue find id:abc then continue polylogue find id:abc then continue polylogue find id:abc then continue --exec polylogue continue --candidates --repo /workspace/polylogue --recent polylogue/cli/query_verbs.py From 7bc6007f13269700dc8ea018f8d4c7491bc56d03 Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 13 Jul 2026 08:25:29 +0200 Subject: [PATCH 11/11] fix(cli): keep session-only unit-query flags failing under a live daemon Problem: the daemon query-units fast path ran before the local validation that rejects session-only modes on unit queries, so with a reachable daemon, commands like --count over 'messages where ...' returned unit rows while silently ignoring the flag (Codex review P2 on #2827). What changed: the fast path now falls back to local execution whenever stats/count/sample/cursor/sort/reverse/open/conv-id modes are set, so the pre-existing local UsageError fires unchanged. Regression test asserts the daemon transport is never consulted for such queries. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013v95mgEuw3uo9AsnpvhDbm --- polylogue/cli/archive_query.py | 27 +++++++++++++++ tests/unit/cli/test_query_exec_laws.py | 46 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index 953c89ad42..edb7e8ebd7 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -334,6 +334,11 @@ def _execute_archive_query_stdout(env: AppEnv, request: RootModeRequest) -> None tags_to_add=tags_to_add, metadata_to_set=metadata_to_set, delete_matched=delete_matched, + sample_count=sample_count, + since_session_id=since_session_id, + cursor=cursor, + sort=sort, + reverse=reverse, ): return if not index_db_path.exists(): @@ -990,11 +995,33 @@ def _try_emit_daemon_unit_page( tags_to_add: tuple[str, ...], metadata_to_set: tuple[tuple[str, str], ...], delete_matched: bool, + sample_count: int | None, + since_session_id: str | None, + cursor: object | None, + sort: str | None, + reverse: bool, ) -> bool: """Render daemon query-unit envelopes with the existing CLI renderer.""" if source is None or stream or tags_to_add or metadata_to_set or delete_matched: return False + if any( + ( + params.get("stats_only"), + params.get("stats_by"), + params.get("count_only"), + params.get("open_result"), + params.get("conv_id"), + sample_count is not None, + since_session_id is not None, + cursor is not None, + sort is not None, + reverse, + ) + ): + # Session-only modes must keep failing with the local UsageError; the + # daemon endpoint would silently ignore these flags and return rows. + return False daemon_params = _daemon_session_query_params(request, params, limit=limit, offset=offset) daemon_params["expression"] = expression payload = _fetch_daemon_payload( diff --git a/tests/unit/cli/test_query_exec_laws.py b/tests/unit/cli/test_query_exec_laws.py index 9101edcd91..ae18a64a81 100644 --- a/tests/unit/cli/test_query_exec_laws.py +++ b/tests/unit/cli/test_query_exec_laws.py @@ -3509,3 +3509,49 @@ def test_search_handles_missing_index( assert "searchable" in result.output.lower() or "c1" in result.output else: assert "no session" in result.output.lower() or "matched" in result.output.lower() + + +def test_daemon_unit_fast_path_defers_session_only_modes_to_local_validation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Session-only flags on unit queries must keep failing, daemon or not. + + Anti-vacuity: without the fast-path guard, a reachable daemon serves + /api/query-units rows for ``--count`` unit queries, silently ignoring the + flag instead of raising the local UsageError. + """ + archive_root = tmp_path / "archive" + archive_root.mkdir() + (archive_root / "index.db").touch() + config = MagicMock() + config.archive_root = archive_root + config.db_path = archive_root / "index.db" + config.daemon_url = "http://127.0.0.1:9876" + config.api_auth_token = None + env = _make_env(repo=MagicMock(), config=config) + + monkeypatch.setattr( + "polylogue.cli.archive_query._fetch_daemon_payload", + MagicMock(side_effect=AssertionError("session-only unit query must not take the daemon fast path")), + ) + store = MagicMock() + store.__enter__ = MagicMock(return_value=MagicMock()) + store.__exit__ = MagicMock(return_value=False) + monkeypatch.setattr( + "polylogue.cli.archive_query.ArchiveStore.open_existing", + MagicMock(return_value=store), + ) + + with pytest.raises(click.UsageError, match="do not combine"): + asyncio.run( + async_execute_query( + env, + { + "archive": True, + "query": ("messages where role:assistant AND text:timeout",), + "count_only": True, + "output_format": "json", + }, + ) + )