@sunilaleti/hx 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,1342 @@
1
- # Temporary Holding Version
1
+ # HX
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An agent harness for the terminal. TypeScript on Node.js, its own terminal
4
+ renderer, and models through OpenRouter or a ChatGPT, Claude or Devin
5
+ subscription.
6
+
7
+ HX runs in your project directory, reads and edits your code, runs commands in
8
+ a sandboxed shell, and shows you what every turn costs.
9
+
10
+ ---
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install --global @sunilaleti/hx
16
+ ```
17
+
18
+ HX runs on [Node.js](https://nodejs.org/) 22.15 or newer, on macOS and Linux.
19
+ Run the same command again to update. Then run `hx`: the first time, it opens
20
+ with the HX logo and says to type `/login`.
21
+
22
+ Or let the install script check your Node.js version and run that command:
23
+
24
+ ```sh
25
+ curl -fsSL https://raw.githubusercontent.com/aletisunil/hx/main/install.sh | sh
26
+ ```
27
+
28
+ ### `@sunilaleti/hx` on npm, `hx` in your terminal
29
+
30
+ The package is published as **`@sunilaleti/hx`**. The command it installs is
31
+ **`hx`**, and that is what you type - the longer name never appears again
32
+ after install. The plain name `hx` on npm belongs to another package.
33
+
34
+ | | Name |
35
+ |---|---|
36
+ | npm package | `@sunilaleti/hx` |
37
+ | Command | `hx` |
38
+ | Config directory | `~/.hx` |
39
+
40
+ If the command isn't found after installing, add npm's global bin directory
41
+ to your `PATH`:
42
+
43
+ ```sh
44
+ export PATH="$(npm prefix --global)/bin:$PATH"
45
+ ```
46
+
47
+ Coming from `hx-cli`, the Python build on PyPI? It is no longer released.
48
+ Remove it, then install from npm - sessions, sign-ins and settings in `~/.hx`
49
+ carry over:
50
+
51
+ ```sh
52
+ uv tool uninstall hx-cli
53
+ npm install --global @sunilaleti/hx
54
+ ```
55
+
56
+ ### Credentials
57
+
58
+ HX reaches models over four routes. Which one serves a turn is decided by the
59
+ model id alone, so "what paid for that" is always answerable by reading it:
60
+
61
+ | Model id | Route | Billing |
62
+ |---|---|---|
63
+ | `anthropic/claude-sonnet-4.5`, `openai/gpt-5`, … | OpenRouter | per token, API key |
64
+ | `openai-codex/gpt-5.6-terra` | ChatGPT Plus/Pro | your subscription |
65
+ | `devin/swe-1-6`, `devin/claude-opus-5`, … | Devin | your subscription |
66
+ | `claude-code/claude-sonnet-5`, `claude-code/claude-opus-4-8`, … | Claude Pro/Max, via Claude Code | your subscription |
67
+
68
+ Credentials live in `~/.hx/auth.json`, mode 0600, one entry per route - except
69
+ Claude Code's, which stays with Claude Code.
70
+
71
+ **OpenRouter.** On first run HX asks for a key. Get one at
72
+ <https://openrouter.ai/keys>. Change it later with `/configure` in the TUI, or:
73
+
74
+ ```sh
75
+ hx auth # which routes have a credential, and where from
76
+ hx auth set # paste a new OpenRouter key (hidden input)
77
+ hx auth clear # remove the saved key
78
+ ```
79
+
80
+ Resolution order is `HX_OPENROUTER_API_KEY`, then `OPENROUTER_API_KEY`, then
81
+ the saved file. The environment wins, and both `/configure` and `hx auth` say
82
+ so — otherwise saving a key while a variable is set looks like a no-op.
83
+
84
+ **ChatGPT Plus/Pro.** Sign in over OAuth and run Codex models against your
85
+ subscription instead of paying per token
86
+ ([OpenAI endorses this for OSS harnesses](https://developers.openai.com/community/codex-for-oss)):
87
+
88
+ ```sh
89
+ hx auth login openai-codex # or /login inside the TUI
90
+ hx --model openai-codex/gpt-5.6-terra
91
+ hx auth logout openai-codex
92
+ ```
93
+
94
+ The browser opens to `auth.openai.com` and redirects to a loopback listener on
95
+ port 1455. Over SSH that redirect cannot reach your machine, so paste the final
96
+ URL into the prompt instead — or set `HX_LOGIN_DEVICE_CODE=1` for the
97
+ device-code flow. Access tokens are refreshed automatically; `/model` only
98
+ offers routes you are actually signed in to.
99
+
100
+ `hx auth` names the account's plan next to the login, because "which account is
101
+ this and what is it paying for" is otherwise a JWT to decode by hand:
102
+
103
+ ```sh
104
+ hx auth
105
+ # openrouter key sk-or-…8abd ~/.hx/auth.json
106
+ # openai-codex signed in (plus) ~/.hx/auth.json
107
+ ```
108
+
109
+ Codex is documented as requiring a paid ChatGPT plan, and HX says so when the
110
+ login is on a free one. It does not refuse to try: the plan on the credential
111
+ has been observed deciding nothing — the backend refuses every Codex model on a
112
+ `plus` account with the same error it gives a `free` one — so the switch is
113
+ allowed and the backend's own answer is what reports the problem.
114
+
115
+ **If every Codex model is refused**, the error says so in those terms: it is
116
+ the account that is being refused, not the model, and a different model id will
117
+ not help. Check that the subscription is active and Codex is enabled for the
118
+ account at <https://chatgpt.com/codex>, and that the official Codex CLI can
119
+ reach it at all.
120
+
121
+ **Which Codex models you get** is decided by your ChatGPT account, not by HX.
122
+ Signing in fetches the account's own list from the Codex backend, and `/model`
123
+ offers exactly that; ctrl+r in `/model` asks again. Entitlement is per model -
124
+ the same `plus` account can run `gpt-5.6-sol` and be refused `gpt-5.3-codex` —
125
+ so a model the picker does not list is one the subscription does not cover.
126
+
127
+ **How hard they think** comes from the same fetch. Each Codex model publishes
128
+ its own reasoning levels and its own default — GPT-6 Astra starts at `low`,
129
+ GPT-5.6 Terra at `medium` — and HX asks for the model's default rather than one
130
+ fixed depth. `/effort` changes it:
131
+
132
+ ```
133
+ /effort # pick from the levels this model offers
134
+ /effort high # none | minimal | low | medium | high | xhigh | max | ultra
135
+ /effort default # back to the model's own default
136
+ ```
137
+
138
+ It takes effect on the next turn, is saved to `~/.hx/settings.json` for later
139
+ sessions, and shows on the status bar next to the model — `gpt-5.6-terra (sub)
140
+ · high`. Levels differ per model, so a choice is clamped to what the current
141
+ one offers: `max` runs at `xhigh` on GPT-5.5 rather than being refused, and the
142
+ bar shows what is actually happening. Set it by hand instead with:
143
+
144
+ ```jsonc
145
+ // ~/.hx/settings.json
146
+ { "models": { "reasoning_effort": "high" } }
147
+ ```
148
+
149
+ When the model list cannot be fetched (no network, an old cache), HX falls back
150
+ to the models it last shipped. To add an id the backend does not advertise:
151
+
152
+ ```jsonc
153
+ // ~/.hx/settings.json
154
+ { "models": { "codex_models": ["gpt-5.6-terra"] } }
155
+ ```
156
+
157
+ They join the `/model` picker on the next start. HX cannot ask how large an
158
+ unknown model's context window is, so it assumes a conservative 200k — that
159
+ costs an accurate context gauge, not the use of the model.
160
+
161
+ **Devin.** Sign in with the same browser flow the official `devin` CLI uses,
162
+ and run every model your Devin plan includes - SWE-1.6, and whatever frontier
163
+ models the plan offers - against the subscription:
164
+
165
+ ```sh
166
+ hx auth login devin # or /login inside the TUI
167
+ hx --model devin/swe-1-6
168
+ hx auth logout devin
169
+ ```
170
+
171
+ **Devin Enterprise** signs in the same way: on the page that opens, choose
172
+ "Log in with Devin for Enterprise", enter your company, and finish through its
173
+ identity provider. Your admin has to grant the "Use Devin CLI" permission first.
174
+ Usage counts toward the organization's allocation, and an enterprise tenant
175
+ served from its own API server is followed there for both turns and the model
176
+ list.
177
+
178
+ The browser opens to `app.devin.ai` and redirects to a loopback listener on
179
+ `127.0.0.1:59653`. Over SSH, paste the final redirect URL into the prompt
180
+ instead. A Devin login does not refresh: when it expires, HX says so and asks
181
+ you to sign in again. On a machine with no browser at all, a session token in
182
+ `HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
183
+
184
+ Signing in fetches the account's own model list, and `/model` offers exactly
185
+ that; ctrl+r in `/model` asks again. Devin publishes each reasoning depth as a
186
+ model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
187
+ family into one model, so `/effort` picks the depth on Devin exactly as it does
188
+ on Codex. A fast lane or a 1M-context lane stays a separate model, since it
189
+ changes what a turn costs or how much fits. Router models such as
190
+ `devin/adaptive` are resolved to a concrete model on every turn by Devin itself.
191
+ If the list cannot be fetched, `devin/swe-1-6` and `devin/swe-1-6-fast` stand in.
192
+
193
+ **Claude Pro/Max.** HX runs Claude models on your Claude subscription through
194
+ the official [Claude Code](https://claude.com/claude-code) CLI, the way the
195
+ Claude Agent SDK does. Install Claude Code, then:
196
+
197
+ ```sh
198
+ hx auth login claude-code # Claude Code's own sign-in; or /login inside HX
199
+ hx --model claude-code/claude-sonnet-5
200
+ ```
201
+
202
+ HX holds no Claude token. The login is Claude Code's, kept and refreshed where
203
+ Claude Code keeps it, so `hx auth logout claude-code` only tells you to run
204
+ `claude auth logout` - which signs out every Claude Code on the machine. A
205
+ headless machine can use a token from `claude setup-token` in
206
+ `CLAUDE_CODE_OAUTH_TOKEN`. A Console login (`claude auth login --console`) bills
207
+ per token and is refused: this route is the subscription.
208
+
209
+ How a turn works: HX starts `claude -p` with every one of Claude Code's own
210
+ tools switched off and serves HX's tools to it as an MCP server. The model's
211
+ tool calls are run, permission-checked and drawn by HX exactly as on every
212
+ other route; Claude Code holds each call open until HX answers it, however long
213
+ an approval takes. The system prompt is Claude Code's, with your AGENTS.md
214
+ files, skills index and `--append-system-prompt` text added. One Claude Code
215
+ process serves a conversation for as long as it can, so the prompt cache
216
+ carries from turn to turn; after an interrupt, a steer while a tool runs, a
217
+ compaction, `/model`, `/effort` or a resumed session, HX starts a new one on a
218
+ session rebuilt from its own transcript. That costs a second or so and a
219
+ partly cold cache, not the conversation.
220
+
221
+ Claude Code is started isolated from your Claude Code setup: no CLAUDE.md, no
222
+ hooks, no plugins or MCP servers from `~/.claude`, no auto-memory, no
223
+ auto-compaction (HX compacts its own transcript), and nothing saved to Claude
224
+ Code's session list. Variables that would move it off the subscription -
225
+ `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_USE_BEDROCK` and the
226
+ rest - are removed from its environment; its TLS settings are kept (see
227
+ *Behind a TLS-inspecting proxy* below). `HX_CLAUDE_CODE_PATH` points HX at a
228
+ `claude` other than the one on `PATH`.
229
+
230
+ `/model` offers the models Claude Code lists for the account, each with the
231
+ efforts it accepts for `/effort`; ctrl+r in `/model` asks again. A model Claude
232
+ Code runs at its long context keeps `[1m]` in its id
233
+ (`claude-code/claude-fable-5-1[1m]`), exactly as Claude Code's own `/model` runs
234
+ it. Claude Code lists no context sizes, so a model starts at the window it was
235
+ measured serving on every plan - 1M for Opus 5.5, Sonnet 5.5, Sonnet 5 and Opus
236
+ 4.7, otherwise 200k - and from its first turn uses the window Claude Code
237
+ reports for your account. When a usage limit is reached, HX says which one and
238
+ when it resets; when a model or its 1M context needs usage credits your plan
239
+ has off, HX says that instead, and what to pick without them.
240
+
241
+ **Web search.** Optional, and the one credential that is not a model route.
242
+ It powers two tools:
243
+
244
+ | Tool | Does |
245
+ |---|---|
246
+ | `WebSearch` | search the web, returning ranked results with a snippet each |
247
+ | `WebFetch` | read up to 5 URLs, returned as markdown |
248
+
249
+ [Tavily](https://app.tavily.com) bills per search rather than per token, so it
250
+ works identically on every route - including the subscription ones,
251
+ where a provider-native search tool is not on offer.
252
+
253
+ Get a key at <https://app.tavily.com> — the free tier is 1000 searches a month
254
+ and takes no card — then save it:
255
+
256
+ ```sh
257
+ hx auth set tavily # paste the key, hidden input, saved to ~/.hx/auth.json
258
+ hx auth # confirm: `tavily key tvly-a…f3d9 …/auth.json`
259
+ hx auth clear tavily # remove it again
260
+ ```
261
+
262
+ Or keep it in the environment instead, which wins only when nothing is saved:
263
+
264
+ ```sh
265
+ export TAVILY_API_KEY=tvly-... # HX_TAVILY_API_KEY is read first
266
+ ```
267
+
268
+ **Without a key, HX works exactly as before** — `WebSearch` and `WebFetch` are
269
+ simply not registered, and `hx auth` says so:
270
+
271
+ ```
272
+ tavily not configured WebSearch and WebFetch are off
273
+ ```
274
+
275
+ There is no nag, no failing tool and no startup warning. Leaving them out is
276
+ deliberate: an advertised tool that always fails still costs cached-prefix
277
+ tokens every turn, and a model told "no key" just calls it again. Adding a key
278
+ turns both on from the next `hx`; no other setting changes.
279
+
280
+ Credits are reported per call and per session at the foot of every result —
281
+ `[Tavily: 1 credit, 4 this session]`. A search costs 1 credit, `search_depth:
282
+ "advanced"` costs 2, and fetching costs 1 per 5 URLs. Tavily's docs describe a
283
+ `usage` field for this, but the live API does not send one, so HX prices every
284
+ call from those published rates and marks the figure `~1` rather than passing
285
+ an estimate off as measured. Check `app.tavily.com` for the authoritative
286
+ number.
287
+
288
+ Credits are *not* in the status bar's cost, which prices tokens: `/cost`
289
+ under-reports a session that searched.
290
+
291
+ **Behind a TLS-inspecting proxy.** HX verifies certificates against the
292
+ operating system's trust store, so a corporate CA the machine already trusts
293
+ needs no setup. When the CA lives only in a file, name it:
294
+
295
+ ```sh
296
+ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDLE also work
297
+ ```
298
+
299
+ `HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
300
+ startup every session, because it is not a fix. A TLS failure is reported in
301
+ one line - `/model` and the startup notice both name the certificate problem
302
+ rather than reporting an empty catalogue with no cause. A file that holds no
303
+ certificates is ignored, with a startup notice that says so.
304
+
305
+ "Unsupported certificate purpose" (or "unsuitable") is different: the CA is
306
+ trusted, but the proxy signs with a certificate that is not valid for servers,
307
+ so no CA setting fixes it. The network has to fix that certificate or exempt
308
+ the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
309
+
310
+ The same settings cover the `claude-code` route, whose requests Claude Code
311
+ makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
312
+ `NODE_EXTRA_CA_CERTS` (unless you already set that yourself), and
313
+ `HX_SSL_NO_VERIFY` as `NODE_TLS_REJECT_UNAUTHORIZED=0`. Claude Code reads the
314
+ OS trust store on its own, and its own TLS settings reach it untouched:
315
+ `CLAUDE_CODE_CERT_STORE`, and `CLAUDE_CODE_CLIENT_CERT`,
316
+ `CLAUDE_CODE_CLIENT_KEY` and `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE` for a network
317
+ that requires a client certificate.
318
+
319
+ HX never sends a Claude subscription token to the API itself - Anthropic
320
+ accepts those only from Claude Code, and HX will not impersonate it. The
321
+ `claude-code` route runs the real Claude Code instead.
322
+
323
+ ---
324
+
325
+ ## Running it
326
+
327
+ ```sh
328
+ hx # interactive TUI in the current directory
329
+ hx -p "explain this repo" # headless: streams to stdout, tool activity to stderr
330
+ hx -p "why?" --image s.png # attach an image to a headless prompt; repeatable
331
+ hx resume # resume the last session here
332
+ hx resume <session-id> # resume a specific one, in the directory it was recorded in
333
+ hx prompt # print the system prompt this directory would use
334
+ hx trace # write the last session here to an HTML page
335
+ hx trace out.html # the same, to a path you choose
336
+ hx trace <id> out.html # a specific session, to a path you choose
337
+ hx --model openai/gpt-5 # override the model for one run
338
+ hx --mode plan # start read-only
339
+ hx --cwd ../other-project # run against a different directory
340
+ hx --no-sandbox # disable OS sandboxing (rules still apply)
341
+ hx --system-prompt @p.md # replace the system prompt for one run
342
+ hx --append-system-prompt "Always run the tests" # add to it; repeatable
343
+ hx mcp list|add|remove # manage MCP servers
344
+ hx mcp approve <server> # let a server from the project's .hx/mcp.json start
345
+ hx mcp login <server> # sign in to a remote MCP server in the browser
346
+ hx mcp logout <server> # forget a remote MCP server's sign-in
347
+ hx auth [set|clear] # manage the OpenRouter key
348
+ hx auth login [provider] # sign in (openrouter, openai-codex, devin, claude-code)
349
+ hx auth logout <provider> # forget a stored credential
350
+ hx docs [section] # the manual; no argument lists its sections
351
+ hx changelog [version] # what shipped in each version
352
+ ```
353
+
354
+ Print mode is the scriptable one: stdout carries only the assistant's text, so
355
+ it pipes cleanly.
356
+
357
+ ### The docs ship with the binary
358
+
359
+ `README.md` and [`CHANGELOG.md`](CHANGELOG.md) are included in the wheel, so
360
+ the installed tool can print the manual and the release record for the version
361
+ you are actually running:
362
+
363
+ ```sh
364
+ hx docs # the section titles
365
+ hx docs credentials # one section, printed whole
366
+ hx docs --all # the entire manual
367
+ hx changelog # every release, newest first
368
+ hx changelog 0.1.5 # one release
369
+ hx changelog unreleased # what has landed since the last tag
370
+ ```
371
+
372
+ The system prompt points a session at these two commands, so "how do I set the
373
+ API key?" or "what changed in the last release?" is answered from the shipped
374
+ documentation rather than from the model's recollection of some other version.
375
+ The call still goes through the permission engine like any other command;
376
+ allowlist it if you would rather not be asked:
377
+
378
+ ```jsonc
379
+ { "permissions": { "allow": ["Bash(hx docs:*)", "Bash(hx changelog:*)"] } }
380
+ ```
381
+
382
+ `CHANGELOG.md` lists what each version added, and a release cannot skip it:
383
+ `tests/e2e/test_cli.py` fails when `__version__` has no section (see
384
+ [Releasing](#releasing)).
385
+
386
+ ### Keys
387
+
388
+ Every key below is defined once, in `src/keys.ts`. `/help`, the hints line
389
+ under the prompt and this table all read from that registry, and any of them can
390
+ be rebound (see [Keybindings](#keybindings)).
391
+
392
+ | Key | Does |
393
+ |---|---|
394
+ | `enter` | send |
395
+ | `shift+enter` | newline (also `ctrl+j`; in a terminal that cannot send `shift+enter`, end the line with `\` and press `enter`) |
396
+ | `esc` | interrupt the current turn |
397
+ | `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
398
+ | `ctrl+d` | exit, when the prompt is empty |
399
+ | `shift+tab` | cycle permission mode |
400
+ | `ctrl+p` | command palette |
401
+ | `ctrl+l` | model picker; inside it, `ctrl+r` fetches the catalogue again |
402
+ | `ctrl+o` | expand tool output (also `ctrl+r`) |
403
+ | `ctrl+t` | show the current plan |
404
+ | `ctrl+x` | copy the selected message to the clipboard |
405
+ | `ctrl+up` | previous message |
406
+ | `ctrl+down` | next message |
407
+ | `pgup` | scroll the transcript up (fullscreen; otherwise your terminal scrolls) |
408
+ | `pgdn` | scroll the transcript down (fullscreen; otherwise your terminal scrolls) |
409
+ | `ctrl+home` | jump to the start: the viewport in fullscreen, the first message otherwise |
410
+ | `ctrl+end` | jump back to the newest output |
411
+ | `ctrl+v` | attach the image on the clipboard (see [Images](#images)) |
412
+ | `@path` | complete a file path |
413
+ | `!command` | run a shell command directly, no model turn |
414
+
415
+ `!` still goes through the permission engine and the sandbox — it skips the
416
+ model, not the safety layers.
417
+
418
+ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
419
+ `ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
420
+ kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
421
+ `ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
422
+ `ctrl+z` undoes and `ctrl+shift+z` redoes (`ctrl+_` undoes too). `cmd+z` and
423
+ `cmd+shift+z` work in a terminal that passes cmd through to the program, such
424
+ as kitty or WezTerm; Apple's Terminal, iTerm2 and Ghostty keep cmd+z for their
425
+ own Edit menu. A terminal without the kitty protocol or modifyOtherKeys sends
426
+ `ctrl+shift+z` as `ctrl+z`, so there it undoes. Suspending to the background
427
+ has no key by default - bind `app.suspend` in `keybindings.json` (for example
428
+ to `"ctrl+g"`) to get it back.
429
+
430
+ Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
431
+ `enter` accepts, `esc` dismisses.
432
+
433
+ #### Copying
434
+
435
+ `ctrl+x` copies the message the cursor is on, and `/copy` copies the last reply.
436
+ HX writes through the platform's own clipboard tool first (`pbcopy`, `wl-copy`,
437
+ `xclip`, `xsel`) and falls back to OSC 52, which is also always sent over SSH so
438
+ the text lands on the machine you are actually sitting at.
439
+
440
+ Drag-select in the transcript and `ctrl+c` copies the selection — assistant
441
+ prose and tool output included. With nothing selected, `ctrl+c` keeps its usual
442
+ meaning and clears the prompt. See [Selecting and copying
443
+ text](#selecting-and-copying-text) for the terminal's own selection.
444
+
445
+ HX does not emit OSC 133 prompt markers yet, so shell-integration features that
446
+ jump between prompts will not see HX's messages.
447
+
448
+ #### Images
449
+
450
+ Give the model a picture three ways, and each lands in the prompt as a token
451
+ such as `[Image #1]`:
452
+
453
+ - **`ctrl+v`** attaches whatever image is on the clipboard: a screenshot copied
454
+ with `cmd+ctrl+shift+4`, an image copied in a browser, or an image file
455
+ copied in Finder or a file manager. Terminals whose own paste sends nothing
456
+ when the clipboard holds a picture are caught too - an empty paste is read as
457
+ a request for the image.
458
+ - **Drag an image file** into the terminal. The terminal pastes its path, and a
459
+ paste that is nothing but paths to image files attaches them instead of
460
+ inserting the text. Quoted, backslash-escaped and `file://` paths all work.
461
+ - **Mention it.** `look at @docs/mockup.png` sends the path as text, and the
462
+ model opens it with `Read`, which returns images as images.
463
+
464
+ The token is the attachment: move it and the text refers to the picture in a
465
+ different place, delete it (one `backspace` takes the whole token) and the
466
+ image is not sent. Sent messages list their images underneath, with size and
467
+ weight. Numbers run for the whole session, so a message recalled from history
468
+ or restored by a rewind still carries its pictures.
469
+
470
+ PNG, JPEG, GIF, WebP, BMP and TIFF are accepted. An image within every route's
471
+ limits goes byte for byte; a larger one is scaled to 2000 px on its long side
472
+ and re-encoded to stay under 5 MB, which is what a retina screenshot usually
473
+ needs. Images an MCP tool returns - a browser screenshot, say - are handled the
474
+ same way rather than dropped.
475
+
476
+ Whether a model can see images comes from the catalogue: OpenRouter's input
477
+ modalities, the Codex catalogue, and Devin's model config. The `/model` picker
478
+ marks the ones that can with `images`. A model that cannot
479
+ is warned about when you attach, and is sent a line saying an image was left
480
+ out, rather than a request that fails. The transcript keeps the image either
481
+ way, so `/model` to one that can see it and ask again.
482
+
483
+ Over SSH the clipboard you copied into is on your own machine, so `ctrl+v`
484
+ says so; copy the file over and drag or paste its path instead.
485
+
486
+ #### Keybindings
487
+
488
+ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
489
+ `src/keys.ts`:
490
+
491
+ ```json
492
+ {
493
+ "app.tools.expand": "ctrl+r",
494
+ "app.message.copy": ["ctrl+x", "alt+c"]
495
+ }
496
+ ```
497
+
498
+ Modifiers are `ctrl`, `alt` (or `option`), `shift` and `cmd` (or `super`);
499
+ a `cmd` key only arrives from a terminal that passes cmd through. Conflicts and
500
+ unknown action names are reported as a notice at startup rather than being
501
+ silently resolved.
502
+
503
+ ### Commands
504
+
505
+ | Command | Does |
506
+ |---|---|
507
+ | `/model [query]` | pick a model; shows context window, price per Mtok, cache support. `ctrl+r` in the picker re-fetches the catalogue |
508
+ | `/effort [level]` | how hard a reasoning model thinks, from the levels it offers |
509
+ | `/configure` | session settings and the API key |
510
+ | `/login [provider]` | sign in to a model route |
511
+ | `/logout <provider>` | forget a stored credential |
512
+ | `/mode [name]` | `plan`, `default`, `acceptEdits`, `bypass` |
513
+ | `/permissions` | active rules and what is enforcing them |
514
+ | `/context` | what is filling the context window |
515
+ | `/cost` | tokens, cache savings, spend |
516
+ | `/compact [focus]` | summarise older turns now |
517
+ | `/clear` | fresh session, same directory |
518
+ | `/resume` | switch to another session - this directory's first, then the rest |
519
+ | `/rewind` | go back to an earlier prompt, restoring the files HX changed |
520
+ | `/title [text]` | show or set this session's name |
521
+ | `/prompt` | the system prompt this session is running with |
522
+ | `/todos` | show the current plan |
523
+ | `/skills` | installed skills |
524
+ | `/agents` | subagent types |
525
+ | `/mcp [login\|logout\|reconnect <server>]` | server status, and signing in to remote servers |
526
+ | `/hooks` | configured hooks, and any that were refused |
527
+ | `/theme [name]` | `dark`, `light`, `ansi`, or any theme in `~/.hx/themes` |
528
+ | `/queue [steer <n>\|clear]` | messages waiting for the turn to end, and what to do with them |
529
+ | `/copy` | copy the last reply to the clipboard |
530
+ | `/trace [path]` | write the whole session to a self-contained HTML page |
531
+ | `/fullscreen [on\|off]` | take the whole window, with the prompt pinned to the bottom |
532
+ | `/mouse [on\|off]` | mouse reporting, and with it your terminal's own text selection |
533
+ | `/init` | generate an `AGENTS.md` for the project |
534
+ | `/help` | list commands and keys |
535
+ | `/quit` | exit (also `/exit`, `/q`) |
536
+
537
+ ### The two screens
538
+
539
+ By default HX draws into your terminal's own scrollback. Finished output belongs
540
+ to the terminal while the session runs: it scrolls it, selects it, copies it and
541
+ pipes it. Nothing is repainted above the fold, so a long session costs the same
542
+ to draw as a short one.
543
+
544
+ The prompt and the status bar still open on the bottom row of the window: a
545
+ conversation too short to fill the screen is padded above them rather than
546
+ below, so the dock stays where you last saw it instead of creeping down the
547
+ screen a block at a time. The padding is gone by the time the terminal starts
548
+ scrolling.
549
+
550
+ Leaving takes the whole interface off the screen: the banner, the conversation
551
+ and the dock are erased and your shell prompt comes back on the row HX started
552
+ on, the terminal as it was found. The erase stops at the top of the window -
553
+ lines that had already scrolled above it stay in your scrollback, because the
554
+ only sequence that reaches them takes everything the terminal remembers,
555
+ including what was there before HX ran. `/clear` is the command that asks for
556
+ that.
557
+
558
+ `/fullscreen` switches to the alternate screen instead - the whole window, the
559
+ prompt pinned to the bottom row, the transcript scrolling above it with `pgup`,
560
+ `pgdn`, `ctrl+home` and `ctrl+end`. The trade is the thing the default was
561
+ chosen for: the alternate screen is not scrollback, so your terminal cannot
562
+ scroll or select it, and it is gone when HX exits. `/fullscreen off` gives it
563
+ back, and either way the choice is written to `tui.fullscreen` for the next
564
+ session. Quitting from fullscreen leaves the same clean terminal as quitting
565
+ from the default screen: the alternate screen goes, and so does the frame HX
566
+ had drawn underneath it before the switch.
567
+
568
+ `/clear` wipes the screen and the scrollback above it. Terminals that do not
569
+ implement scrollback erasure - macOS Terminal.app is the one people hit - clear
570
+ the screen only, and the old conversation stays above it until you clear it the
571
+ terminal's own way (`cmd+k`).
572
+
573
+ ### Selecting and copying text
574
+
575
+ Drag to select and `ctrl+c` to copy. HX copies through `pbcopy`, `wl-copy` or
576
+ `xclip` first and falls back to OSC 52, so it works in terminals that ignore
577
+ OSC 52 - macOS Terminal ignores it outright, and iTerm2 ships with it off. The
578
+ notice says which mechanism took the text, so a copy that did nothing says so.
579
+
580
+ While HX is running, the terminal reports mouse events to it, which means the
581
+ terminal's *own* click-and-drag selection is unavailable. Two ways around that:
582
+
583
+ * Hold `alt`/`option` while dragging. Most terminals - macOS Terminal, iTerm2,
584
+ GNOME Terminal - take that as "this drag is mine", and you get the native
585
+ selection without changing anything.
586
+ * `/mouse off` turns mouse reporting off for the session. Native selection comes
587
+ back everywhere, and HX stops seeing scroll and clicks until `/mouse on`.
588
+
589
+ ### Steering a running turn
590
+
591
+ Type while HX is working and `enter` queues the message: it runs when the turn
592
+ ends, which is what you want for "and then do this".
593
+
594
+ `alt+enter` steers instead. The message goes into the turn that is running now -
595
+ the model call is cut off mid-stream and the next one starts from what you just
596
+ said. Tools already running are left to finish first, because cancelling a
597
+ half-written file is worse than waiting a second for it.
598
+
599
+ With nothing typed, `alt+enter` steers the message at the front of the queue, so
600
+ a correction you already queued does not have to be typed twice. `/queue` lists
601
+ what is waiting; `/queue steer 2` sends one of them now.
602
+
603
+ Set `tui.enterWhileBusy` to `"steer"` to swap the two keys around.
604
+
605
+ ### The status bar
606
+
607
+ Two lines: working directory and permission mode above; token counts, cache
608
+ read/write with hit rate, spend, last-turn latency, context gauge and model
609
+ below. The cache and cost fields are the point of it — a hit rate that
610
+ collapses after an edit is the visible symptom of a broken prefix.
611
+
612
+ On a subscription there is no per-token price. Once the route reports its
613
+ usage windows, a line above the stats shows them instead:
614
+
615
+ ```
616
+ 5h 16% (renews 06:30 am) | 7d 14% (renews Mon Oct 26 04:00 am)
617
+ ```
618
+
619
+ That is 16% of the five-hour window and 14% of the week spent, and when each
620
+ renews. A window turns yellow at 75% and red at 90%, and drops to 0% by itself
621
+ when it renews. ChatGPT and Claude Code report both windows on every answer.
622
+ A Claude Code older than the field HX reads reports a percentage only near a
623
+ limit, so until then its window shows only when it renews:
624
+ `5h (renews 06:30 am)`. Before the first answer there is
625
+ no usage line and the cost field says `sub`, because nothing has been
626
+ reported yet. `/cost` lists the same windows. Devin reports no usage.
627
+
628
+ On a narrow terminal the usage line gives up detail in steps: the clock time
629
+ of a renewal days away, then the renewals, then every window but the one
630
+ nearest its limit. The stats line drops whole fields, least important first:
631
+ the latency, the token counts, the cache. The context gauge stays.
632
+
633
+ ---
634
+
635
+ ## Configuration
636
+
637
+ Settings are JSON, merged lowest to highest:
638
+
639
+ ```
640
+ defaults < ~/.hx/settings.json < ./.hx/settings.json < ~/.hx/projects/<project>/settings.local.json < HX_* env < CLI flags
641
+ ```
642
+
643
+ Permission rule lists and `prompt.append` are unioned across layers, so a
644
+ project can add a deny rule — or a line to the system prompt — without
645
+ discarding yours. Everything else is replaced.
646
+
647
+ `./.hx/settings.json` is the project's shared file, yours to check in, and
648
+ nothing writes to it but you — HX only ever reads it. Versions before the local
649
+ layer existed did append grants there; those are lifted out into the file below
650
+ on the next run, once, and a rule you put back by hand afterwards stays put.
651
+ Only `allow` moves: a project's `deny` and `ask` are the guardrails it shares,
652
+ and HX never wrote them.
653
+
654
+ Anything HX decides on your behalf - an "always allow" grant, for one - goes in
655
+ `~/.hx/projects/<project>/settings.local.json` instead. Still per project,
656
+ because a grant is: allowing `pytest` in one repo should not allow it
657
+ everywhere. But under your home rather than in the checkout, so HX never leaves
658
+ a file in your repository for you to find in a diff. If an older version
659
+ already wrote `./.hx/settings.local.json`, it is moved there on the next run,
660
+ along with the `.hx/.gitignore` it added to hide it.
661
+
662
+ ```jsonc
663
+ {
664
+ "theme": "dark", // dark | light | ansi, or a file in ~/.hx/themes
665
+ "quiet_startup": false, // skip the startup header
666
+
667
+ "models": {
668
+ "model": "anthropic/claude-sonnet-4.5",
669
+ "subagent_model": null, // defaults to "model"
670
+ "title_model": null, // model that names sessions; defaults to "model"
671
+ "max_tokens": 8192,
672
+ "temperature": null,
673
+ "reasoning_effort": null, // Codex route: null = the model's own default
674
+ "codex_models": [] // extra Codex ids, e.g. ["gpt-5.6-terra"]
675
+ },
676
+
677
+ "permissions": {
678
+ "mode": "default", // plan | default | acceptEdits | bypass
679
+ "allow": ["Bash(git status:*)"],
680
+ "ask": [],
681
+ "deny": ["Read(**/.env)"],
682
+ "sandbox": true,
683
+ "allow_network": false // outbound network for sandboxed commands
684
+ },
685
+
686
+ "context": {
687
+ "compact_at": 0.80, // fraction of the window that triggers compaction
688
+ "keep_recent_turns": 6, // turns kept verbatim across a compaction
689
+ "tool_output_char_cap": 25000,
690
+ "tool_output_line_cap": 2000,
691
+ "git_notices": true // late-inject the branch and outside file changes
692
+ },
693
+
694
+ "bash": {
695
+ "timeout_seconds": 120,
696
+ "max_timeout_seconds": 600, // ceiling; caps what the model may ask for
697
+ "shell": null // defaults to $SHELL
698
+ },
699
+
700
+ "prompt": {
701
+ "system": null, // replaces the built-in system prompt
702
+ "append": [] // added after it; unioned across layers
703
+ },
704
+
705
+ "tui": {
706
+ "enterWhileBusy": "queue", // queue | steer - what enter does mid-turn
707
+ "fullscreen": false // true takes the whole window (see /fullscreen)
708
+ },
709
+
710
+ "tools": {
711
+ "hashline": true // Read labels lines with content anchors Edit accepts
712
+ }
713
+ }
714
+ ```
715
+
716
+ `hooks` is configured in the same files but is deliberately not part of this
717
+ merge: it is read per layer, and only from your own settings. See
718
+ [Extending it](#extending-it).
719
+
720
+ Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
721
+ `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_GIT_NOTICES`,
722
+ `HX_THEME`, `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state, `HX_TAVILY_API_KEY`
723
+ for web search, `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
724
+ [Credentials](#credentials)), and `HX_OPENROUTER_BASE_URL` to point the OpenRouter
725
+ route at another OpenAI-compatible endpoint - a gateway, or a recording proxy.
726
+ `HX_CODEX_BASE_URL` does the same for the ChatGPT route's backend.
727
+ `HX_MODIFIER_PROBE=0` stops HX asking macOS whether shift is held when an
728
+ `enter` arrives (see [Keys](#keys)).
729
+
730
+ ### The system prompt
731
+
732
+ `hx prompt` prints the prompt this directory resolves to, with its source on
733
+ stderr so stdout stays pipeable. `/prompt` shows the same thing inside a running
734
+ session.
735
+
736
+ The built-in prompt is `SYSTEM_PROMPT` in `src/core/context.ts`. It is
737
+ replaced by the first of these that exists:
738
+
739
+ | Source | Scope |
740
+ |---|---|
741
+ | `--system-prompt TEXT` or `@path`, or `prompt.system` | one run |
742
+ | `./.hx/system-prompt.md` | this project |
743
+ | `~/.hx/system-prompt.md` | you, everywhere |
744
+ | the built-in prompt | fallback |
745
+
746
+ Appended text is added after whichever prompt won, in this order:
747
+ `~/.hx/system-prompt-append.md`, `./.hx/system-prompt-append.md`, then each
748
+ `--append-system-prompt` value (and `prompt.append`) in the order given. Nothing
749
+ appended is ever discarded by a later layer.
750
+
751
+ This is a different lever from `AGENTS.md`: `AGENTS.md` describes *the project* and is
752
+ injected as its own context section, while these files change *the agent's
753
+ instructions*. Both are read once at startup and frozen, because they sit above
754
+ the first cache breakpoint — an override edited mid-session applies on the next
755
+ run, and `/prompt` says so when it spots one.
756
+
757
+ ### Sessions are named
758
+
759
+ After the first exchange, HX asks the model for a short name for the session and
760
+ writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
761
+ than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
762
+ if you want a cheaper model for it — and it is counted in `/cost` like any other.
763
+ If the call fails, or you leave before it is back, the session is still named,
764
+ from your first message.
765
+
766
+ The live session is never renamed while it runs, and nothing is renamed on
767
+ `/exit`, which returns your shell at once. A session you leave with `/clear` or
768
+ `/resume` - including a `/resume` into another directory - is renamed in the
769
+ background for what it became. `/title <text>` renames it yourself, and a name
770
+ you chose is never replaced.
771
+
772
+ `/resume` lists every session except the one you are in: this directory's
773
+ first, then other directories', each row naming where it lives. A session from
774
+ this directory is swapped in place. One from elsewhere belongs to that
775
+ directory - its sandbox, permission rules, prompt and tools - so HX restarts
776
+ itself there with `hx resume <id>`, keeping options like `--mode` and
777
+ `--model`.
778
+
779
+ ### Themes
780
+
781
+ A theme is a JSON file: a `vars` block of raw colours, and a `colors` block
782
+ mapping semantic roles onto them. Drop one in `~/.hx/themes/mine.json` and
783
+ `/theme mine` picks it up — no restart, no code change.
784
+
785
+ ```jsonc
786
+ {
787
+ "name": "mine",
788
+ "dark": true,
789
+ "vars": { "green": "#b5bd68", "gray": "#808080" },
790
+ "colors": {
791
+ "background": "#18181e",
792
+ "success": "green", // a vars key, or a literal
793
+ "muted": "gray"
794
+ // ...every role; a missing one is an error, not a silent black
795
+ }
796
+ }
797
+ ```
798
+
799
+ The role names are pi's, so [pi](https://github.com/earendil-works/pi) theme
800
+ files load here unchanged. `src/tui/themes/dark.json` is the reference.
801
+ A few roles pi has no concept of may be left out, and borrow another role:
802
+ `toolArgs`, the colour of a tool call's paths and patterns, borrows `accent`;
803
+ `panel` and `mdCodeBlockBg` borrow `surface`; `userText` borrows `text`; and
804
+ `diffHunk` borrows `mdLink`. `background` and `surface` may come from pi's
805
+ `export` instead. pi's `toolPendingBg` is accepted and ignored.
806
+
807
+ ### Where things live
808
+
809
+ | Path | What |
810
+ |---|---|
811
+ | `~/.hx/settings.json` | your settings |
812
+ | `~/.hx/auth.json` | API keys and logins, mode 0600 |
813
+ | `~/.hx/models.json` | cached model catalogue, refreshed daily |
814
+ | `~/.hx/themes/` | your themes, one JSON file each |
815
+ | `~/.hx/keybindings.json` | your key overrides |
816
+ | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
817
+ | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
818
+ | `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
819
+ | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
820
+ | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
821
+ | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
822
+ | `~/.hx/projects/<project>/settings.local.json` | this machine's settings for one project - where "always allow" lands |
823
+ | `./.hx/settings.json` | project settings, checked in if you like — read by HX, never written |
824
+ | `./.hx/mcp.json` | project MCP servers |
825
+ | `./.hx/system-prompt.md`, `./.hx/system-prompt-append.md` | project prompt overrides |
826
+ | `./.hx/skills/`, `./.hx/agents/` | project skills and agents |
827
+ | `AGENTS.md` | project instructions: the repository root's, then each one down to where HX started |
828
+
829
+ `AGENTS.md` is the place for things a newcomer would get wrong: how to run the
830
+ tests, conventions, what not to touch. `/init` writes a first draft. It is
831
+ loaded once per session and frozen, so it costs one prefix, not one per turn.
832
+
833
+ `~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
834
+ like commits written, tools you always want used, in every repository. It is
835
+ loaded first and the project's `AGENTS.md` after it, so where the two disagree
836
+ the project has the last word. The first session creates it empty, ready to
837
+ fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
838
+
839
+ Started below the repository root - in `services/billing`, say - HX still loads
840
+ the root's `AGENTS.md`, then every `AGENTS.md` on the way down to that directory,
841
+ the nearest last, so a package can refine the repository's rules. A submodule
842
+ counts as part of the repository around it. Nothing above the repository root is
843
+ read; outside a repository only the current directory is.
844
+ Subagents run under the same files as the session that started them.
845
+
846
+ ---
847
+
848
+ ## Safety
849
+
850
+ Two independent layers guard every tool call, and both must pass.
851
+
852
+ **Permission rules** are `Tool(specifier)` strings — `Bash(git commit:*)`,
853
+ `Edit(src/**)`, `Read(**/.ssh/**)`. Deny beats ask beats allow, and a deny
854
+ holds even in bypass mode. Shell commands are decomposed into their real
855
+ segments first, so an allow rule for `git status` does not carry
856
+ `&& rm -rf /` along with it; a command that cannot be decomposed with
857
+ confidence prompts rather than passing. What runs inside a command counts as a
858
+ segment of its own - `$(...)`, `<(...)`, `>(...)` - so `cat <(rm -rf src)` is
859
+ not a read-only `cat`. A deny rule matches the command however it is spelled:
860
+ `deny: Bash(rm:*)` also stops `FOO=1 rm`, `/bin/rm` and `nohup rm`. An allow
861
+ rule covers only what it names, so `allow: Bash(ls)` does not cover
862
+ `LD_PRELOAD=x ls`.
863
+
864
+ **Credential files** - the same `~/.ssh`, `~/.aws` and `auth.json` the sandbox
865
+ guards - always ask before Read, Grep, Glob, Symbols, Write or Edit touch them,
866
+ in every mode, symlinks followed. Those tools run inside HX, outside the
867
+ sandbox. An allow rule that names the file, such as `allow: Read(~/.ssh/config)`,
868
+ opens that one file. A search that starts above them, from `$HOME`, leaves
869
+ them out.
870
+
871
+ **An OS sandbox** wraps command execution: Seatbelt on macOS, bubblewrap on
872
+ Linux. The filesystem is readable, writes are confined to the project and the
873
+ temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
874
+ unreadable and unwritable - even inside the project, and even before they
875
+ exist - and outbound network is off. If neither backend is present the
876
+ status bar says `no-sandbox` rather than implying protection that is not there.
877
+ The API keys HX signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`,
878
+ and the Tavily and Devin keys) are removed from the environment of every
879
+ command it runs.
880
+
881
+ Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
882
+ offered to the model), `default`, `acceptEdits`, `bypass`. `acceptEdits` covers
883
+ the project's own files: a Write or Edit outside the project, through a symlink
884
+ that leads out of it, or into its `.git` or `.hx` folder still asks.
885
+
886
+ **Nothing that arrives with a clone runs by itself.** Hooks in a project's
887
+ shared `.hx/settings.json` are ignored, and a server in its `.hx/mcp.json`
888
+ starts only once you approve it - see [Extending it](#extending-it).
889
+
890
+ ---
891
+
892
+ ## Context engineering
893
+
894
+ Long sessions are the normal case, so the harness is built around keeping the
895
+ provider's KV cache warm and the window from filling up.
896
+
897
+ **A stable prefix.** System prompt, tool schemas and project context are
898
+ assembled in a fixed order and never mutated mid-session. Cache breakpoints sit
899
+ at the end of that static block and at a rolling point before the recent turns,
900
+ which only advances once enough tokens have accumulated behind it.
901
+
902
+ **Late injection** carries everything that changes per turn - the todo list,
903
+ files that changed on disk since HX read them, the git branch and any file the
904
+ working tree gained or lost outside the session - on the tail of the newest
905
+ user message rather than in the prefix. Stale copies are stripped and
906
+ regenerated each turn, so six todo updates leave one copy in context, not six.
907
+
908
+ **Working-tree notices** come from one `git status --porcelain=v2 --branch -z`
909
+ per provider call, run without a shell and bounded by a two-second timeout. It
910
+ runs in a worker thread, as every injector does: a turn makes one of these per
911
+ tool round-trip, and on the event loop a slow repository would freeze the UI
912
+ each time. A file HX has already read is left to the stale-file notice, so no
913
+ path is reported twice; outside a repository the watcher disables itself for
914
+ the session. Turn it off with `"context": {"git_notices": false}`.
915
+
916
+ **Rewind** (`/rewind`) picks an earlier prompt and takes the session back to
917
+ it: the transcript is cut there and every file HX wrote after it is restored
918
+ from a content-addressed snapshot taken before each Write and Edit. A file
919
+ someone else changed in the meantime is reported and left alone, and shell
920
+ commands are outside the scheme - the report says so rather than implying the
921
+ tree was fully returned. The prompt that was cut comes back in the input box,
922
+ because a rewind is usually the first half of saying it differently. Nothing is
923
+ deleted from the session file: the rewind is one more append, and replaying the
924
+ file reproduces the state - which is also how an undone compaction gives its
925
+ messages back. The cost ledger is deliberately not rewound; those tokens were
926
+ spent.
927
+
928
+ **Compaction** fires at 80% of the window, or on `/compact [focus]`. Older
929
+ turns are replaced by a structured summary; the recent turns and the todo list
930
+ survive verbatim, and the boundary snaps to a turn edge so a tool call is never
931
+ severed from its results. Superseded messages are flagged, not deleted, so
932
+ resume replays exactly what happened.
933
+
934
+ **Output capping** keeps the head and tail of a large tool result, spills the
935
+ rest to the session directory, and hands the model that path to grep.
936
+
937
+ ---
938
+
939
+ ## Tracing a session
940
+
941
+ `/trace` writes everything that happened to one self-contained HTML file and
942
+ opens it in a browser:
943
+
944
+ ```
945
+ /trace # ~/.hx/sessions/<id>/trace.html
946
+ /trace ~/Desktop/bug.html # somewhere you choose
947
+ ```
948
+
949
+ Everything means everything. The system prompt, the project context, the skills
950
+ index, every tool schema as the provider received it, every prompt, every
951
+ reply, every thinking block, every tool call next to the result it produced,
952
+ the per-call token ledger, and the files HX changed. Late-injected reminders
953
+ and compacted turns are in there too, behind a toggle that says how many there
954
+ are — a trace that hides the reminders and the superseded turns is not a trace
955
+ of the session that ran.
956
+
957
+ Nothing is summarised and nothing is prettified. Assistant text is shown as it
958
+ arrived, backticks and all, because a trace exists to answer "what actually
959
+ went through" and a rendered version of that is a different document. Long tool
960
+ results are folded rather than truncated, with the character count on the fold.
961
+
962
+ **Thinking, where the provider allows it.** How much of a model's reasoning HX
963
+ ever sees is the provider's decision, not HX's. Anthropic's models return the
964
+ thinking itself. The Responses API - the OpenAI and Codex routes - returns a
965
+ one-line summary in plaintext and the reasoning encrypted, to be replayed on
966
+ the next call and read by nobody, HX included. A trace labels which of the two
967
+ it is holding, because a one-line thinking block is otherwise indistinguishable
968
+ from a trace that dropped the rest.
969
+
970
+ A running session traces as it stands right now, including the turn that just
971
+ landed. The page says so, because a trace of a live session is a snapshot.
972
+
973
+ **Reading it.** Long tool results are folded and the reference panels - the
974
+ prompt, the schemas, the ledger - start shut, so the transcript is what you
975
+ land on. "Expand all" opens the whole page, panels included, and turns into
976
+ "Collapse all". A session with no turns yet opens its panels on its own, since
977
+ there is nothing else to read.
978
+
979
+ **Dark or light.** The page follows your system by default and a button in the
980
+ corner overrides it, remembered per browser. No stylesheet is fetched to do it.
981
+
982
+ **Nothing is uploaded and nothing is fetched.** The payload, the stylesheet and
983
+ the script all live in the file, so it opens offline, renders the same a year
984
+ later, and a trace of a private repository stays as private as the directory
985
+ you wrote it into. The embedded payload is also the machine-readable trace: it
986
+ is JSON, in a `<script id="trace-data">` element, and `/trace` is the only way
987
+ HX will ever put your transcript anywhere but `~/.hx`.
988
+
989
+ From outside a session, on any transcript on the machine:
990
+
991
+ ```sh
992
+ hx trace # the last session in this directory
993
+ hx trace 20260918-214743-b5b8f9 # one by id
994
+ hx trace ~/somewhere.html # the last session, to a path you choose
995
+ hx trace <id> ~/somewhere.html # both, to a path or a directory
996
+ open "$(hx trace)" # the path is all that goes to stdout
997
+ ```
998
+
999
+ `hx trace` reads the transcript and runs nothing, so it works on a session that
1000
+ ended months ago and on one another terminal has open right now.
1001
+
1002
+ **What a trace can be missing.** The prompt and the tool schemas are written to
1003
+ the transcript on the session's first provider call — the first moment the tool
1004
+ registry has settled, because MCP servers connect after the loop is built. A
1005
+ session traced before that call, or one recorded by a version of HX older than
1006
+ this feature, has the transcript but not the prompt, and the page leaves those
1007
+ panels out rather than guessing. `/trace` inside a live session fills them in
1008
+ from the loop either way.
1009
+
1010
+ ---
1011
+
1012
+ ## Reading and editing code
1013
+
1014
+ **Line anchors.** `Read` labels every line with a short content hash:
1015
+
1016
+ ```
1017
+ 1 a3f9 def handle(request):
1018
+ 2 7c1c if request.method == "POST":
1019
+ ```
1020
+
1021
+ The anchor is a hash of the line and its two neighbours, truncated to the
1022
+ narrowest width that is unambiguous in that file. `Edit` takes those anchors
1023
+ instead of retyped content:
1024
+
1025
+ ```json
1026
+ { "file_path": "app.py", "hashline": [{ "start": "a3f9", "end": "7c1c", "new_string": "..." }] }
1027
+ ```
1028
+
1029
+ The span is inclusive; omit `end` to replace one line, pass an empty
1030
+ `new_string` to delete. Anchors are recomputed from disk when the edit runs, so
1031
+ a file that moved under the model fails to resolve and the patch is rejected
1032
+ rather than applied at stale coordinates. Exact-match `old_string` still works
1033
+ and is unchanged; anchors are a second shape, not a replacement. Turn the whole
1034
+ thing off with `"tools": {"hashline": false}` and reads go back to plain
1035
+ `cat -n`.
1036
+
1037
+ **Images.** `Read` on a PNG, JPEG, GIF, WebP, BMP or TIFF - or on a file with
1038
+ no extension whose bytes are one - returns the image itself for the model to
1039
+ look at, normalised the way a pasted one is (see [Images](#images)).
1040
+
1041
+ **Symbols** answers structural questions with a parse tree rather than a
1042
+ regex — three modes, `outline` for one file's shape, `definition` for where a
1043
+ name is defined, `references` for where it is used:
1044
+
1045
+ ```
1046
+ Symbols(mode="references", symbol="handle")
1047
+ ```
1048
+
1049
+ Because it matches identifier nodes, the same word in a comment or a string
1050
+ literal never matches. It cannot resolve types, so an unrelated symbol with the
1051
+ same name in another file still does; it is tighter than `Grep`, not a language
1052
+ server. Python, TypeScript, JavaScript, Go and Rust.
1053
+
1054
+ It needs grammars that are not installed by default, and the tool is simply not
1055
+ registered without them — its schema never enters the cached prefix advertising
1056
+ something that cannot run:
1057
+
1058
+ ```sh
1059
+ npm install --global @vscode/tree-sitter-wasm
1060
+ ```
1061
+
1062
+ ---
1063
+
1064
+ ## Extending it
1065
+
1066
+ **Skills** are directories containing `SKILL.md` with YAML frontmatter:
1067
+
1068
+ ```markdown
1069
+ ---
1070
+ name: deploy
1071
+ description: Tag, build and ship a release
1072
+ allowed-tools: Read, Bash # optional; narrows the toolset while active
1073
+ ---
1074
+
1075
+ 1. Run the tests.
1076
+ 2. Tag the commit.
1077
+ ```
1078
+
1079
+ Drop them in `.hx/skills/<name>/` or `~/.hx/skills/<name>/`; a project skill
1080
+ shadows a user one of the same name. Only the name and description enter the
1081
+ context — the body loads when the model calls `Skill(name)`, so a hundred
1082
+ installed skills cost a hundred lines, not a hundred documents.
1083
+
1084
+ **Subagents** run in their own context with their own transcript, tool
1085
+ allowlist and model. Only the final report returns to the parent, so a long
1086
+ search costs the caller one paragraph instead of every intermediate tool
1087
+ result. `explore`, `plan` and `general` ship built in; add your own as
1088
+ `.hx/agents/<name>.md`:
1089
+
1090
+ ```markdown
1091
+ ---
1092
+ name: reviewer
1093
+ description: Reviews a diff against the project's conventions
1094
+ tools: Read, Grep
1095
+ model: openai/gpt-5 # optional
1096
+ ---
1097
+
1098
+ You review code. Be specific and cite file:line.
1099
+ ```
1100
+
1101
+ A subagent never gets the `Task` tool, so recursion is impossible by
1102
+ construction.
1103
+
1104
+ **MCP servers** go in `.hx/mcp.json`:
1105
+
1106
+ ```json
1107
+ {
1108
+ "mcpServers": {
1109
+ "local": { "command": "python", "args": ["server.py"] },
1110
+ "remote": { "url": "https://example.com/mcp" }
1111
+ }
1112
+ }
1113
+ ```
1114
+
1115
+ Or `hx mcp add local python server.py`, or
1116
+ `hx mcp add remote --url https://example.com/mcp`.
1117
+
1118
+ A server in the project's `.hx/mcp.json` runs a command on your machine, and
1119
+ the file arrives with a clone - so HX does not start it until you approve it.
1120
+ A session names each one waiting, with what it runs. `hx mcp approve local`
1121
+ starts it from the next session on, and `hx mcp add` approves what it adds.
1122
+ The approval is for that command: if a `git pull` changes it, the server waits
1123
+ again. Servers in your own `~/.hx/mcp.json` need no approval.
1124
+
1125
+ Tools arrive namespaced
1126
+ `mcp__<server>__<tool>` in a deterministic order. Servers connect concurrently
1127
+ with a per-server timeout; one that is broken or slow logs a warning and is
1128
+ dropped rather than taking the session with it. A server that changes its tool
1129
+ list mid-session (`notifications/tools/list_changed`) has the new list swapped
1130
+ in without a restart.
1131
+
1132
+ A remote server that asks for sign-in - Atlassian's, for one - is signed in to
1133
+ over OAuth, with nothing to configure:
1134
+
1135
+ ```json
1136
+ { "mcpServers": { "atlassian": { "url": "https://mcp.atlassian.com/v2/mcp" } } }
1137
+ ```
1138
+
1139
+ It shows as needing sign-in until you run `/mcp login atlassian` in a session
1140
+ or `hx mcp login atlassian` in a shell. That opens the browser, and the tools
1141
+ arrive as soon as you approve - no restart. HX follows the MCP authorization
1142
+ spec: it finds the authorization server from the server's metadata, registers
1143
+ itself as a client, signs in with PKCE, and keeps the token in `~/.hx/auth.json`
1144
+ (mode 0600), refreshing it before it expires and again if the server rejects
1145
+ it. Over SSH, paste the final redirect URL into the prompt instead.
1146
+ `/mcp logout atlassian` forgets it.
1147
+
1148
+ A server that does not offer client registration needs a client you register
1149
+ with it yourself, and anything else about the sign-in can be pinned the same
1150
+ way:
1151
+
1152
+ ```json
1153
+ "oauth": { "clientId": "...", "clientSecret": "...", "scope": "read write", "callbackPort": 33418 }
1154
+ ```
1155
+
1156
+ To send a token of your own instead - an API key - give the server an
1157
+ `Authorization` header, and OAuth stays out of the way:
1158
+ `hx mcp add jira --url https://mcp.atlassian.com/v2/mcp --header "Authorization: Basic <base64 email:token>"`.
1159
+
1160
+ **Hooks** are shell commands HX runs at named points in a turn. Four events:
1161
+ `PreToolUse`, `PostToolUse`, `UserPromptSubmit` and `Stop`.
1162
+
1163
+ ```json
1164
+ {
1165
+ "hooks": {
1166
+ "PreToolUse": [
1167
+ {
1168
+ "matcher": "Bash",
1169
+ "hooks": [{ "type": "command", "command": "~/.hx/guard-bash.sh", "timeout": 10 }]
1170
+ }
1171
+ ],
1172
+ "PostToolUse": [
1173
+ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "ruff check --quiet" }] }
1174
+ ]
1175
+ }
1176
+ }
1177
+ ```
1178
+
1179
+ `matcher` is a regex matched against the whole tool name; omit it, or use `*`,
1180
+ to match every tool. The hook reads a JSON event on stdin — `session_id`,
1181
+ `cwd`, `hook_event_name`, and `tool_name`/`tool_input` where they apply — and
1182
+ answers with its exit code:
1183
+
1184
+ | Exit | Means |
1185
+ |---|---|
1186
+ | `0` | allow |
1187
+ | `2` | block. stderr becomes the reason the model is given |
1188
+ | anything else | the hook is broken. Reported as a notice; the action proceeds |
1189
+
1190
+ That last row is deliberate: a typo in a shell command must not be able to
1191
+ wedge a session. Only an explicit refusal stops anything.
1192
+
1193
+ A hook may also print JSON on stdout: `{"decision": "block", "reason": "..."}`
1194
+ refuses, `{"updatedInput": {...}}` rewrites a tool's arguments before the
1195
+ permission engine sees them, and `{"additionalContext": "..."}` adds a note for
1196
+ the model to read. The format matches Claude Code's, so hooks already written
1197
+ against that work here unchanged.
1198
+
1199
+ A worked example ships in the repository:
1200
+ [`examples/hooks/check.sh`](https://github.com/aletisunil/hx/blob/main/examples/hooks/check.sh)
1201
+ runs the checker a project already has — `ruff`, `tsc`, `go vet`, `cargo
1202
+ check` — against the one file the model just edited, and returns whatever it
1203
+ says as `additionalContext`. The model sees the errors appended to its own edit
1204
+ result and fixes them on the next turn, rather than discovering them at test
1205
+ time. It exits 0 either way: by `PostToolUse` the write has already landed, so
1206
+ refusing it would be an answer to something that already happened.
1207
+
1208
+ ```sh
1209
+ curl -o ~/.hx/check.sh https://raw.githubusercontent.com/aletisunil/hx/main/examples/hooks/check.sh
1210
+ chmod +x ~/.hx/check.sh
1211
+ ```
1212
+
1213
+ **Where hooks may be declared is a security boundary.** They are loaded from
1214
+ `~/.hx/settings.json` and `~/.hx/projects/<slug>/settings.local.json` only.
1215
+ Hooks in a project's checked-in `.hx/settings.json` are ignored and listed by
1216
+ `/hooks`, because that file arrives with a clone and cloning a repository must
1217
+ never be enough to run commands on the machine that cloned it. To adopt a
1218
+ shared hook, copy it into your own settings — a deliberate act by the person
1219
+ who will run it. Subagents inherit the parent's hooks, so a guard cannot be
1220
+ bypassed by delegating through `Task`.
1221
+
1222
+ ---
1223
+
1224
+ ## Development
1225
+
1226
+ ```sh
1227
+ git clone https://github.com/aletisunil/hx && cd hx
1228
+ npm ci
1229
+
1230
+ npm run check # tsc, biome, and the source-literal check
1231
+ npm run build # dist/hx.js, the bundle npm ships
1232
+ node dist/hx.js # run from the checkout
1233
+ npm run e2e # build, then the whole end-to-end suite
1234
+ uv run ruff check . && uv run ruff format --check . # the suite's own Python
1235
+ ```
1236
+
1237
+ The suite is end-to-end, written in Python with pytest, and run with
1238
+ [uv](https://docs.astral.sh/uv/). Each test in `tests/e2e/` starts the `hx`
1239
+ bundle - on a real pseudo-terminal for the TUI, or as a plain subprocess for
1240
+ print mode and the other commands - in a private `$HOME` and `$HX_HOME`, and
1241
+ points it at a local OpenRouter stand-in with `HX_OPENROUTER_BASE_URL`, and
1242
+ at a ChatGPT stand-in with `HX_CODEX_BASE_URL`.
1243
+ Everything between the keyboard and the socket is the shipped code; only the
1244
+ model's replies are scripted. The screen is read back through a VT emulator,
1245
+ so assertions are on the cells a terminal shows, not on the bytes HX meant to
1246
+ write. The suite never imports HX: it tests `dist/hx.js` by default, and any
1247
+ other `hx` - an installed one, say - named by `HX_E2E_BIN`.
1248
+
1249
+ Every run writes `e2e-report/`: `index.html` shows each test's outcome and
1250
+ every screen it checked, rendered in colour, and one text file per screen lets
1251
+ two runs be compared with `diff -r`. CI uploads it from every test job as an
1252
+ `e2e-report-*` artifact. Set `HX_E2E_REPORT` to write it somewhere else.
1253
+
1254
+ Tests that need the OS sandbox are skipped where there is no backend
1255
+ (`sandbox-exec` on macOS, `bwrap` on Linux).
1256
+
1257
+ ### Layout
1258
+
1259
+ ```
1260
+ src/
1261
+ cli.ts runtime.ts config.ts paths.ts frontmatter.ts git.ts trace.ts
1262
+ core/ loop, context assembly, compaction, late injection, sessions, usage
1263
+ auth/ credential store, OAuth flows, per-route resolution
1264
+ providers/ OpenRouter, Codex, Claude Code, Devin, the model catalogue
1265
+ tools/ Bash, Read, Write, Edit, Glob, Grep, Symbols, TodoWrite, Task,
1266
+ WebSearch, WebFetch, line anchors, output capping
1267
+ permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
1268
+ hooks/ the four events, the trust boundary, subprocess execution
1269
+ skills/ agents/
1270
+ mcp/ client, OAuth, and approval for a project's servers
1271
+ keys.ts keybinding registry: ids, defaults, descriptions, user overrides
1272
+ term/ the renderer: raw mode, input decoding, the differ, the screen
1273
+ tui/ views, commands, per-tool renderers, themes
1274
+ themes/ the shipped palettes, as JSON
1275
+ py.ts pathlib.ts json.ts Python's string, path and JSON behaviour, which
1276
+ HX's output and files were written against
1277
+ ```
1278
+
1279
+ The core is headless and emits events; the TUI and print mode are both just
1280
+ consumers. Nothing under `core/`, `tools/`, `providers/` or `permissions/`
1281
+ imports `tui/`.
1282
+
1283
+ ---
1284
+
1285
+ ## Releasing
1286
+
1287
+ For maintainers. HX is published to npm as **`@sunilaleti/hx`**, on the
1288
+ `latest` tag, by CI. Every version is a stable release; there is no beta
1289
+ channel.
1290
+
1291
+ A push to `main` whose `package.json` version is not on npm yet is a release:
1292
+ once lint, the test suite and the install script pass, CI's `release` job
1293
+ publishes it to npm, tags it `vX.Y.Z` and makes the GitHub release, with the
1294
+ version's `CHANGELOG.md` section as its notes. A push that leaves the version
1295
+ alone publishes nothing.
1296
+
1297
+ ```sh
1298
+ # 1. move CHANGELOG.md's [Unreleased] entries under the new version, dated,
1299
+ # and leave a fresh empty [Unreleased] behind
1300
+ # 2. bump "version" in package.json
1301
+ npm version X.Y.Z --no-git-tag-version
1302
+ # 3. the whole suite; the version must have a CHANGELOG section
1303
+ npm run e2e
1304
+ git commit -am "chore(release): X.Y.Z"
1305
+ git push origin main
1306
+ ```
1307
+
1308
+ Step 1 is not optional and is not a convention: `tests/e2e/test_cli.py` fails the
1309
+ whole suite when the version has no `CHANGELOG.md` section, and the `release`
1310
+ job checks again before it publishes. The file ships inside the bundle and
1311
+ `hx changelog` prints it, so a version with no section there would reach users
1312
+ as a blank release.
1313
+
1314
+ One-time setup: CI publishes with npm's
1315
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm
1316
+ token is stored anywhere. On npmjs.com, open `@sunilaleti/hx` → Settings →
1317
+ Trusted Publisher, choose GitHub Actions, and enter owner `aletisunil`,
1318
+ repository `hx` and workflow `ci.yml`, with no environment.
1319
+
1320
+ To see what npm would ship before releasing:
1321
+
1322
+ ```sh
1323
+ npm pack --dry-run
1324
+ ```
1325
+
1326
+ ---
1327
+
1328
+ ## Status
1329
+
1330
+ Feature complete against the original plan: the agent loop, OpenRouter
1331
+ streaming with prefix caching and accurate cost accounting, session persistence
1332
+ and resume, the tool suite, the permission engine and OS sandbox, late
1333
+ injection, compaction, output capping, skills, subagents, MCP, and the TUI.
1334
+
1335
+ Four routes to a model: an OpenRouter API key, a ChatGPT Plus/Pro
1336
+ subscription over OAuth, a Claude Pro/Max subscription through Claude Code, or
1337
+ a Devin subscription over OAuth. The model id decides which.
1338
+
1339
+ Published to npm as [`@sunilaleti/hx`](https://www.npmjs.com/package/@sunilaleti/hx).
1340
+ The Python build that came before it, `hx-cli` on PyPI, stopped at 0.2.20.
1341
+
1342
+ No test reaches a real model: every route is proven against a local stand-in.