Compact, editor-like file edits for agents: select text, type the change, skip rewriting full patches.
hpatch is a stdin script language and CLI. The optional router sits in front of Codex Responses, swaps the native apply_patch surface for functions.hpatch, translates successful scripts into real apply_patch payloads, and leaves Codex’s sandbox and permission path intact so the UI still shows a normal diff.
TL;DR:
| Goal | Start here |
|---|---|
| Edit files from a script | CLI quick start |
| Use hpatch inside Codex | Codex router, then override base instructions |
| Inspect token savings | hpatch gain |
| Full grammar and semantics | hpatch --help, hpatch --tool-help, doc/spec/interface.md |
apply_patch makes the model emit envelope framing, old lines, new lines, and context that already exist in the repo. For an 11-line function replacement, hpatch asks the model for this:
functions.hpatch
in parser.go
rsel 50:60
type <<PATCH
func parse(input []byte) (Document, error) {
tokens, err := tokenize(input)
if err != nil {
return Document{}, fmt.Errorf("tokenize: %w", err)
}
document, err := buildDocument(tokens)
if err != nil {
return Document{}, fmt.Errorf("build document: %w", err)
}
return document, nil
}
PATCH
The same edit through direct apply_patch in Code Mode:
functions.exec
const result = await tools.apply_patch(`*** Begin Patch
*** Update File: parser.go
@@
-func parse(input []byte) (Document, error) {
- tokens := tokenize(input)
- if len(tokens) == 0 {
- return Document{}, errEmptyInput
- }
- document := buildDocument(tokens)
- if document.Empty() {
- return Document{}, errEmptyDocument
- }
- return document, nil
-}
+func parse(input []byte) (Document, error) {
+ tokens, err := tokenize(input)
+ if err != nil {
+ return Document{}, fmt.Errorf("tokenize: %w", err)
+ }
+ document, err := buildDocument(tokens)
+ if err != nil {
+ return Document{}, fmt.Errorf("build document: %w", err)
+ }
+ return document, nil
+}
*** End Patch
`);
text(result);
The direct call repeats all 11 old lines, then writes the same 11 new lines plus patch framing and the JavaScript carrier. hpatch writes the new function once; the router reads the old region from the baseline.
The router also supplies functions.hpatch to the provider with a Lark grammar. As the model writes the tool call, only tokens that can still lead to a valid script are allowed. Bad syntax never becomes a finished tool call, so there is no generate-reject-retry cycle for it. Grammar is syntax only: a valid script can still fail for missing files, ambiguous selectors, or conflicting edits, and those failures stay atomic.
- Go 1.26 or newer (
go installonly; no clone required for normal use) - For the router: Codex CLI with ChatGPT file auth (
codex login, credentials at~/.codex/auth.jsonor$CODEX_HOME/auth.json)
Install the CLI (requires Go 1.26+; binary lands in $(go env GOPATH)/bin or $GOBIN):
go install github.com/yusing/hpatch/cmd/hpatch@latestApply a script (writes only after the full script validates and stages; success report on stderr):
hpatch <<'EOF'
in src/app.go
tsel 12 "oldName"
type "newName"
EOFTranslate to an OpenAI apply_patch envelope without touching files (patch on stdout, pending report on stderr):
hpatch translate <<'EOF'
new message.txt
type "hello world\n"
EOFScript paths are workspace-relative.
| Surface | Workspace root | Path base inside root |
|---|---|---|
| Standalone CLI | Process current directory, or absolute --root |
., or --cwd (relative or absolute, must stay under root) |
| Codex router | The single usable absolute path from x-codex-turn-metadata |
Root itself (no CLI flags) |
Translated patches always use root-relative paths. Details: hpatch --help.
| Mode | Mutates files? | stdout | stderr |
|---|---|---|---|
hpatch |
Yes, after full validation | empty on success | final-state report |
hpatch translate |
No | apply_patch envelope |
pending final-state report |
hpatch gain |
No | metrics report | empty on success |
--help / --tool-help / --version |
No | help or version | empty |
Built-in references:
hpatch --help
hpatch --tool-help
hpatch translate --help
hpatch --versionThe router listens on HTTP, rewrites Responses traffic so Codex calls functions.hpatch, evaluates scripts against the workspace declared in x-codex-turn-metadata, and returns the translated apply_patch carrier to Codex. You see a real diff in the UI rather than a silent file rewrite.
On each request it strips the Code Mode ### apply_patch section from the functions.exec / additional_tools description and installs a standalone functions.hpatch tool instead. That rewrites only the tool definition the model is given for that turn; the history may still label the apply step as apply_patch because that is what Codex actually runs.
Defaults:
| Setting | Default |
|---|---|
| Listen | 127.0.0.1:8080 (--listen) |
| Upstream start timeout | 10m (--timeout) |
| Auth | ~/.codex/auth.json, or $CODEX_HOME/auth.json |
| Metrics / hooks | $XDG_CONFIG_HOME/hpatch or ~/.config/hpatch |
| Endpoints | POST /v1/responses, GET / (dashboard), GET /api/metrics |
The process must run as your login user so it can open the absolute workspace paths Codex sends and read your Codex credentials. A user systemd unit is the intended long-running setup.
go install github.com/yusing/hpatch/cmd/hpatch-router@latestDefault install path is ~/go/bin/hpatch-router (or $GOBIN/hpatch-router if GOBIN is set). Ensure that directory is on your PATH.
The unit template is published at
contrib/systemd/hpatch-router.service
(raw URL works after the repo is on GitHub):
mkdir -p ~/.config/systemd/user
curl -fsSL https://raw.githubusercontent.com/yusing/hpatch/main/contrib/systemd/hpatch-router.service \
-o ~/.config/systemd/user/hpatch-router.service
systemctl --user daemon-reload
systemctl --user enable --now hpatch-router.service
systemctl --user status hpatch-router.serviceOptional: keep the service after logout:
loginctl enable-linger "$USER"One-shot without the unit (still uses the installed binary):
hpatch-router --listen 127.0.0.1:8080If auth lives outside ~/.codex, or the binary is not in ~/go/bin, use a drop-in:
systemctl --user edit hpatch-router.service[Service]
Environment=CODEX_HOME=%h/.codex
# ExecStart=
# ExecStart=%h/.local/bin/hpatch-router --listen 127.0.0.1:9090Then systemctl --user daemon-reload && systemctl --user restart hpatch-router.service.
Add a Responses provider in ~/.codex/config.toml (or another Codex profile config under ~/.codex/):
[model_providers.hpatch]
name = "hpatch"
base_url = "http://127.0.0.1:8080/v1"
wire_api = "responses"
requires_openai_auth = trueMake it the default for the whole config:
model_provider = "hpatch"Or pick it per invocation (same pattern as other local providers):
codex --local-provider hpatch --ossProfiles work the same way: put the [model_providers.*] block in the profile config (or the main config), then run with --profile <name> --local-provider <provider> --oss.
Start sessions from a git worktree: the router requires exactly one usable absolute workspace root in turn metadata.
Useful checks:
systemctl --user status hpatch-router.service
journalctl --user -u hpatch-router.service -f
curl -sS http://127.0.0.1:8080/api/metrics
# open http://127.0.0.1:8080/ for the local dashboardThe router exposes functions.hpatch and strips apply_patch from the Code Mode functions.exec definition, but Codex’s default base prompt still tells the model to use apply_patch for local edits, and a runtime ALL_TOOLS dump can still list tools.apply_patch. Point Codex at a custom base-instructions file and replace that section so the model prefers hpatch even when it can see both.
-
Fetch a recent copy of the Codex default base prompt (keep the rest of the file; only replace the file-editing section):
-
In the file-editing section (often
## File editingor## File editing constraints), replace only theapply_patchguidance withcontrib/codex/file-editing-instructions.md. Leave dirty-worktree handling and the non-destructive git rules as in the default base prompt; those are not hpatch-specific. -
Point Codex at your file in
~/.codex/config.toml(or a profile config):
model_instructions_file = "/absolute/path/to/your/base_instructions.md"Do not rely on project AGENTS.md alone for this: the stock base prompt still steers file edits toward apply_patch. Override the base prompt the same way other host tooling (for example skills) does when it must replace a default section rather than append to AGENTS.md.
Authoritative guidance: hpatch --help and hpatch --tool-help. Contract: doc/spec/interface.md.
Selectors (prefer the first that fits):
- Complete logical lines or indentation changes:
rsel START:END - Exact non-whitespace content:
tsel FROM_LINE "TEXT" [N] - Block whose ends sit inside lines:
bsel "START" "END" - Exact rune columns only when needed:
sel LINE START:END
Common commands: in / new / mv / rm, type "…" or type <<PATCH … PATCH, del, copy / cut / paste, commit.
Rules worth remembering:
- Never put leading spaces or tabs in
tsel/bselanchors; start at stable non-whitespace content. - First
inof a file freezes an immutable baseline; selectors use that baseline untilcommit. - Disjoint edits can land together; overlapping replacements fail the whole script atomically.
- Rejection changes nothing. Router-owned retries can replace, delete, or insert failed commands by index without resending the full script.
Multiline example:
in parser.go
rsel 50:60
type <<PATCH
func parse(input []byte) (Document, error) {
tokens, err := tokenize(input)
if err != nil {
return Document{}, fmt.Errorf("tokenize: %w", err)
}
document, err := buildDocument(tokens)
if err != nil {
return Document{}, fmt.Errorf("build document: %w", err)
}
return document, nil
}
PATCH
Successful CLI and router edits record paired GPT-5 output-token estimates for:
- hpatch:
functions.hpatch+ the model-emitted script - baseline:
functions.exec+ a fixed program that callstools.apply_patchwith the translated envelope
Failed calls charge the full rejected hpatch payload against an empty-patch baseline. Final-state reports, diagnostics, and once-per-session tool definitions are tracked as input overhead separately.
hpatch gainThese are reproducible payload estimates, not provider billing totals. They omit reasoning tokens, commentary, and host-specific framing.
Hand-authored scenario comparison (does not update hpatch gain):
go run ./compareCLI: resolve workspace (--root / --cwd or process cwd) → parse script → evaluate against an in-memory baseline → stage multi-file result → commit (normal mode) or emit one apply_patch envelope (translate).
Router: load ChatGPT Codex auth → accept POST /v1/responses → expose functions.hpatch instead of Code Mode apply_patch → on tool call, translate against the single usable workspace from Codex metadata (CLI --root / --cwd are unused here) → return a carrier that makes Codex apply the real patch and surface the diff.
Workspace selection is host-owned. Zero or multiple usable roots fail closed. Codex still enforces sandbox permissions on apply.
| Doc | Contents |
|---|---|
doc/brief.md |
Product brief and scope |
doc/spec/index.md |
Spec inventory |
doc/spec/interface.md |
CLI, script, correction, metrics contracts |
doc/spec/comparison.md |
Token comparison scenarios |
doc/architecture/index.md |
Architecture ownership |
contrib/systemd/hpatch-router.service |
User unit template |
contrib/codex/file-editing-instructions.md |
Base-prompt replacement for file editing |
AGENTS.md |
Codex router E2E notes for agents |
Library use: module path github.com/yusing/hpatch. Importable as a library (hpatch.Translate, hpatch.Workspace, host metrics helpers); hosts should open an *os.Root capability for the workspace before calling in.
git clone https://github.com/yusing/hpatch.git
cd hpatch
go test ./...
go vet ./...
go install ./cmd/hpatch ./cmd/hpatch-router