Metadata-Version: 2.4
Name: ai-agent-gateway-cli
Version: 0.9.2
Summary: Canonical dev CLI client for ai-agent-gateway
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: ai-agent-gateway[anthropic]==0.32.1
Requires-Dist: hank-model-authority>=1.7.32
Requires-Dist: hank-model-transport<2,>=1.0.2
Requires-Dist: httpx>=0.28.1
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: httpx>=0.28.1; extra == "dev"

# ai-agent-gateway-cli

Python client for the `ai-agent-gateway` HTTP/SSE protocol, shipped as the `hank`
entrypoint.

Owner doc: [`docs/reference/AGENT_INFRASTRUCTURE.md`](../../docs/reference/AGENT_INFRASTRUCTURE.md) — gateway clients and the `/control` API.

## Usage

```bash
python -m agent_gateway_cli login
python -m agent_gateway_cli chat "hello"
python -m agent_gateway_cli --config-namespace cashnerd login
python -m agent_gateway_cli --config-namespace cashnerd chat --session default "hello"
hank login
hank chat
hank chat "hello"
hank chat --model-key anthropic.claude-sonnet-5 --effort high \
  --model-key-for plan.author=anthropic.claude-opus-5-5 \
  --effort-for plan.author=high "research this company"
hank chat --model-key anthropic.claude-opus-5-5 --save-preference "remember this choice"
hank preset
hank chat --model-preset <published-name> "run this under a published preset"
hank chat --show-thinking "walk me through the margin bridge"
hank replay ~/.local/state/hank/evidence/isolated-gateways/<key>/ --session sess_<id>
hank control health
```

The default config namespace is `agent-gateway` for `python -m agent_gateway_cli`
and `hank` for the `hank` entrypoint, which writes:

- `~/.cache/hank/cli_config.json`
- `~/.cache/hank/sessions/<name>.json`

`--config-namespace cashnerd` writes under `~/.cache/cashnerd/` instead. The
namespace directory and its `sessions/` directory are created mode 0700; the
files inside are 0600.

Each named session file holds a local transcript, which is display and
persistence only, and the gateway session identity: the live session id and
token, and the conversation's durable id (`log_session_id`, the first session's
id), which is set when the first chat POST is accepted — init alone does not
establish a durable conversation, so a session that never sent one re-inits
plainly. Each turn sends only the new user message; the gateway replays the
conversation from its session log. When the stored token is no longer usable
(the session's roughly one-hour TTL has passed, or the gateway restarted), the
CLI opens a new gateway session with `resume_session_id` set to the durable id,
and the conversation continues for a session the caller owns. If the gateway
refuses the resume, the CLI prints a notice and starts a new conversation.
`--new` clears both the transcript and the identity.

`GatewayTransport` and `AsyncGatewayTransport` (`agent_gateway_cli/transport.py`,
with `session_map.SessionMap` for per-conversation identity) are the one Python
owner of the gateway chat client contract — init and resume, token reuse, the 401
rule, SSE frame parsing, re-attaching a dropped stream via `/chat/subscribe` from
the last delivered `seq`, cancel, tool approvals and results — consumed by
`hank chat`, `api/dev/chat_cli.py` and, for frame parsing only, the control
client; the contract they mirror is [the consolidation design record](../../docs/design/gateway-chat-client-consolidation-design-2026-09-29.md).
Every `StreamEnvelope` carries `received_at`, and `stream_turn` yields the
gateway's `:keepalive` as a seq-less `transport_keepalive` envelope that never
moves the reconnect cursor. A `stream_error` is yielded and changes neither when
the turn ends nor how it re-attaches, but a turn that then fails carries the last
`stream_error`'s detail in its error message. A chat stream that sends no byte
for `STREAM_IDLE_SECONDS` (`agent_gateway_cli/transport.py`; the transports'
`stream_idle_seconds` option) is a dead transport: the read times out and the
turn re-attaches through `/chat/subscribe` like a dropped connection; the server
turn is never cancelled.

No client wait is a minted cap. `/chat/init` and the non-stream posts have no
request timeout, and a turn re-attaches for as long as the server still holds
it, repeating the last reconnect delay. A turn-busy 409 is not retried: the
chat command surfaces it (`TurnAlreadyRunning`). The cancel post is bounded
only by a caller's own timeout. The returned session
carries `elapsed_s` and `reconnects`, the cancel returns
`cancel_outcome` and `cancel_elapsed_s`, and a transport failure names its cause
and the seconds it waited.

Async callers implement `AsyncSessionHolder`; `MemorySessionHolder` and
`SessionMap.holder(key)` supply it. Same-key holders share live identity and
coordinate init with POST acceptance, releasing the lock before SSE reading.
The streaming holder still pins its own session for approvals.
`cancel_chat`, `submit_approval` and `submit_tool_result` take the holder and
post on the in-flight session the caller passes; a dead-bearer 401 refreshes the
holder for the next turn and the request is never replayed on the new token.
`SessionMap.invalidate(key)` retires that conversation for every old holder;
delayed init or POST completion cannot save it over the key's new identity.

`hank chat` with no message on an interactive terminal replaces the process with
the `agent-gateway-tui` binary (installed with
`npm install -g @henrychien/agent-gateway-tui`, whose `engines` field declares
Node >= 24), passing the resolved namespace, session and any
`--research-file-id` as arguments and the resolved connection as
`GATEWAY_BASE_URL`, `GATEWAY_API_KEY`,
`GATEWAY_USER_ID`, and `AGENT_GATEWAY_ROUTE_PREFIX`. The terminal reads no
per-turn selector, so `--skill`, `--mode`, `--model-key`, `--effort`,
`--model-key-for`, `--effort-for`, `--model-preset`, `--save-preference`,
`--clear-preference`, `--raw`, `--drain-trailing` and `--new` given there are named in one stderr note
and the terminal launches; they apply to the message form. `hank chat "<message>"` and
any non-interactive stdout run the Python client in this package instead.

`hank replay <path>` opens a retained chat transcript in the same terminal binary
without connecting to a gateway: `<path>` is a `sess_*.jsonl` file, a directory
holding them, an isolated instance's records root (`state/logs/chat/`) or a
checkout (`api/chat_logs/`), searched in that order. `--session <sess_id>` picks
one transcript, otherwise the newest by mtime; the chosen file is printed on
stderr and passed as `agent-gateway-tui --config-namespace <ns> --replay <file>`.

The Python client prints streamed thinking only with `--show-thinking`: the
model's summarized thinking appears dimmed on a `[thinking]` line ahead of the
`[text]` reply. Without the flag, thinking deltas go to the debug log only. The
TUI shows thinking by default and needs no flag.

When citations trigger an answer revision, the Python client prints
`— revised for citations —` in `hold` mode and saves only the revised answer.
In `append` mode it keeps the draft, then prints and saves a
`Citations corrected:` block. `--raw` still prints each original event in order;
`--drain-trailing` still controls capture of verdict events after the terminal.

When the provider refuses a turn (a `refusal` event), the Python client keeps the
text already delivered, prints `[refusal] <category>: <explanation>` and
`[refusal] <guidance>` after it, and exits 0; nothing is reset, and the guidance
is to start a fresh conversation before continuing.

When the gateway reports a frame it could not deliver (a `stream_error` event),
both the Python message CLI and `api/dev/chat_cli.py` print
`[stream_error] <error>` where it arrived and keep reading; the turn ends on its
own terminal, and the exit code follows that terminal.

Both the Python message CLI and `api/dev/chat_cli.py` stop cleanly (exit 0) on
`stream_complete` with `terminal_disposition: "awaiting_approval"`. They show a
neutral parked notice with `approval_id`, `job_id`, and `root_turn_id` when
present, plus usage, without re-prompting or continuing automatically. The
attempt remains open in the gateway's durable log; like an interrupted turn,
the packaged CLI does not append it to local history. Completed turns alone
commit local history; unknown terminal dispositions remain protocol errors.

Automated callers of either transport can pass `resume_after_approval=True`
to `stream_turn`: each approval park is yielded, then the transport re-attaches
through `/chat/subscribe` using the last delivered `seq`, until a non-approval
terminal. The caller still submits every approval vote; the transport does not
approve tools. This opt-in is client-side only and does not change message CLI
behavior or send a new user turn. Continuation events must keep the run's
monotonic sequence so the cursor includes every later approval and terminal.

## Interrupt one background child

While the owning conversation is running, use a second terminal:

```bash
hank interrupt <task_id> --session <conversation-name> --json
```

This posts on that conversation's saved bearer to
`/chat/background/<task_id>/interrupt`, without starting a new session or
cancelling the parent or siblings. It never refreshes or replays a control;
an expired bearer is an error, not a new conversation. The selected gateway
must match the saved session's gateway. Exit 0 means `interrupt_requested:
true`; exit 1 means no interrupt was accepted or the request failed. Stdout is
the JSON acknowledgement, not a claim that settlement finished.

Wait for the child's automatic interrupted notification, then ask the parent
to call `run_agent(resume=<original-task-id>)`. The parent can also interrupt
with `run_agent(interrupt=<task-id>)`. Neither control accepts spawn/model
overrides. `hank tool run_agent --interrupt ...` is not an in-flight control:
the ordinary tool door cannot run alongside that conversation's active turn.

## hank tool

Execute one admitted operation without a model turn, using its registered name:
`hank tool <tool_name>`. There is no `tool run` wrapper, domain prefix, kebab
alias or second catalog. FMS tools use their registered `local_tool_name`;
FMS's separate process spelling and exit codes are unchanged.

```bash
hank tool ls --json
hank tool run_optimization --schema --json
hank tool run_optimization --example --json
hank tool run_optimization --args '{"portfolio_name":"owned","optimization_type":"max_sharpe","format":"full"}' --json
hank tool run_optimization --portfolio-name owned --optimization-type=max_sharpe --format full --json
hank tool run_optimization --args '{"portfolio_name":"owned","format":"summary"}' --format full --json
hank tool run_whatif --args @whatif.json --json
cat backtest.json | hank tool run_backtest --args - --json
cat backtest.json | hank tool run_backtest --args @- --json
hank tool <tool_name> --help
```

Execution accepts one `--args` source containing a JSON object: inline JSON,
`@file`, or `-` / `@-` for stdin. Repeating `--args` is an input error, not a
merge. Scalar schema properties also accept `--<kebab-field> <value>` or
`--<kebab-field>=<value>`; kebab names map to the owner's snake_case properties.
Every override is parsed against the named tool's live `input_schema` from
capabilities before execution. Unknown fields and non-scalar properties are
input errors (exit 2), with `next_call: hank tool <tool_name> --schema`; use
`--args` for objects and arrays. A flag without a value is also an input error.

The owner's declared type alone determines conversion: strings remain verbatim,
integers parse as integers (not floats), numbers parse as finite floats, and
booleans accept exactly lowercase `true` or `false`. **Overrides replace matching
fields in `--args`, regardless of argv order:** they are the caller's later
explicit statement. `--args` may be omitted only when overrides supply every
required schema field. The CLI injects no schema or financial defaults.

`ls` reads the caller's full `tools` array from `GET /api/jobs/capabilities`.
On a TTY, the default display groups compact summaries by `owner`, keeping each
tool's `name`, `effect_class`, `dry_run` and `loadable`. `ls --json` and off-TTY
`ls` print the identical raw full `tools` array, including `input_schema`,
`example` and loadable tools, without loading packs or changing admission.
Explicit `--json` also selects this raw array on a TTY. `--schema` prints the
named tool's `input_schema`; `--example` prints its declared argv array, or
reports `the owner declares no example` on stderr with exit 1 when it is null.
These two inspection flags are mutually exclusive and both accept `--json`.
Named-tool `--help` (or `-h`) prints static usage plus the
owner, effect class, dry-run support, loadable flag and one line per scalar
property: `--<kebab> (<type>, required|optional): <description>`.
`hank tool --help` without a tool remains static and needs no gateway.
Listing, inspection, generated leaf help and scalar overrides require
`GET /api/jobs/capabilities`. If the route is not mounted (404), they exit 1;
stderr names the unavailable URL, HTTP status and verbatim response body, with
`next_call: hank tool ls --json`.
Named-tool help still prints static usage on stdout, but never exits 0 while
its live contract is unavailable.

`--dry-run` sets `input.dry_run=true` and sends it; the tool door owns the
dry-run declaration (it reads the owner's input schema), so a tool whose owner
declares no `dry_run` answers with the owner's typed refusal (exit 2) and a read
tool reports dry-run as not applicable in `capabilities`. Unlike scalar
overrides, this valueless flag refuses a `dry_run` already stated in input
(exit 2), rather than replacing it. The flag alone never requires a capabilities
read and never refuses on the owner's behalf; combining it with scalar
overrides still requires their capabilities read. Discovery, inspection and
help take no execution input (`--args`, scalar overrides or `--dry-run`).

The command resolves the same config namespace, gateway credential, route prefix
and named gateway session as chat, initializes or reuses its bearer, and posts
`{"name":"<tool_name>","input":{...}}` to `/api/chat/tool`. `--session <name>`
selects the session (default `default`); this preserves session-local `load_tools`
grants while the gateway session remains live. For each request, a dead-bearer
401 follows the transport's bearer classification and refreshes through
`/api/chat/init` once, then retries once. The refreshed identity is saved to
the selected named session so the next invocation reuses it. A persistent
401 is a gateway session refusal, not a tool-door envelope; `cross_user_reuse`
is not a dead bearer and is never retried. Connection overrides
`--base-url`, `--api-key`, `--user-id` and loopback-only `--insecure` are supported.
The terminal holds no Risk service key or browser cookie.

Stdout is data only. Execution prints the complete `hank.tool/1` response
object, retaining owner error fields (`type`, `message`, `state_unchanged`,
`next_call`, `safe_alternative`) unchanged. `--json` or non-TTY stdout emits a
single JSON line with Unicode preserved and one trailing newline; a TTY
pretty-prints the same object, with no prose, colors, prompts or pager.
Discovery and inspection use the same JSON rendering rules, apart from the
TTY listing's grouped summaries.

Capability-read failures use the canonical jobs owner's error mapping, not the
execution tool-door mapping. A non-2xx capability owner's error object is
printed as JSON only to stderr, preserving every field and leaving an absent
`safe_alternative` absent; stdout stays empty. Other non-2xx gateway responses,
whether envelope-less JSON or non-JSON, print the HTTP status, URL and verbatim
raw response body only to stderr and exit 1. A 401 names the gateway session
owner rather than reporting missing tool-door metadata.

Execution projects a `hank.tool/1` envelope's `meta.exit_code` unchanged, even
on a non-2xx HTTP response. Missing or malformed exit metadata in such an
envelope is an owner-protocol error (exit 1, with the owner named on stderr),
never an exit guessed from HTTP status.

| Exit | Meaning |
| --- | --- |
| 0 | Success. |
| 1 | Owner/provider operational failure, HTTP/transport failure, or malformed owner response. |
| 2 | Bad input from the local JSON/scalar entry parse or owner, or execution tool-door integrity refusal. |
| 3 | Approval durably parked under a job, not an in-process wait. |
| 5 | Authority refusal or confirmation required, including a money-boundary call with no job. |
| 124 | Caller wait timed out. |
| 130 | Local interrupt (Ctrl-C). |

`hank tool` publishes one exit dictionary, the operations contract's (§3):
a capability-read owner outage (`owner_unavailable`, the jobs client's
`external_blockage`) exits 1 and a stale or integrity conflict exits 2, with the
owner's error object rendered verbatim on stderr either way; 4 and 6 are never
produced on this surface.
`--timeout-seconds <seconds>` bounds the wait (default 600). Timeout and local
interrupt stop waiting without sending cancellation; the owner may still finish.

## hank chat --local-runner

`hank chat --local-runner [--local-desk DIR] ["message"]` runs the package
`AgentRunner` on this machine with the user's own provider key in
`HANK_LOCAL_API_KEY`; no hosted provider credential or product secret is used.
The desk bearer comes from `/api/chat/init` (the gateway key), and the model is
offered two kinds of tools:

- **Hosted tools**, under their catalog names and input schemas: the session's resident
  surface (catalog rows with `loadable: false`, what the hosted session offers its own model),
  read each turn from `GET /api/jobs/capabilities` (the same parser as `hank tool`). A hosted
  `load_tools` call re-reads the catalog, so the loaded pack joins the surface for the rest of
  the turn (the gateway session keeps what it loaded; a renewed bearer is a new session). Each call
  is one `POST /api/chat/tool {name, input}`; the door's `hank.tool/1` envelope
  (success, failure, `needs_confirmation`, `needs_decision`, `next_call`) is the
  tool result unchanged. The door owns admission, approvals and tenant scope; the
  client adds no check of its own. Hosted tools write the authoritative cloud desk.
- **Local tools** `local_file_read`, `local_file_write` and `local_bash`, confined
  to the local working tree `users/<id>/workspace/` under `--local-desk` (default
  the namespace cache). A local file is not a publication and is never uploaded.

Before and after each turn the desk client pulls research metadata and artifact
sidecars into `users/<id>/workspace/`, never overwriting a locally edited file;
that pull is how an artifact a hosted tool wrote reaches the local replica. If
the catalog cannot be read the turn fails; it does not run with local tools only.
The turn's history is durable per `--session`; `--new` archives it.

The local runner binds `~/.config/hank/run-route.json` when present, before loading
the model authority. A strict route rejects an explicit model outside its pool
even with BYOK (`capability_selection_invalid`). If the operator file is absent,
customer BYOK remains route-less; an unreadable or invalid file raises
`RunRouteError`, never silently bypasses the setting. `HANK_RUN_ROUTE_FILE`, when
handed by a launcher, wins over the operator file; `""` means handed no-route.

## Contract

The CLI sends `context.channel = "cli"` on every chat request. When `user_id`
is configured, it is sent as a top-level field on both `/api/chat/init` and
`/api/chat`. Tool approvals post `tool_call_id`, `nonce`, `approved`, and
`allow_tool_type`.

The CLI consumes authenticated `capability_choices` from `/api/chat/init`.
`--model-key` and `--effort` are sent as given; the gateway judges them against
those choices and refuses an ineligible key or unsupported effort. When both are
omitted, the chat request omits all selection fields and the server resolves the
capability default. Repeatable `--model-key-for CAPABILITY=MODEL_KEY` and
optional `--effort-for CAPABILITY=LEVEL` flags populate init-time run overrides;
the capability id is sent as given and the gateway owns which capabilities are
selectable. Provider-qualified selectors, upstream model names, display-label
aliases, and local fallback catalogs are not accepted.

`--save-preference` and `--clear-preference` use the authenticated server
preference contract for account-wide `session.driver` state; the server refuses
a save with no `--model-key`. Local transcript files do not store or resolve
model preferences.

`hank preset` lists the names the model-selection policy publishes and your
selection, `hank preset <name>` selects one of those names account-wide, and
`hank preset --clear` clears it, through the gateway's `/model-presets/self`
door, which owns which names exist. The shipped policy publishes none today,
so the list is empty and any name is refused. Concrete `--model-key`/`--effort`
selection is unchanged.

`hank chat --model-preset NAME` sends `model_preset` on `/api/chat/init` (only
when given): the session runs under that published preset, above your saved
preset and your firm's, for every turn on it; roles the preset omits come from
policy. The gateway refuses a name its policy does not publish before any turn
runs. A stored session token is not reused when the flag is given; the CLI
re-inits, resuming the stored conversation.

`--research-file-id <n>` attaches `context.research_file_id` to every chat
turn (interactive and message form) and binds a `control dispatch` run; the
gateway's research preflight judges the id.

Every gateway request is composed as `base_url + route_prefix + path`, so the
hosted deployment is reached as `https://hank.investments/gateway/api/chat/init`.
The default `BaseUrlPolicy` accepts any hostname over `https`, allows `http`
only for a loopback host, and rejects a query or fragment. A base URL may carry
the deployment path: it is hoisted out of the base URL into the route prefix, so
`--base-url https://hank.investments/gateway` resolves to base URL
`https://hank.investments` with route prefix `/gateway/api`, identical to
`--base-url https://hank.investments --route-prefix /gateway/api`. An explicit
`--route-prefix` (or `AGENT_GATEWAY_ROUTE_PREFIX`) that neither equals nor
extends the base URL path is refused, naming both values. TLS
verification is on by default; `--insecure` and `HANK_INSECURE=1` are honored
only for a loopback gateway, and `HANK_CA_BUNDLE` selects a custom CA bundle.
Products that need a narrower policy can inject
`BaseUrlPolicy(allowed_schemes=..., allowed_hostnames=...)`.

## Job clients

`agent_gateway_cli.jobs` is the standalone Python client for the job catalog
(`ls`, `status`, `show`, `watch`, `capabilities`, `whoami`, `new`, `plan`, `run`).
It imports nothing from the product gateway (`api.*`) at runtime, and `agent_gateway_cli/jobs/`
itself has no `agent_gateway.*` imports: `agent_gateway_cli/jobs/generated.py` imports only the Python stdlib
(`dataclasses`, `types`, `typing`), and `agent_gateway_cli/jobs/client.py` and `agent_gateway_cli/jobs/async_client.py` reuse
`agent_gateway_cli.transport`, `agent_gateway_cli.config`, and `agent_workflow_contracts`
(plus `httpx` and `pydantic`) instead. The package root `agent_gateway_cli/__init__.py` loads
`.local_runner` (the gateway runner stack, ~2 s) only when `DeskSyncClient`, `DeskSyncResult`
or `LocalTerminalAgentRunner` is first read, and `cli.py` imports it inside `--local-runner`
chat, so `hank` start-up stays inside `main`'s `KeyboardInterrupt` handler. The only product source of the contract is the generator below.

- `agent_gateway_cli/jobs/generated.py` is **generated data**: verb specs with their HTTP binding
  (method, path template, path/query/body keys), the exit dictionary, the
  error-type and watch-outcome to outcome-family tables, and the degraded
  capabilities. Never edit it by hand. `scripts/generate_job_clients.py` writes
  it, `packages/gateway-chat-client/src/jobs.generated.ts` and the shared wire
  fixture `packages/gateway-chat-client/tests/fixtures/job-client-contract.json`
  from `api/jobs/catalog.py` (`JOB_CATALOG`) at generation time only:

  ```bash
  .venv/bin/python3 scripts/generate_job_clients.py          # rewrite the three outputs
  .venv/bin/python3 scripts/generate_job_clients.py --check  # exit non-zero if any differs
  ```

  Generation is deterministic; change the catalog, regenerate, and commit all
  three files together. `agent_gateway_cli/jobs/client.py` and `agent_gateway_cli/jobs/async_client.py` are
  handwritten wrappers that consume `generated` and are owned by this package.
- `JobClient(transport, holder)` and `AsyncJobClient(transport, holder)` take an
  existing `GatewayTransport` / `AsyncGatewayTransport` and `SessionHolder` /
  `AsyncSessionHolder` (`MemorySessionHolder`, `SessionMap.holder(key)`). They
  expose one method per catalog verb, `call(verb, **kwargs)` for generic
  dispatch, and `watch_stream(job_id, ...)` for the SSE event stream
  (`WatchStream` / `AsyncWatchStream`, an iterator of new events).
- A call returns `JobCallResult(status, data, outcome_family, exit_code,
  watermarks, cursors)` and raises `JobClientError(status, error_type, message,
  state_unchanged, next_call, safe_alternative, outcome_family, exit_code, body)`
  for a refusal. `data` and `body` are the gateway's JSON, untouched; handles and
  digests are neither translated nor defaulted, and the error type's family and
  exit code (0, 1, 2, 3, 4, 5, 6, 124, 130) come only from the generated tables.
  A body with no canonical error type (a dead-bearer 401, a proxy page) leaves
  them `None`. A body that does carry one must be whole (non-empty `message`,
  bool `state_unchanged`, non-empty `next_call`, `safe_alternative` null or
  non-empty): a result or error that is not the typed shape (a non-object body,
  a verb's missing top-level key, a malformed `gate`/`registration`/`plan`/`watch`
  container, an undeclared watch outcome, malformed or repeated `watermarks`)
  raises `JobWireError`, never a coerced value. Keyword arguments are checked
  against the catalog `ArgSpec`: a flag takes a `bool`, seconds a finite number
  `>= 0`, an instant a non-empty `str`, cursors distinct `<source>:<cursor>`
  tokens; anything else is a `TypeError`/`ValueError` before any request. `run`
  parked on an approval gate (HTTP 409 carrying the
  `RunResult`) is a result with exit 3 `needs_decision`, not an error; a `run`
  result carrying a `--wait` watch takes the watch outcome. An unfinished
  `watch` snapshot has no family: it is never a success claim.
- Transport reuse, not a second policy: requests go through the transport's own
  httpx client (so its TLS `verify`), `ensure_session`/`connect` and bearer
  header, and `parse_sse_events`. A safe read that meets a dead bearer (401 with
  `detail`) refreshes the holder once and retries; `new`, `plan` and `run` refresh
  the holder for the next call but are never re-sent, so no mutation is replayed
  on a new token. A stream keeps the session it opened with (a pinned holder's
  identity) and refreshes only for its own reconnect after a dead bearer.
- `watch_stream` follows per-source cursors (`<source>:<cursor>` tokens): it drops
  an event whose token it already delivered, ignores `:keepalive`, re-attaches
  with the current cursor vector after a dropped connection, and ends on a
  needs-me (3), external-blockage (4) or failed/lapsed (1) view. Timeout (124),
  `close()`, a keyboard interrupt or task cancellation only detach the local
  subscription (130 for the latter three); the job is never cancelled and no
  `/chat/cancel` is sent. A chat `awaiting_approval` termination stays neutral:
  it does not settle or fail the linked job.

`tests/test_job_client_contract.py` drives both clients against the shared
fixture; `tests/test_job_client_generator.py` covers the generator.

## Control plane

Chat and control use separate credentials and separate channels. `hank login
--control-api-key <key> [--control-channel <channel>]` stores the control key in
the same namespace config file. Chat requests always send
`context.channel = "cli"`; control sessions default to `--control-channel tui`
(cron/scripts convention sets `AGENT_CONTROL_CHANNEL=cli` instead). Whichever
channel is used MUST match the channel of the `GATEWAY_USER_KEYS` entry the
control key belongs to, or session mint fails with `ChannelMismatch`.

`hank control <subcommand>` drives `/api/control/*`: `dispatch` starts an
autonomous run (`--profile analyst --mode once|skill … [--follow]`); `runs`,
`run`, `logs`, `watch`, `cancel` observe and steer it; `approvals`/`approve`/
`deny`, `batches`, `artifacts`, and `schedules` cover the rest. Run listings are
owner-and-channel scoped: `hank control runs` shows only runs started on the
control key's channel (documented in `docs/reference/agent-run-evaluation.md`).

`--follow` follows a run until it settles; nothing cancels it on a clock.
`--timeout-seconds` is an optional caller bound: reaching it stops following
and prints `elapsed_s` with the `hank control watch <run>` next call, and the
run keeps going. Ctrl-C cancels that exact run (or batch) and waits for its
terminal receipt; a second Ctrl-C exits at once with `cancel_outcome: unknown`.
Receipts and batch results carry `elapsed_s`, `reconnects`, `cancel_elapsed_s`
and `cancel_outcome`, and control object results carry `elapsed_s`.

`hank control approvals` lists pending approvals and `approve`/`deny` resolve
them; there is no standing approval policy to read or set from the CLI.
