lisa-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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. {lisa_cli-0.2.0/src/lisa_cli.egg-info → lisa_cli-0.3.0}/PKG-INFO +1 -1
  2. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/pyproject.toml +1 -1
  3. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/main.py +154 -78
  4. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/transport.py +202 -39
  5. {lisa_cli-0.2.0 → lisa_cli-0.3.0/src/lisa_cli.egg-info}/PKG-INFO +1 -1
  6. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/SOURCES.txt +1 -1
  7. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_transport.py +157 -16
  8. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_transport_stage.py +195 -0
  9. lisa_cli-0.3.0/tests/test_update_command.py +249 -0
  10. lisa_cli-0.2.0/tests/test_run_command.py +0 -132
  11. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/LICENSE +0 -0
  12. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/setup.cfg +0 -0
  13. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/__init__.py +0 -0
  14. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/__init__.py +0 -0
  15. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/entry.py +0 -0
  16. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/expand.py +0 -0
  17. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/render.py +0 -0
  18. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
  19. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/entry_points.txt +0 -0
  20. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/requires.txt +0 -0
  21. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/top_level.txt +0 -0
  22. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_approval_panel.py +0 -0
  23. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_group_resolution.py +0 -0
  24. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_last_command.py +0 -0
  25. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_models_command.py +0 -0
  26. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_pack_update.py +0 -0
  27. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_profile_option.py +0 -0
  28. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_command.py +0 -0
  29. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_decisions.py +0 -0
  30. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_entry.py +0 -0
  31. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_expand.py +0 -0
  32. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_render_catalogs.py +0 -0
  33. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_render_models.py +0 -0
  34. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_restart.py +0 -0
  35. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_runtime_teardown.py +0 -0
  36. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_streaming.py +0 -0
  37. {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_verify_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lisa-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Terminal CLI for the Lisa Harness
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -7,7 +7,7 @@ where = ["src"]
7
7
 
8
8
  [project]
9
9
  name = "lisa-cli"
10
- version = "0.2.0"
10
+ version = "0.3.0"
11
11
  description = "Terminal CLI for the Lisa Harness"
12
12
  requires-python = ">=3.11"
13
13
  license = "MIT"
@@ -3,8 +3,8 @@
3
3
  This is the terminal-transport entry point for the Lisa framework. It
4
4
  wraps the transport-neutral :mod:`host` runtime with Typer commands
5
5
  and Rich rendering. Every command is a one-shot process: turns run via
6
- ``lisa prompt`` and lifecycle/inspection commands (verify, models, policy,
7
- pack, mcp, last, intake) run once and exit.
6
+ ``lisa prompt`` and lifecycle/inspection commands (verify, update, models,
7
+ policy, pack, mcp, last, intake) run once and exit.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -14,6 +14,8 @@ import logging
14
14
  import os
15
15
  import platform
16
16
  import shlex
17
+ import shutil
18
+ import subprocess
17
19
  import sys
18
20
  from collections.abc import Callable
19
21
  from pathlib import Path
@@ -415,7 +417,7 @@ def _get_version() -> str:
415
417
 
416
418
  return _pkg_version("lisa-cli")
417
419
  except Exception: # pylint: disable=broad-except
418
- return "0.2.0"
420
+ return "0.3.0"
419
421
 
420
422
 
421
423
  _VERSION_OPTION = typer.Option(
@@ -1233,7 +1235,7 @@ def _models_command(
1233
1235
  await runtime.start()
1234
1236
  return await handle_models_command(runtime, stripped, stripped)
1235
1237
  finally:
1236
- await runtime.aclose()
1238
+ await _aclose_runtime_bounded(runtime)
1237
1239
 
1238
1240
  response_text = asyncio.run(_run_models_command())
1239
1241
  console.print(Panel(Markdown(response_text), title="Models", border_style="cyan"))
@@ -1900,6 +1902,142 @@ async def _run_pack_update_all(runtime: HostRuntime) -> tuple[bool, str]:
1900
1902
  return await run_pack_update_all(runtime)
1901
1903
 
1902
1904
 
1905
+ # ---------------------------------------------------------------------------
1906
+ # update command
1907
+ # ---------------------------------------------------------------------------
1908
+
1909
+ _UPDATE_TOOL_NAME = "lisa-cli"
1910
+
1911
+ #: uv cannot replace a running console-script trampoline on Windows; the
1912
+ #: command reports the argv for the operator to run after exiting instead.
1913
+ _UPDATE_IN_PLACE_BLOCKED = platform.system() == "Windows"
1914
+
1915
+
1916
+ @app.command(
1917
+ "update",
1918
+ help="Upgrade the installed lisa CLI in place (uv tool upgrade lisa-cli).",
1919
+ )
1920
+ def _update_command(
1921
+ force: bool = typer.Option(
1922
+ False,
1923
+ "--force",
1924
+ help=(
1925
+ "Reinstall even when the recorded version is unchanged "
1926
+ "(picks up new code from a local or git checkout without a version bump)."
1927
+ ),
1928
+ ),
1929
+ dry_run: bool = typer.Option(
1930
+ False,
1931
+ "--dry-run",
1932
+ help="Print the uv command instead of running it.",
1933
+ ),
1934
+ ) -> None:
1935
+ """Upgrade the tool install that provides this ``lisa`` executable.
1936
+
1937
+ ``uv tool upgrade`` is the only correct update path: it replays the
1938
+ install receipt (``<tool-dir>/lisa-cli/uv-receipt.toml``), which is where
1939
+ the installer records the ``--with`` framework requirements, the
1940
+ ``[prompt]`` extra, and ``no-sources``. ``uv tool install --upgrade
1941
+ lisa-cli`` would rewrite that receipt from a bare index spec and drop
1942
+ all of it, so this command never does that.
1943
+ """
1944
+ from runtime.paths import default_packs_venv, is_tool_install
1945
+
1946
+ if not is_tool_install():
1947
+ console.print(
1948
+ "[yellow]lisa is running from a repo checkout, so there is no uv tool to "
1949
+ "upgrade. Update with:[/yellow] git pull && uv sync"
1950
+ )
1951
+ raise typer.Exit(code=2)
1952
+
1953
+ tool_dir = _installed_tool_dir()
1954
+ if tool_dir is None:
1955
+ console.print(
1956
+ "[yellow]This interpreter is not a uv tool install (no uv-receipt.toml in "
1957
+ f"{sys.prefix}), so there is nothing for `uv tool upgrade` to update.[/yellow]"
1958
+ )
1959
+ raise typer.Exit(code=2)
1960
+
1961
+ uv_bin = shutil.which("uv")
1962
+ # Point uv at the tool dir this process is actually running from. Without
1963
+ # it uv consults only UV_TOOL_DIR/default, so an install made with a custom
1964
+ # tool dir would miss the real one and advise `uv tool install lisa-cli` —
1965
+ # the receipt-clobbering path this command exists to avoid.
1966
+ env = {**os.environ, "UV_TOOL_DIR": str(tool_dir.parent)}
1967
+ argv = [
1968
+ uv_bin or "uv",
1969
+ "tool",
1970
+ "upgrade",
1971
+ *(["--reinstall"] if force else []),
1972
+ _UPDATE_TOOL_NAME,
1973
+ ]
1974
+ printable = shlex.join(argv)
1975
+
1976
+ # On Windows the console script is a .exe trampoline inside the tool
1977
+ # venv, which cannot be replaced while this process still has it open.
1978
+ blocked = uv_bin is None or (_UPDATE_IN_PLACE_BLOCKED and not dry_run)
1979
+ if dry_run or blocked:
1980
+ if dry_run:
1981
+ console.print(f"Run: [bold]{printable}[/bold]")
1982
+ else:
1983
+ reason = (
1984
+ "uv is not on PATH; run it yourself"
1985
+ if uv_bin is None
1986
+ else "Windows locks the running lisa.exe"
1987
+ )
1988
+ console.print(f"[yellow]{reason}.[/yellow] Run: [bold]{printable}[/bold]")
1989
+ raise typer.Exit(code=0 if dry_run else 1)
1990
+
1991
+ console.print(f"Running [bold]{printable}[/bold]")
1992
+ completed = subprocess.run(argv, check=False, env=env) # inherits stdio so uv streams progress
1993
+ if completed.returncode != 0:
1994
+ console.print(f"[red]uv tool upgrade failed (exit {completed.returncode}).[/red]")
1995
+ raise typer.Exit(code=completed.returncode)
1996
+
1997
+ console.print("Updated. Restart lisa to use the new version.")
1998
+ _warn_stale_packs_venv(default_packs_venv())
1999
+
2000
+
2001
+ def _installed_tool_dir() -> Path | None:
2002
+ """Return the uv tool directory this process is running from.
2003
+
2004
+ A ``uv tool`` venv carries the receipt at its root, so ``sys.prefix``
2005
+ identifies the install unambiguously — including installs made with a
2006
+ custom ``UV_TOOL_DIR``, which uv would otherwise not find. The receipt's
2007
+ parent is the value ``UV_TOOL_DIR`` expects (it is the directory holding
2008
+ one subdirectory per tool).
2009
+
2010
+ This is a positive check: a plain virtualenv or pipx install has no
2011
+ receipt and returns ``None``.
2012
+ """
2013
+ prefix = Path(sys.prefix)
2014
+ if not (prefix / "uv-receipt.toml").is_file():
2015
+ return None
2016
+ return prefix
2017
+
2018
+
2019
+ def _warn_stale_packs_venv(packs_venv: Path) -> None:
2020
+ """Point the operator at the one follow-up packs may need.
2021
+
2022
+ The packs venv is a second install with its own framework copies. Packs
2023
+ import the CLI's framework packages (the tool venv's site-packages
2024
+ precedes the packs venv on ``sys.path``), so an upgrade does not break
2025
+ them — but a pack whose *dependencies* moved can still need a re-resolve,
2026
+ and ``uv pip install`` never upgrades an already-satisfied requirement.
2027
+ ``lisa pack update --all`` re-installs each pack from its recorded source,
2028
+ which re-resolves its dependency set. Surfacing the command is cheaper
2029
+ than silently re-seeding every pack after an upgrade.
2030
+ """
2031
+ if not packs_venv.is_dir():
2032
+ return
2033
+ console.print(
2034
+ f"[yellow]Packs live in a separate venv ({packs_venv}). Packs keep working "
2035
+ "against the upgraded framework, but their own dependencies are only "
2036
+ "re-resolved by `lisa pack update --all` — run it if a pack misbehaves "
2037
+ "after an update.[/yellow]"
2038
+ )
2039
+
2040
+
1903
2041
  # ---------------------------------------------------------------------------
1904
2042
  # prompt command
1905
2043
  # ---------------------------------------------------------------------------
@@ -2064,7 +2202,7 @@ async def _run_host_command_turn(runtime: HostRuntime, command: str) -> str:
2064
2202
  await runtime.start()
2065
2203
  result = await handle_message(runtime, command, NullTransport())
2066
2204
  finally:
2067
- await runtime.aclose()
2205
+ await _aclose_runtime_bounded(runtime)
2068
2206
  return getattr(result, "text", "") or str(result)
2069
2207
 
2070
2208
 
@@ -2128,7 +2266,7 @@ async def _decide_approval_turn(
2128
2266
  transport=NullTransport(),
2129
2267
  )
2130
2268
  finally:
2131
- await runtime.aclose()
2269
+ await _aclose_runtime_bounded(runtime)
2132
2270
  return getattr(result, "text", "") or str(result)
2133
2271
 
2134
2272
 
@@ -2392,76 +2530,6 @@ def _deny_command( # Match shared runtime options used by one-shot commands.
2392
2530
  )
2393
2531
 
2394
2532
 
2395
- @app.command(
2396
- "run",
2397
- hidden=True,
2398
- help=(
2399
- "[DEPRECATED] Run one slash command (host admin or pack-defined). "
2400
- "Every host command now has a real subcommand (pack-registered ones "
2401
- "like `stalls`/`retry` are generated from the pack registry); this "
2402
- "escape hatch remains one release for compatibility. The leading '/' "
2403
- "is optional. Quote multi-word arguments as one string; with multiple "
2404
- "tokens they are re-quoted verbatim so the runtime sees the exact "
2405
- "slash-command string."
2406
- ),
2407
- )
2408
- def _run_command_command( # Match shared runtime options used by one-shot commands.
2409
- command: Annotated[
2410
- list[str] | None,
2411
- typer.Argument(help="Slash command and its arguments."),
2412
- ] = None,
2413
- log_level: LogLevelOption = "",
2414
- log: LogOption = "",
2415
- no_log: NoLogOption = False,
2416
- data_dir: DataDirOption = "",
2417
- profile: ProfileOption = "",
2418
- overlays_dir: OverlaysDirOption = "",
2419
- thread_dir: ThreadDirOption = "",
2420
- no_thread: NoThreadOption = False,
2421
- session_context: SessionContextOption = "",
2422
- timezone: TimezoneOption = "",
2423
- state_snapshot: StateSnapshotOption = "",
2424
- streaming: StreamingOption = "",
2425
- host_log: HostLogOption = "",
2426
- ) -> None:
2427
- """Dispatch one slash command to the runtime exactly as a persistent
2428
- host would, via ``handle_message`` and ``_run_host_command_turn``.
2429
-
2430
- DEPRECATED: kept one release for compatibility. Every host admin verb
2431
- now has a tailored Typer command; pack-registered commands (``stalls``,
2432
- ``retry``, ...) resolve as dynamically generated subcommands. This
2433
- generic string escape hatch hides from help and will be removed once
2434
- scripts have migrated.
2435
- """
2436
- console.print(
2437
- "[yellow]`lisa run` is deprecated: host commands are real subcommands "
2438
- "now (`lisa settings`, `lisa stalls`, ...).[/yellow]"
2439
- )
2440
- if not command:
2441
- console.print("[red]No slash command given. Example: lisa run '/policy mode trust'[/red]")
2442
- raise typer.Exit(code=2)
2443
- parts = list(command)
2444
- command_str = parts[0] if len(parts) == 1 else " ".join(shlex.quote(part) for part in parts)
2445
- if not command_str.startswith("/"):
2446
- command_str = "/" + command_str
2447
- _run_host_command(
2448
- command_str,
2449
- log_level=log_level,
2450
- log_path=log,
2451
- no_log=no_log,
2452
- data_dir=data_dir,
2453
- profile=profile,
2454
- overlays_dir=overlays_dir,
2455
- thread_dir=thread_dir,
2456
- no_thread=no_thread,
2457
- session_context=session_context,
2458
- timezone=timezone,
2459
- state_snapshot_path=state_snapshot,
2460
- streaming=streaming,
2461
- host_log_path=host_log,
2462
- )
2463
-
2464
-
2465
2533
  def _compose_prompt_message(instruction: str, payload: str) -> str:
2466
2534
  """Compose the prompt-mode turn message from the CLI instruction and stdin.
2467
2535
 
@@ -2672,14 +2740,22 @@ async def _run_prompt_with_teardown(
2672
2740
  # stop — there is no turn to run.
2673
2741
  decision_text = getattr(result, "text", "") or str(result)
2674
2742
  transport.stop_activity()
2675
- await runtime.aclose()
2743
+ await _aclose_runtime_bounded(runtime)
2676
2744
  await transport.on_response(decision_text)
2677
2745
  return transport
2678
2746
  if message:
2679
2747
  await handle_message(runtime, message, transport)
2680
2748
  finally:
2681
2749
  transport.stop_activity()
2682
- await runtime.aclose()
2750
+ # Bounded teardown: the prompt command previously awaited bare
2751
+ # ``runtime.aclose()`` — when the MCP pool's sequential anyio
2752
+ # task-group teardown stalls (the 2026-09-21 headroom incident
2753
+ # recurred on 2026-10-26 ~16:11: the turn had already completed
2754
+ # and persisted, yet the CLI sat idle in epoll for the rest of
2755
+ # the prompt run because the unbounded close never returned).
2756
+ # The bounded helper falls back to terminating every tracked
2757
+ # MCP/proxy subprocess after the grace window.
2758
+ await _aclose_runtime_bounded(runtime)
2683
2759
  return transport
2684
2760
 
2685
2761
 
@@ -320,8 +320,7 @@ class PromptTransport:
320
320
  # ── Turn counters ───────────────────────────────────────────
321
321
  self._turn_started_perf: float = 0.0
322
322
  self._current_mode: str = ""
323
- self._status_current_model: str = ""
324
- self._status_model_provider: str = ""
323
+ self._reset_model_status()
325
324
  # Global thread turn number of the turn being rendered, stamped by
326
325
  # the host on the ``model_call`` stage payload (``turn_index``).
327
326
  # Rendered on the Response rule and the ⚡ footer; -1 = unknown. A
@@ -329,12 +328,7 @@ class PromptTransport:
329
328
  # in ``start_thinking``: a pre-armed-decision run dispatches a second
330
329
  # message on the same transport and each capture replaces it.
331
330
  self._turn_number: int = -1
332
- self._status_turn_tokens: int = 0
333
- self._status_prompt_tokens: int = 0
334
- self._status_completion_tokens: int = 0
335
- self._status_cached_tokens: int = 0
336
- self._status_billed_tokens: int = 0
337
- self._status_reasoning_chars: int = 0
331
+ self._init_status_counters()
338
332
  self._iteration_durations: list[float] = []
339
333
  self._model_call_count: int = 0
340
334
  self._handoff_count: int = 0
@@ -382,6 +376,43 @@ class PromptTransport:
382
376
  # ── Stream velocity (observable, not manifest-trusted) ──────
383
377
  self._init_velocity_state()
384
378
 
379
+ def _init_status_counters(self) -> None:
380
+ """Zero the per-turn token/cost counters shared by the footer.
381
+
382
+ ``start_thinking`` re-runs this for each turn; constructing the
383
+ transport runs it once for the pre-turn state.
384
+
385
+ ``_status_turn_tokens`` is the *walk* (every call's ``total_tokens``),
386
+ ``_status_billed_tokens`` the uncached basis, and the two cost fields
387
+ carry the kernel's canonical estimate and the provider's own reported
388
+ charge. The provider figure is authoritative when non-zero; when the
389
+ kernel supplied neither cost field the footer falls back to pricing
390
+ ``model_prices`` itself, so an older runtime still shows a number.
391
+ """
392
+ self._status_turn_tokens: int = 0
393
+ self._status_prompt_tokens: int = 0
394
+ self._status_completion_tokens: int = 0
395
+ self._status_cached_tokens: int = 0
396
+ self._status_billed_tokens: int = 0
397
+ self._status_cost_usd: float = 0.0
398
+ self._status_provider_cost_usd: float = 0.0
399
+ # Model calls that reported a provider charge. A turn mixing
400
+ # providers (some reporting cost, some silent) has a partial total,
401
+ # which must not masquerade as the full bill.
402
+ self._status_provider_cost_calls: int = 0
403
+ # Per-turn sums of the counters the serving proxy declared on its
404
+ # response headers (e.g. compression ``tokens_saved``), keyed by the
405
+ # pack's own field names. Empty when no proxy is in the path — the
406
+ # footer then renders no proxy segment at all.
407
+ self._status_proxy_telemetry: dict[str, int] = {}
408
+ self._status_reasoning_chars: int = 0
409
+
410
+ def _reset_model_status(self) -> None:
411
+ self._status_current_model: str = ""
412
+ self._status_model_provider: str = ""
413
+ self._status_proxy_id: str = ""
414
+ self._status_proxy_via: str = ""
415
+
385
416
  def _init_velocity_state(self) -> None:
386
417
  """Initialize the velocity-sampling state (one statement block).
387
418
 
@@ -458,12 +489,15 @@ class PromptTransport:
458
489
  self._status_completion_tokens = 0
459
490
  self._status_cached_tokens = 0
460
491
  self._status_billed_tokens = 0
492
+ self._status_cost_usd = 0.0
493
+ self._status_provider_cost_usd = 0.0
494
+ self._status_provider_cost_calls = 0
495
+ self._status_proxy_telemetry = {}
461
496
  self._status_reasoning_chars = 0
462
497
  self._fork_children = {}
463
498
  self._fork_total = 0
464
499
  self._current_mode = ""
465
- self._status_current_model = ""
466
- self._status_model_provider = ""
500
+ self._reset_model_status()
467
501
  self._active_label = ""
468
502
  self._phase_label = "starting runtime"
469
503
  self._last_tool_data = None
@@ -755,6 +789,24 @@ class PromptTransport:
755
789
  if isinstance(turn_raw, int) and turn_raw >= 0:
756
790
  self._turn_number = turn_raw
757
791
 
792
+ def _capture_proxy_route(self, data: dict[str, Any]) -> None:
793
+ """Capture the provider's proxy routing from a stage payload.
794
+
795
+ ``proxy_route.via`` is ``"proxy"`` (requests flow through the named
796
+ proxy pack, e.g. headroom) or ``"direct"``. A payload without the
797
+ key clears the chip: transports must not keep showing a stale hop
798
+ when the runtime stops stamping routing info.
799
+ """
800
+ route = data.get("proxy_route")
801
+ if not isinstance(route, dict):
802
+ self._status_proxy_id = ""
803
+ self._status_proxy_via = ""
804
+ return
805
+ via = str(route.get("via") or "")
806
+ self._status_proxy_via = via if via in ("proxy", "direct") else ""
807
+ proxy_id = route.get("proxy_id") if via == "proxy" else route.get("bypassed_proxy_id")
808
+ self._status_proxy_id = str(proxy_id or "")
809
+
758
810
  def _handle_model_stage(self, label: str, data: dict[str, Any]) -> None:
759
811
  """Route model_call* stage events.
760
812
 
@@ -770,6 +822,7 @@ class PromptTransport:
770
822
  provider = data.get("provider")
771
823
  self._status_current_model = str(model_id)
772
824
  self._status_model_provider = str(provider) if provider not in (None, "") else ""
825
+ self._capture_proxy_route(data)
773
826
  self._model_call_count += 1
774
827
  self._active_reasoning_model = bool(data.get("reasoning_model"))
775
828
  # A new model call resets the stall clock: pre-call silence is
@@ -790,28 +843,7 @@ class PromptTransport:
790
843
  if d_raw not in (None, "", 0):
791
844
  with contextlib.suppress(TypeError, ValueError):
792
845
  self._iteration_durations.append(float(d_raw))
793
- total_raw = data.get("_state.total_tokens") or data.get("total_tokens")
794
- if total_raw not in (None, "", 0):
795
- self._status_turn_tokens += int(total_raw)
796
- # Split counts feed the footer's cost estimate (manifest prices
797
- # are per prompt/completion token). Cache-hit counts let the
798
- # estimate price cached prompts at the manifest's cache-read rate.
799
- with contextlib.suppress(TypeError, ValueError):
800
- self._status_prompt_tokens += int(data.get("prompt_tokens") or 0)
801
- with contextlib.suppress(TypeError, ValueError):
802
- self._status_completion_tokens += int(data.get("completion_tokens") or 0)
803
- cached_now = 0
804
- with contextlib.suppress(TypeError, ValueError):
805
- cached_now = int(data.get("cached_tokens") or 0)
806
- self._status_cached_tokens += cached_now
807
- with contextlib.suppress(TypeError, ValueError):
808
- prompt_now = int(data.get("prompt_tokens") or 0)
809
- # Billed basis (2026-10-12 perf review): the walk figure counts
810
- # every cache hit at full size; the operator-facing "what did
811
- # this turn actually cost" is uncached prompt + completion.
812
- self._status_billed_tokens += max(0, prompt_now - cached_now) + int(
813
- data.get("completion_tokens") or 0
814
- )
846
+ self._accumulate_call_usage(data)
815
847
  return
816
848
  if label == "model_call_failed":
817
849
  self._print_event_line(
@@ -1263,7 +1295,7 @@ class PromptTransport:
1263
1295
  return body
1264
1296
 
1265
1297
  def _status_leading_parts(self) -> list[Text]:
1266
- """Leading status segments: turn, mode, model, in-flight label."""
1298
+ """Leading status segments: turn, mode, model, proxy hop, in-flight label."""
1267
1299
  parts: list[Text] = []
1268
1300
  if self._turn_number >= 0:
1269
1301
  parts.append(Text(f"turn {self._turn_number}", style=STYLE_ROW_MODEL))
@@ -1274,10 +1306,35 @@ class PromptTransport:
1274
1306
  if self._status_model_provider:
1275
1307
  model = f"{self._status_model_provider}:{model}"
1276
1308
  parts.append(Text(model, style=STYLE_STAGE))
1309
+ chip = self._proxy_chip_part()
1310
+ if chip is not None:
1311
+ parts.append(chip)
1277
1312
  if self._active_label:
1278
1313
  parts.append(Text(_truncate(self._active_label, 48), style=STYLE_STAGE))
1279
1314
  return parts
1280
1315
 
1316
+ def _proxy_chip_part(self) -> Text | None:
1317
+ """Proxy-pack routing chip, or ``None`` when the host stamped nothing.
1318
+
1319
+ ``⤳ <proxy_id>`` when the provider routes through a ready proxy
1320
+ (e.g. headroom); nothing when unstamped or direct with no proxy
1321
+ declared. The chip keeps the model segment honest about where
1322
+ requests actually flow.
1323
+ """
1324
+ if not self._status_proxy_via:
1325
+ return None
1326
+ if self._status_proxy_via == "proxy" and self._status_proxy_id:
1327
+ proxy_id = self._status_proxy_id
1328
+ prefix, separator, suffix = proxy_id.partition(".")
1329
+ if separator and prefix == suffix:
1330
+ proxy_id = prefix
1331
+ return Text(f"⤳ {_truncate(proxy_id, 24)}", style=STYLE_STAGE)
1332
+ if self._status_proxy_via == "direct" and self._status_proxy_id:
1333
+ return Text(
1334
+ f"{_truncate(self._status_proxy_id, 24)} ↓ direct", style=STYLE_ROW_TRANSITION
1335
+ )
1336
+ return None
1337
+
1281
1338
  def _append_meter_parts(self, parts: list[Text]) -> None:
1282
1339
  """Append the thinking meter segment when armed.
1283
1340
 
@@ -1396,20 +1453,126 @@ class PromptTransport:
1396
1453
  if self._status_turn_tokens:
1397
1454
  footer.append(" · ", style=STYLE_TEXT_MUTED)
1398
1455
  footer.append(f"{_format_tokens(self._status_turn_tokens)} tok", style="green3")
1399
- # Cache-split figure: when the turn walked context with cache
1400
- # hits, the walk is not the billed basis. Show what the turn
1401
- # actually consumed next to the walk (2026-10-12 perf review).
1402
- if self._status_cached_tokens > 0 and self._status_billed_tokens:
1456
+ # Billed segment: the walk above counts every cache hit at full
1457
+ # size, which on a cache-heavy turn reads up to 30x the tokens the
1458
+ # provider actually bills. Always show the billed basis beside it
1459
+ # when the two differ — that is the number to sanity-check spend
1460
+ # against. When the turn had no cache hits the two are identical
1461
+ # and the segment would be noise.
1462
+ if 0 < self._status_billed_tokens < self._status_turn_tokens:
1403
1463
  footer.append(
1404
1464
  f" · billed {_format_tokens(self._status_billed_tokens)}",
1405
1465
  style="cyan",
1406
1466
  )
1407
- cost = self._estimate_turn_cost()
1467
+ cost, authoritative = self._resolve_turn_cost()
1408
1468
  if cost:
1409
1469
  footer.append(" · ", style=STYLE_TEXT_MUTED)
1410
- footer.append(f"${cost:g}", style="cyan")
1470
+ # ``$`` marks a provider-reported charge (the real meter); the
1471
+ # ``~`` prefix marks the harness's own estimate from manifest
1472
+ # rates, so the two are never mistaken for each other.
1473
+ prefix = "$" if authoritative else "~$"
1474
+ footer.append(f"{prefix}{cost:g}", style="cyan")
1475
+ self._append_proxy_savings(footer)
1411
1476
  self.console.print(footer)
1412
1477
 
1478
+ def _append_proxy_savings(self, footer: Text) -> None:
1479
+ """Append the serving proxy's reported savings, when it declared any.
1480
+
1481
+ A proxy in the path (compression, cache steering) reports what it
1482
+ saved per call on response headers; the kernel sums them and the
1483
+ footer shows the turn's total. The counter is whatever the proxy
1484
+ declared, so nothing is rendered unless a known name is present —
1485
+ an unrecognised counter still reaches ``lisa tokens`` and any pack
1486
+ reading the telemetry log, but the one-line footer only labels the
1487
+ fields it can describe without guessing at units.
1488
+ """
1489
+
1490
+ saved = self._status_proxy_telemetry.get("tokens_saved")
1491
+ if not saved:
1492
+ return
1493
+ footer.append(" · ", style=STYLE_TEXT_MUTED)
1494
+ footer.append(f"saved {_format_tokens(saved)}", style="dark_sea_green4")
1495
+
1496
+ def _accumulate_call_usage(self, data: dict[str, Any]) -> None:
1497
+ """Fold one completed model call's usage into the turn totals.
1498
+
1499
+ Three figures accumulate independently, because they answer different
1500
+ questions and must not be conflated:
1501
+
1502
+ * ``_status_turn_tokens`` — the *walk*: sum of every call's
1503
+ ``total_tokens``. Grows quadratically across an agentic turn
1504
+ because each iteration re-sends the whole conversation. Useful for
1505
+ "how much context did this turn chew through", misleading as a bill.
1506
+ * ``_status_billed_tokens`` — uncached prompt + completion, i.e. the
1507
+ tokens the provider charges at full rate.
1508
+ * ``_status_cost_usd`` / ``_status_provider_cost_usd`` — the kernel's
1509
+ canonical estimate and the provider's own reported charge.
1510
+ """
1511
+
1512
+ total_raw = data.get("_state.total_tokens") or data.get("total_tokens")
1513
+ if total_raw not in (None, "", 0):
1514
+ with contextlib.suppress(TypeError, ValueError):
1515
+ self._status_turn_tokens += int(total_raw)
1516
+ prompt_now = 0
1517
+ with contextlib.suppress(TypeError, ValueError):
1518
+ prompt_now = int(data.get("prompt_tokens") or 0)
1519
+ self._status_prompt_tokens += prompt_now
1520
+ completion_now = 0
1521
+ with contextlib.suppress(TypeError, ValueError):
1522
+ completion_now = int(data.get("completion_tokens") or 0)
1523
+ self._status_completion_tokens += completion_now
1524
+ cached_now = 0
1525
+ with contextlib.suppress(TypeError, ValueError):
1526
+ cached_now = int(data.get("cached_tokens") or 0)
1527
+ self._status_cached_tokens += cached_now
1528
+ self._status_billed_tokens += max(0, prompt_now - cached_now) + completion_now
1529
+ with contextlib.suppress(TypeError, ValueError):
1530
+ self._status_cost_usd += float(data.get("cost_usd") or 0.0)
1531
+ provider_now = 0.0
1532
+ with contextlib.suppress(TypeError, ValueError):
1533
+ provider_now = float(data.get("provider_cost_usd") or 0.0)
1534
+ if provider_now > 0.0:
1535
+ self._status_provider_cost_usd += provider_now
1536
+ self._status_provider_cost_calls += 1
1537
+ # Proxy counters are summed without interpreting them: the key set is
1538
+ # whatever the serving proxy declared, and the footer renders only the
1539
+ # ones it knows how to label.
1540
+ telemetry = data.get("proxy_telemetry")
1541
+ if isinstance(telemetry, dict):
1542
+ for key, raw in telemetry.items():
1543
+ with contextlib.suppress(TypeError, ValueError):
1544
+ self._status_proxy_telemetry[str(key)] = self._status_proxy_telemetry.get(
1545
+ str(key), 0
1546
+ ) + max(0, int(raw))
1547
+
1548
+ def _resolve_turn_cost(self) -> tuple[float, bool]:
1549
+ """Return ``(usd, authoritative)`` for the turn's cost segment.
1550
+
1551
+ Three sources, most trustworthy first:
1552
+
1553
+ 1. ``_status_provider_cost_usd`` — the provider's own reported
1554
+ charge (OpenRouter ``usage.cost``). Authoritative: it already
1555
+ reflects the provider's real cache read/write rates and any
1556
+ surcharges, so nothing here can be more accurate.
1557
+ 2. ``_status_cost_usd`` — the kernel's canonical estimate from the
1558
+ manifest's per-token rates. Correct but list-price only.
1559
+ 3. ``_estimate_turn_cost()`` — this transport pricing the counts it
1560
+ accumulated itself. Kept for runtimes whose stage payload predates
1561
+ the cost fields, so an older kernel still yields a figure.
1562
+
1563
+ A provider charge wins only when *every* call in the turn reported
1564
+ one; a partial sum would understate the bill, so mixed turns fall
1565
+ back to the complete canonical estimate.
1566
+ """
1567
+
1568
+ calls = self._model_call_count
1569
+ provider_total = self._status_provider_cost_usd
1570
+ if provider_total > 0.0 and calls > 0 and self._status_provider_cost_calls == calls:
1571
+ return round(provider_total, 4), True
1572
+ if self._status_cost_usd > 0.0:
1573
+ return round(self._status_cost_usd, 4), False
1574
+ return self._estimate_turn_cost(), False
1575
+
1413
1576
  def _estimate_turn_cost(self) -> float:
1414
1577
  """Estimate the turn's USD cost from the manifest per-token prices.
1415
1578
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lisa-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Terminal CLI for the Lisa Harness
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -27,8 +27,8 @@ tests/test_prompt_transport.py
27
27
  tests/test_render_catalogs.py
28
28
  tests/test_render_models.py
29
29
  tests/test_restart.py
30
- tests/test_run_command.py
31
30
  tests/test_runtime_teardown.py
32
31
  tests/test_streaming.py
33
32
  tests/test_transport_stage.py
33
+ tests/test_update_command.py
34
34
  tests/test_verify_command.py