Metadata-Version: 2.4
Name: litetui
Version: 0.25.1
Summary: TUI chat client for LM Studio with image support
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: textual>=0.89.0
Requires-Dist: openai>=1.0.0
Requires-Dist: httpx>=0.28.1
Requires-Dist: Pillow>=10.0.0
Requires-Dist: psutil>=5.9
Requires-Dist: textual-image>=0.12.0
Requires-Dist: lmstudio>=1.5.0
Requires-Dist: websockets>=15
Requires-Dist: pyttsx3>=2.9
Provides-Extra: claude
Requires-Dist: claude-agent-sdk==0.2.159; extra == "claude"
Dynamic: license-file

# LiteTUI

## Agent-local CLI control

A registered seat from this build can advertise an authenticated loopback
JSONL endpoint using the existing GUI management dispatcher. Interactive and
`--rpc` seats share these operations; existing stdio clients are unchanged.
This code half adds the server and capability reader only. The corresponding
LiteHarness CLI adapter and live attachment proof are separate work; do not
assume the installed CLI exposes these verbs. No spawn, resume, replacement,
or reparenting operation is added to this endpoint.

The per-seat token is kept under the seat's data root in an owner-only file
(0600 on POSIX, protected user-only DACL on Windows). Presence advertises only
its path and runtime identity, never the token. If protection or ownership
cannot be established, attachment is unavailable rather than unauthenticated.
This is **OS-user-local control**, not protection from another process running
as the same user. No remote listener, automatic replacement or authority
reparenting is provided. A timeout after dispatch has an ambiguous outcome:
inspect state and do not automatically retry a mutating command.

`litetui --capabilities --backend B --model M` reads only LiteTUI's existing
pinned Claude catalogue or Codex model cache (no app, network, refresh or model
startup). Unknown offline capability fails loudly; Haiku does not support
`high`. Connected capability requests use the backend's catalogue and refuse
unknown models rather than guessing. There is no second CLI-owned model list.
Model load/context and engine start use their existing backend owners and VRAM
guards; local attachment does not bypass them. Unsupported remote load/context
or non-NInfer engine operations are errors, not successful no-ops. Positive
model/engine operations require separate runtime authorization and validation.

A terminal chat client and agent harness for **local** LLMs. Textual TUI,
streaming, tool use, vision, per-agent memory, and compaction.

**Two engines, one seam** (`src/litetui/llm_backend.py`): [LM Studio](https://lmstudio.ai)'s
desktop server, or LiteTUI's own `llama-server` from llama.cpp. Switch between
them mid-conversation with `/backend` — the history survives.

Originally built against `qwen3.8-27b` on an RTX 5090. LM Studio and llama.cpp run locally; the optional Codex OAuth backend sends inference requests to the hosted Codex service using subscription credentials.

```bash
uv sync
run.bat            # or: uv run --locked litetui
```

Either engine will do. When both are detected, a one-time picker runs at first
boot; `/backend` changes the answer later.

| engine | where | notes |
|---|---|---|
| `lmstudio` | `http://localhost:1234` | Needs LM Studio's server up. Control (load / **unload** / context length) goes through the official `lmstudio` SDK, not a `lms` shell-out. |
| `llamacpp` | `http://localhost:7470` | LiteTUI's own `llama-server` in **router mode**: one process, every chat GGUF on the box behind it. Attaches to LiteSuite's server on `:8088` when that is healthy, otherwise spawns its own — detached, console-safe, with a log file, and killed with the app. |

Neither engine loads anything at boot, and the router runs `--models-max 2` so a
model switch cannot quietly fill 32 GB of VRAM. The router's model list is
generated: LiteSuite's install dir, LM Studio's dirs, the HuggingFace cache and
any custom roots, deduplicated, with voice and embedding GGUFs filtered out by
the file's own `general.architecture` header rather than by guessing from names.

## Seat-scoped MCP servers

Use `litetui --mcp-servers none` for a scout/doc seat with no project MCP
servers, or `--mcp-servers SOTS_MCP_CORE,VibeUE` for an exact named set.
The default is `all`. This is invocation-only: it never rewrites `.mcp.json`
or shared settings. Unknown selected names produce a startup warning rather
than silently looking like an empty tool set. Native Codex receives process-local config overrides for
project registrations; unrelated global Codex servers are outside this scope.

Host-managed stdio servers start lazily by default. `/mcp` shows **not started
(lazy)** until `tool_search` loads one of their tools or a tool is called.
After the first real connection, names and schemas are cached in
`.mcp-tool-cache/` under the data root, keyed by launch config and executable/
argument-file timestamps. A cold or invalidated cache has no tool names yet:
an unmatched keyword `tool_search` discovers enabled cold servers on demand,
then answers from their live tools. This can start multiple cold servers once,
since their tool keywords are unknowable until discovery. An exact server-name
query or `select:mcp__server__tool` limits discovery to that server. Empty tool
lists remain cold. Parallel first calls wait behind startup/maintenance rather
than failing busy. HTTP servers retain their existing startup.
`--mcp-start eager` restores eager host startup.

Native Codex owns its direct MCP processes and fixed thread inventory; host
lazy-start/cache does **not** defer those native registrations. Seat selection
works there, but an included native server may still start at thread open.
There is no automatic idle stop. Disconnect/reconnect are explicit lifecycle
operations; no live project-server or RAM benchmark is implied by this feature.

## llama.cpp speculative decoding (opt-in)

In `/modelcfg` → **Load** → **Speculative decoding**, choose:

- **Unset**: preserve existing draft-model settings and server defaults.
- **Off** (`spec_type: "none"`): suppress all draft inputs, including a previously
  saved external draft path. Clearing the selector is not the same as Off.
- **External draft** (`draft-simple`): use an existing draft-model file.
- **Embedded MTP** (`draft-mtp`): use the target GGUF's prediction heads, ignoring
  any saved external draft path. Offered only when the installed binary supports
  the mode/flags and discovery finds a positive integer
  `<architecture>.nextn_predict_layers` in the model metadata—not its filename.

MTP uses max/min/probability defaults **3 / 0 / 0** when tuning is blank; explicit
values are preserved. Stored settings live in `llama_load_settings[model]`.
Eligibility is rechecked at apply/load time using file-identity-cached metadata.
INI regeneration validates the complete proposed preset before evicting resident
models; an already-served target load validates only that target's profile.
Applying load settings still
restarts an owned router and reloads the selected model; it does not restore
other residents. An adopted router's preset belongs to its owner and cannot be
rewritten here. If another model's stale profile prevents the restart, the
resident stays serving; the UI may already have saved the valid target profile.

Speculation is never enabled automatically. Performance depends on the model,
quantization and workload; enabling MTP is not a guaranteed speedup. No binary
upgrade or model benchmark is performed by this setting.

## Claude Agent (experimental native backend)

Install the optional, pinned runtime while LiteTUI is closed:

```powershell
uv sync --locked --extra claude
claude auth login
uv run --locked --extra claude litetui --backend claude
```

Or select `/backend claude` in an installed session. Authentication is delegated
entirely to the official SDK/CLI, matching LiteSuite's ClaudeAdapter: LiteTUI does
not read/copy login tokens or implement OAuth. The tested pair is Python Agent
SDK **0.2.159** and bundled CLI **2.1.281**. An explicit `claude_executable` setting
must report that tested CLI version; reconnect after changing it. Other backends
need no Claude extra. Anthropic's permission requirements for offering subscription
login in third-party products remain a separate distribution gate; a local smoke
test is not distribution approval.

**Claude owns its agent loop, tools and native context; LiteTUI owns compaction.**
LiteTUI renders text/thinking and native activity, persists session references and
input delivery, and supplies guarded host services through in-process MCP.
Native calls pass mandatory `PreToolUse` policy checks; host calls pass the existing
LiteTUI dispatcher. Questions and approvals work in TUI and JSONL RPC.

- `/claude status`: native identity and pending delivery states (not prompt content).
- `/claude new`: fresh independent segment; old native history and held inputs stay saved.
- `/claude continue`: explicitly continue unsent prepared inputs after restart.
- `/claude resolve`: explicitly resolve uncertain deliveries **without replaying them**.
  Inspect their effects/history first; this is not rollback.
- Returning to Claude resumes the selected Claude segment. Other-provider history
  is **not** imported. Busy prompts are durably queued for their original segment.

LiteTUI reads Claude's effective session window before sending, rather than treating
all model IDs as 1M or using another model's cumulative usage. It pauses native tool
turns at the configured auto-compaction threshold, drains the interrupted turn,
summarises, and admits one continuation in the fresh session. Existing local-backend
tool-loop pauses also resume once. User stop, newer input, and changed owners win;
failed or uncertain turns are never automatically replayed. This interrupted-turn
recovery does not enable the optional `wake_after_compact` preference: ordinary
manual compaction still follows that preference.

A failed window measurement holds the input unsent and keeps the session alive;
`/claude continue` retries once the metadata service recovers. A preflight-held,
non-RPC input remains prepared if summarising fails. On success, its original
content, profile and source become a fresh prepared input, recoverable through
`/claude continue` even if the automatic continuation is cancelled. Already delivered
work receives a resume instruction, not a replay of the original prompt or tools.
An RPC input with an `operation_id` is not transferred between sessions: when already
above threshold, inspect `/claude status`, run `/claude new`, then resend as a **new**
RPC operation ID. The old held input remains saved without replay; `/claude continue`
cannot shrink that full session. A session already beyond its hard limit may also
refuse the summary request; no success or automatic recovery is claimed in that case.

Native agents/background launches, image attachments, host goal-loop followups,
legacy subagent/summary calls, raw native slash passthrough and
local loading/context/sampling controls are not enabled. Unsupported calls are
refused rather than sent through an OpenAI fallback. Unknown context/usage remains
unknown; cumulative cost is not described as per-turn cost.

Initial configuration is controlled: user/project/local settings, external MCP,
skills and Chrome integration are not automatically loaded. Native prompt preset
plus a small LiteTUI append replaces the local-backend system prompt. Changes to
tool inventory or session-creation prompt/configuration require a new session.
The SDK retains its own session files; LiteTUI's companion `claude_ledger.json`
tracks selected segments and prepared/submitted/terminal/uncertain deliveries.
String-query delivery does not currently supply a stable native user-message UUID;
LiteTUI does not fabricate provider acknowledgment. In-flight deliveries become
uncertain after restart and are never automatically replayed. Native activity
cards are saved at turn settlement; a mid-turn crash can lose those display cards,
not the SDK's authoritative history. There is no old-segment picker or rollback.

Live probes are **opt-in**, not default tests: `e2e/claude_contract_probe.py`
(isolated offline controls; `--with-query` uses no credentials and an unreachable
endpoint), `e2e/claude_live_probe.py`, and `LITETUI_CLAUDE_LIVE=1` for
`e2e/claude_tui_smoke.py`, `e2e/claude_rpc_smoke.py`,
`e2e/claude_negative_probe.py`, `e2e/claude_timeout_probe.py`, and
`e2e/claude_restart_probe.py`. The restart probe uses two real OS processes and
checks native identity, context continuity, foreign-history isolation, and owned
child-process cleanup. It does not run inference on a second provider.
Evidence and remaining release gates are in
`artifacts/claude-implementation-progress.md`.

## What it does

**Chat + agent loop.** Streams replies, renders the thinking trace in a
collapsible block, and runs a tool loop until the model gives a plain answer.

**Four tools**, pi-style, capped at 2000 lines / 50KB per result:
`bash` · `read` · `write` · `web_fetch`. Toggle with `Ctrl+T`.

**Vision.** Paste an image with `Ctrl+O`, or give it a path — quoted or bare,
with or without a question after it:

```
C:\shots\screen.png what is the error in this dialog?
"C:\My Folder\screen.png"
```

> A model can only see images if LM Studio reports it as `type: vlm`. A GGUF
> shipped without an `mmproj` projector loads as `type: llm` and returns
> HTTP 400 on image input — the fix is to place a matching `mmproj-*.gguf`
> beside the weights, not to change anything here.

## Conversations

Every launch opens an **agent home**, and every conversation lives inside one:

```
.agents/<Name>/
    settings.json  the agent's backend, model and thinking level
    memory.md      an INDEX the agent maintains
    soul.md        who it is here: preferences, standing corrections
    handoff.md     in flight / owed / absent-by-decision / caveats
    memories/      the memories themselves, one file per idea
    conversations/<uuid>/
        convo.jsonl    append-only transcript
        settings.json  this conversation's contextual settings
```

A plain `litetui` launch opens the agent named `LiteTUI` by default (your seat
name, when you have set one); if that home is busy, the launch gets its own,
`LiteTUI-<8 hex>`. `/resume` picks an agent first. Memory, soul and handoff
belong to the agent and are shared by all of its conversations.

Upgrading from 0.25.0: conversations saved under `.convos/<uuid>/` stay on disk
as a **read-only archive**. They are not migrated and cannot be continued;
`litetui --convo <old id>` explains how to export one (`--export-conversation`).

The agent is told its own uuid and absolute path in the system prompt, and
`memory.md`, `soul.md` and `handoff.md` are injected **once**, into the system
message at the start of the conversation — a snapshot, not a live view. They
are not re-sent each turn: three files on every request is affordable at 1M
context and is not on a local 27B, where it crowds out the conversation. To
see current contents the agent reads them with the `read` tool, and the prompt
says so. Index lines are capped at ~50 tokens: pointers, never the memory itself.

### The transcript is append-only, always

`convo.jsonl` is never rewritten and never backed up and replaced. A torn
append costs one line, which the reader tolerates; a failed rewrite costs the
conversation. Record types applied in file order:

| type | effect |
|---|---|
| `meta` | id / created / model |
| `msg` | one message, verbatim |
| `edit` | replace ONE message in place |
| `truncate` | drop a head range, splice new messages in front |
| `snapshot` | whole list (read for older files; no longer written) |

`/compact` records a `truncate` rather than a fresh copy of everything.
Measured on a 30-message conversation: **303 bytes instead of 5,445** — and the
raw history stays on disk behind the marker.

## Commands

| | |
|---|---|
| `/new` `/clear` | start a new conversation (new folder on disk) |
| `/system <text>` | set the system prompt |
| `/model [n\|name]` | show or switch the agent's model; startup defaults stay unchanged |
| `/backend [--default] [name]` | switch the agent's backend; only `--default` also changes the startup default |
| `/load` `/unload` | put a model into memory, or free it |
| `/modelcfg` | per-model Info / Load / Inference screen (see below) |
| `/think [level]` | `off · minimal · low · medium · high · xhigh · unset` |
| `/compact [hint]` | summarise older messages, keep the last 4 |
| `/convos` | list saved conversations with sizes |
| `/resume <n\|id>` | load one (id = uuid prefix) |
| `/reconnect` `/quit` | |

`Esc` stops the current turn (asks first; `Esc` again forces). `Ctrl+O` paste
image · `Ctrl+X` clear image · `Ctrl+L` new conversation · `Ctrl+T` tools.

### `/modelcfg` — a control an engine cannot drive is greyed, never hidden

Three tabs per model: **Info**, **Load**, **Inference**. Load covers context
length, GPU offload, threads, batch sizes, parallel slots, flash attention, KV
cache quantization, mlock/mmap, RoPE, seed, a draft model for speculative
decoding, a vision `mmproj`, and the chat template. Inference layers per-model
sampling over your global `/settings`, plus structured output (a JSON schema)
and named presets.

The two engines do not expose the same knobs. **A control the active engine
cannot drive renders greyed with the reason attached** — it is never silently
absent and never a switch that does nothing. That distinction is the point: a
missing control is a question, and a fake one is a bug you find much later.

### `/think` — unset is not off

`unset` sends no `reasoning_effort` at all, and **LM Studio's own default for an
absent value is `xhigh`**. So "unset" means maximum thinking, not none.
`/think off` is a different thing and sends `reasoning_effort: "none"`.

The six accepted values come from the server's own 400 body, not its docs,
which omit the parameter entirely:

```
Invalid 'reasoning_effort' value: 'x'.
Supported values: none, minimal, low, medium, high, xhigh.
```

### `/compact` writes to memory before it summarises

Compaction is the moment context is about to be destroyed, so the compaction
prompt runs in two steps: **persist first** (durable lesson → a new file in
`memories/` plus one pointer line; a correction → `soul.md`; anything in flight
→ `handoff.md`), **then** produce the summary. Tools are passed to that call so
the instruction can actually reach disk — without them it would be theatre.

It reports which of the three happened, including
`persisted: nothing — TOOLS ARE OFF, so it could not write`.

The summarisation call forces `reasoning_effort: "none"` regardless of
`/think`: at the server default a 12k budget was spent entirely on the thinking
trace and returned an **empty** answer, and a compaction that returns nothing is
worse than not compacting.

## Skills

`skills/<name>/SKILL.md`, same shape Claude Code uses — YAML frontmatter with
`name` and `description`, then the body.

Only the **index** (name + one line each) goes into the system prompt. The body
loads on demand through the `skill` tool. Inlining every body would spend the
context window on instructions the model doesn't need this turn; an index with
no way to open it would be worse.

A directory with no `SKILL.md` is skipped. One that can't be read becomes a
skill whose description says so — a skill that vanishes on a decode error looks
exactly like one that was never written.

## MCP

A standard `mcp.json` in the repo root; `.mcp.json` (the Claude Code project
convention) is read too when present, with `mcp.json` winning a name collision:

```json
{ "mcpServers": {
    "files": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
    "litesuite-tools": { "type": "http", "url": "http://localhost:7423/mcp" }
} }
```

An entry with a `url` and no `command` is reached over plain-JSON HTTP POST —
one request per call, the response body IS the JSON-RPC reply (the stateless
variant of MCP streamable-HTTP; what LiteSuite's `/mcp` endpoint implements).
Everything else is spawned as a stdio child.

Each server's tools are registered as `mcp__<server>__<tool>` and dispatched
like any other tool, so the agent loop has no MCP-specific branch. `disabled:
true` skips one.

A server that fails to start is **recorded, not swallowed** — the others still
load. A tool that silently never appears looks like one the model chose not to
use.

> Server stderr goes to `mcp.log`, never to the terminal. An MCP server is a
> long-running child process, and a child that inherits this console paints
> straight over a TUI that owns every cell.

## Two instances at once

**Running two LiteTUI windows against one model server is supported, and they run
in parallel.** llama.cpp and LM Studio both serve concurrent requests; two
instances on the *same loaded model* cost one set of weights and do not wait on
each other.

### The one thing you will be asked about: a *different* model

Loading a **different** model in a second instance puts a **second set of weights
in VRAM**, and on a card that was already full that is an OOM. So whenever
another LiteTUI is running, a load that would add a model you do not already have
resident stops and asks first:

> Another LiteTUI instance is running (another session).
> Loading a different model puts a second model in VRAM and can OOM depending on
> your setup. Load qwen/qwen3-8b anyway? **[Load] [Cancel]**

- It asks **every time**, for as long as the instance is alive. There is no
  "don't ask again" — the cost is real every time, not just the first.
- It asks on **every** route into a load: `/model`, the picker, a `/modelcfg`
  context change (a reload *is* a load), the pre-turn load, and the rpc
  `set_model` a host drives. The check lives inside the backend, so a route
  added later is covered without anyone remembering to add it.
- The **same** model raises nothing. That is the parallel case, and it is the
  point.
- A **headless** child (LiteSuite's rpc LiteTUI) cannot show a modal, so it
  refuses instead of deciding for you, and the host renders the refusal.

"Another instance" means a LiteHarness registry row with `cli = litetui` and a
live session — which includes LiteSuite's headless children, because they load
models too.

### The agent remembers its own setup

An agent's backend, model and thinking level are saved in its home,
`.agents/<Name>/settings.json`. Switching model or engine publishes there first,
so the agent keeps its choice across launches; another agent you have open does
not move. A conversation's own `settings.json` keeps the contextual knobs (the
codex reasoning effort, which is a separate vocabulary, and the llama.cpp or LM
Studio load settings for the model in use), and the global `settings.json` keeps
being the **defaults** a new agent is born from, plus the app-wide knobs (theme,
seat name, dialog style).

Resuming a conversation does **not** restore the backend, model or thinking
level it was saved with: the agent's home decides, and a saved load setting for a
different model is reported as not applied. A new agent home with no default
model asks you to choose one with `/model` before the first send. A launch flag
(`--backend`, `--model`, a thinking level) that differs from the agent's saved
setting is refused with an explanation, and `LITETUI_MODEL`, `LITETUI_THINKING`
and `LITETUI_BACKEND` only seed a new agent home; change a saved agent with
`/model`, `/backend` and `/think`. Reconnecting does not request
a local model load. If the saved model/provider is unavailable or connection
fails, sending is blocked with an explanation; choose an available model/provider
or retry `/reconnect` rather than silently falling back to another model.

### The shared `settings.json`

Two processes from one checkout share one data root and therefore one
`settings.json` — 70 keys, one file. A save reads it, writes back only the keys
*this* instance changed, and replaces it atomically, so the think level you set
in one window survives a theme change in the other. Env-sourced fields
(`LITETUI_MODEL`, `LITETUI_THINKING`, `LITETUI_BACKEND`, …) are written on every
save: the file records what you *chose*, so unsetting a variable must not
silently revert the knob.

⚠️ **`background-tasks.json` no longer lives in the data root.** Each conversation
keeps its own `background-tasks.json` in its conversation folder, so two windows
only meet in a store when they share a conversation (which the session lease
forbids). The old shared file is neither read nor written, and its rows are not copied
into a conversation; task output logs still live in `output/tasks/`.

⚠️ **The model ceiling is shared too.** The llama.cpp router holds at most
`--models-max` models and that limit belongs to whichever instance started it, so
a load can push out a model the other window is mid-turn on. The instance doing
the loading names what is resident before it displaces anything.

### Or keep them completely separate

Give one its own root before it starts:

```bash
LITETUI_DATA_ROOT=~/.litetui-b litetui
```

That instance gets its own `settings.json`, `.agents/`, tasks and jobs — and,
being a separate data root, no shared anything. `LITETUI_SEAT_NAME` names the
default agent; `LITETUI_MODEL`, `LITETUI_THINKING` and `LITETUI_BACKEND` seed a
new agent home and do not change a saved one.

## Launching backends from a terminal or another agent

Run `litetui --help` (or `python -m litetui.cli --help`) for the complete argument
contract. Unknown flags are errors. Options apply to this invocation and do not
overwrite saved defaults; on an existing agent, a backend, model or thinking
level that differs from the agent's saved one is refused.

Normal launches open the TUI in the current terminal. Add `--rpc` for JSONL over
stdio. Agents must wait for the `ready` event with `launch_status: "ready"` and
check its `backend`, `model`, `base_url`, `thinking_level` and `context_length`
before sending a `{"type":"prompt","message":"..."}` command. Failed startup or
an unavailable explicit model blocks the launch prompt. `--prompt` can supply an
initial prompt for a headed launch; avoid putting sensitive text on process argv.

| Backend | Connect/start | Model and context |
| --- | --- | --- |
| `custom` | `--base-url URL`; optional `--start-server --server-command JSON` | Server-native arguments load the model. `--model` selects an advertised ID; `--context-length` declares the client budget, not a server resize. |
| `llamacpp` | `--server-mode connect` or `--start-server`; optional `--server-executable PATH` and `--base-url URL` | `--load-model --model ID --context-length N`; optional `--model-path GGUF` adds its directory to discovery. |
| `lmstudio` | `--server-mode connect` or `--start-server` using installed `lms server start` | `--load-model --model ID --context-length N` uses the LM Studio SDK. |
| `ninfer` | Connect to discovery or `--base-url URL`; `--start-server` uses an allocated port | Startup accepts `--model-path ARTIFACT --context-length N --server-executable PATH`. `--model` must match the served ID. Requires RTX 5090. |
| `strata` | Connect to the install's port (8080) or `--base-url URL`; `--start-server` runs the install's own server | [Strata](https://github.com/Niko1221/Strata) runs Qwen3.8-Flash-Next on one GPU plus system RAM. `--model-path strata-MODEL.json` picks a prepared model; `--context-length N` with `--start-server` starts it at that size. Install it and download models from LiteSuite's Model Hub, or with Strata's own `START-HERE.bat`. |
| `codex` | Subscription login; connects using the configured Codex adapter (native app-server is managed by that adapter when enabled) | `--model ID --reasoning-effort LEVEL`. Endpoint, context and output limits belong to Codex; local-server overrides are rejected. |

For Codex, `--codex-engine native` selects the official app-server agent loop;
`--codex-engine http` selects LiteTUI's own loop over the subscription HTTP API.
Precedence is **CLI flag > `LITETUI_CODEX_ENGINE` > saved conversation/global
`codex_native_engine` setting > `http` default**. The environment variable is
resolved at CLI startup and accepts the same lowercase `native`/`http` values;
an unset or empty variable leaves the saved setting alone. An invalid selected
value is a startup error (an explicit valid flag supersedes the environment).
Both inputs are invocation-only, including on resume: unrelated settings saves
and new conversations do not remember the override. The existing Settings →
Engine toggle remains the way to save a preference. Neither input selects the
backend: use `--backend codex` or an already Codex-configured agent.

```powershell
litetui --backend codex --codex-engine native
litetui --backend codex --codex-engine http
$env:LITETUI_CODEX_ENGINE = 'native'
litetui --backend codex
Remove-Item Env:LITETUI_CODEX_ENGINE
```

`--server-mode auto` preserves each backend's existing behavior. `connect` never
starts an inference server. `--start-server` explicitly authorizes starting one;
it does not restart or evict a server already running. An explicit llama.cpp URL
never falls back to another router. `--load-model` explicitly authorizes loading
or reloading on llama.cpp/LM Studio; context changes require this flag.

`--thinking-level` and `--reasoning-effort` are mutually exclusive aliases.
`none` is accepted as an alias for UI `off`. Available levels depend on the
backend/model: NInfer uses `off/low/medium/xhigh`; Codex uses its model metadata;
custom servers receive the selected value and their template defines support.
`--max-tokens` sets the output limit for local/custom servers. Use
`--server-timeout SECONDS` for slow model loads (default 600).

### Codex HTTP failure handling

The OAuth/Responses transport retries HTTP **500/502/503/504** and selected
connection failures up to **two times**, with 0.5s then 1s backoff, only before
that completion request emits any text, reasoning, tool delta or usage chunk.
The same retry budget covers connection loss while waiting for the first chunk.
A retry resends that request, not the agent turn; previously completed host tools
are not rerun. The server may still have processed a failed request before the
connection broke, so this is not an exactly-once generation guarantee.

After any output, failure is surfaced without automatic replay. SSE
`error`/`response.failed`/`response.incomplete` events report their supplied
code/message/reason (including `max_output_tokens` or `content_filter` when
present), rather than guessing that context is the cause. These events are not
automatically retried. Bounded, secret-redacted diagnostics also go to
`.logs/runtime-errors.log`; raw response bodies and authorization headers do not.
HTTP server failures name the status and retry count rather than blaming login.
Existing 401 login refresh, 403 access refusal and 429 usage-limit behavior is
unchanged. Native Codex app-server retry behavior is separate and unchanged.

### Codex tool-argument diagnostics

The OAuth/Responses transport preserves terminal-only function arguments from
argument-done, item-done and completed events, including expanded parallel
batches. It appends only the missing suffix, not duplicate JSON. Conflicting
snapshots fail before pending calls are released. This is a demonstrated protocol
fix, not a conclusive attribution of historical missing-argument incidents:
saved conversations retain assembled calls, not the original stream. There is
no local `multi_tool_use.parallel` unpacker.

`LITETUI_CAPTURE_TOOL_EVENTS=1` enables **metadata-only** diagnostics for a future
explicitly authorized session (OFF by default; no restart/probe is implied).
Only a currently leased agent can append to its own conversation's
`tool-call-stream.jsonl`. Ownership and non-linked paths are checked on every
append; linked/reparse-point paths, hard-linked files and unowned fallbacks are
refused. No arbitrary output path or raw-argument mode exists.

Records contain event type, per-request sequence, bounded call ID/tool name, and
argument/delta **character count** only. No argument content, headers, auth,
reasoning, output text or tool results are saved: arguments can contain sensitive
credentials, paths and private text. This instrument shows event order and where
nonempty fields arrived, but cannot reconstruct a wrapper or validate its JSON.

Retention is one fixed file per conversation, capped at **1 MiB**. At the cap,
recording stops without rotation, deletion or growth. Missing folders are not
created. Records append across requests (sequence numbers restart); disabling the
flag does not remove evidence. Explicit owner cleanup is separate. This uses the
store's normal lease/path protections, not a sandbox against hostile filesystem
races. Storage refusal only stops diagnostics, not the model reply.

`parallel_tool_calls` remains `True`. Setting it to `False` would ask the provider
for at most one function per response, serializing independent tools into extra
model round trips (latency/token cost). It would neither repair received batches
nor guarantee provider compliance; no new parallel-disable switch was added.

Examples (PowerShell):

```powershell
# Existing Prism or another OpenAI-compatible server, no environment override needed
litetui --backend custom --base-url http://127.0.0.1:1235/v1 --model ternary-bonsai-2-27b --thinking-level off --context-length 32768

# Start our llama.cpp router, then load a model before accepting the first prompt
litetui --backend llamacpp --start-server --load-model --model qwen3.8-27b-q4_k_m --context-length 32768 --thinking-level medium

# Start LM Studio's API server (LM Studio/lms must already be installed)
litetui --backend lmstudio --start-server --load-model --model qwen/qwen3.8-27b --context-length 32768 --thinking-level medium

# Start NInfer from an explicitly chosen artifact; verify its advertised model ID
litetui --backend ninfer --start-server --model-path D:/models/model.ninfer --model YOUR_SERVED_MODEL_ID --context-length 32768 --thinking-level xhigh

litetui --backend codex --model gpt-5.6-sol --reasoning-effort high

# Custom executable arguments are a JSON array, never a shell command.
# A PowerShell script file avoids nested Start-Process -Command quote loss.
$serverArgs = @('D:/Prism/llama-server.exe', '-m', '{model_path}', '--alias', '{model}', '-c', '{context_length}', '--host', '{host}', '--port', '{port}', '--jinja') | ConvertTo-Json -Compress
litetui --backend custom --base-url http://127.0.0.1:1235/v1 --start-server --server-command $serverArgs --model-path D:/models/bonsai.gguf --model bonsai --context-length 32768 --thinking-level off
```

Custom command placeholders are `{model}`, `{model_path}`, `{context_length}`,
`{host}` and `{port}`. Add native flags (GPU layers, projector, cache settings)
as separate array elements. Only loopback endpoints can be started locally.
Output goes to `custom-server.log` in the data root. LiteTUI cleans up custom
processes it starts; attaching to an existing server never claims ownership.
For authentication use `--api-key-env VARIABLE_NAME`; the secret stays in the
environment. Custom URL, key-variable name and declared context budget are also
available under `/settings`, and `/backend` includes Custom.

The internal `LaunchSpec` accepts the same controls in its optional `launch`
object (`base_url`, `server_mode`, `load_model`, `context_length`, `max_tokens`,
`server_executable`, `model_path`, `server_command`, `api_key_env`, `timeout`).
Its supervised headless path supports all five backends and verifies readiness
before forwarding the prompt. For headed launches use the public CLI in a visible
terminal; the internal headed transport remains unavailable.

## Harness seat

LiteTUI registers as a LiteHarness agent and monitors its own inbox, so other
agents can reach it and mail **wakes** it — the message is delivered as a turn.

It does **not** run `liteharness.hooks watch` or `check_inbox`. Both are
consumers that move files out of `inbox/new/` for whichever agent id they
resolve, and a second consumer on a shared mailbox is the defect the
`ls-liteharness` fix retracted — mail vanished for three hours. `watch_inbox`
also writes to stdout, which a Textual app cannot survive.

Instead it reads the maildir directly under one rule:

> **Only ever touch a file whose `to` is this agent.**

Anything addressed elsewhere is left in `new/` exactly as found — unread,
unmoved, unclaimed. Expired messages (past `ttl_minutes`) are cleared without
delivery; the agent's own echo is skipped.

### Approval replies and explicit authority changes

A pending RPC approval widget also accepts an addressed inbox line
`APPROVE appr-<id>` or `DENY appr-<id>` from the spawner recorded when that request
started. It resolves once, never remembers permission; a wrong sender, addressee,
unknown/expired id or duplicate cannot grant approval. The RPC host's own answer
path is unchanged. Ordinary mail is still delivered as a turn.

Spawner authority is captured from the launch identity envelope at process startup,
not read back from registry metadata. Editing `spawned_by` in a presence row or
re-registering a running seat does **not** grant another leader approval authority.
After a leader restart, changing that authority is an explicit security action:
use the supported `liteharness spawn --resume <id> --kill-old --spawned-by <newleader>`
flow to start the resumed seat with the new launcher identity. There is no automatic
parent-lineage redirect, registry takeover or fallback to a different approver.

## Tests

### Focused local merge check

Before integrating a candidate, the reviewer names its affected production domain,
existing neighboring tests, and intentional omissions in a JSON evidence file:

```json
{
  "base": "<full target commit ID before integration>",
  "candidate": "<full reviewed worker commit ID>",
  "domain": "sidecar jobs and owned app startup",
  "review": "<reviewer and durable review reference>",
  "neighbors": ["tests/test_sidecar_jobs_t1082.py", "tests/test_sidecar_reader.py"],
  "exceptions": []
}
```

On a **clean, committed integration-preview tree** containing both commits, run:

```bash
python tools/merge_gate.py --base <target-before> --candidate <worker-tip> --review-evidence <review.json> tests/test_new_regression.py
```

Use the project's existing development interpreter; the final positional test
files are optional additions. Keep evidence outside the checkout or in an ignored
scratch directory. The command requires a nonempty domain, review reference and
neighbor selection; neighbors must already exist at the base commit. Evidence
must name the exact resolved base/candidate IDs. `exceptions` must be present,
with reasons for intentional omissions (or `[]`), and cannot bypass required tests.

Every run includes `test_autoscroll.py` and `test_sidecar_jobs_t1082.py`: these two
files were found red on main by neighboring checks on 2026-10-07. This is a small
incident-derived floor, **not automatic dependency discovery**. Select additional
existing neighbors by tracing the changed behavior, not just the new test file.
The command reuses `run_all`'s inventory and per-file collection accounting, runs
only the selected files with their proper runner, checks `git diff --check`, and
fails on errors, zero-item selected files, timeouts, or a changed/dirty tree.
Inherited `PYTEST_ADDOPTS` and configured pytest `addopts` are deliberately cleared
so they cannot silently expand the selection. Other pytest configuration remains.
Its PASS names the exact HEAD tested; rerun if the integrated tree changes.

**Enforced:** evidence shape and commit identity, existing-neighbor selection,
commit ancestry, clean stable tree, and selected-check exit status.
**Reviewed by people:** whether the domain and neighbors cover the actual change,
exceptions are justified, and the review reference is genuine. This command does
not authenticate the reviewer, install hooks, merge, or enforce GitHub branch
protection. The merge owner must require it on the actual integration tree before
accepting that tree; a worker-only green is not integration evidence. Required
review, current-intent and human-look gates remain separate.

### Full validation (when explicitly requested)

```bash
uv run --locked python tests/run_all.py
```

Believe the exit code rather than a pass count. The runner maintains a reviewed
inventory of pytest files and legacy standalone scripts; bare `pytest` is not a
substitute for that inventory. CI's full validation is unchanged. Neither engine
nor network is required by the selected unit tests; no live-service checks are
included in the focused merge command.

## Bundled

`chrome-bridge/` — drive Chrome from Python (navigate, read page, click,
screenshot) via an MV3 extension. `pccontrol/` — Win32 mouse/keyboard/window
automation and OCR helpers. Both are separate tools that happen to live here;
`chrome-bridge` also has a canonical copy elsewhere, so treat this one as a
snapshot rather than the source of truth.
