Agent hook injection
Farhelm uses conversation reporters for Claude, Codex, Goose, Pi, OMP, and Grok so Resume lands in the conversation you
were actually in after a /clear or /new, instead of the one you threw away. Claude, Codex, Goose, Pi, and OMP get
their reporter from the launch Farhelm builds. Grok requires three user-installed hook entries because its supported
hook surface is configuration-based. Farhelm never edits that configuration for you.
For Claude and Codex, the injected flags ride on one command line and die with the process. On a Codex launch that gets
the flags, Codex prints one warning line about hook trust, and with that bypass in place any hook of your own in that
configuration home ($CODEX_HOME when it is set, ~/.codex otherwise) that you have not trusted runs too. A few Claude
and Codex invocation shapes turn injection off. Without a report, Farhelm cannot offer to resume that new conversation.
Farhelm does not guess which conversation to resume from files on disk. See the harness notes for Goose’s saved reporter and manual-resume dependency, Pi’s saved-file requirement and permission behavior, and OMP’s report-only contract and limits.
Cursor has basic launch support only: it uses no hook or status wrapper and has no conversation tracking or automatic Resume.
Grok’s integration documents the required SessionStart, UserPromptSubmit, and Stop entries,
the --no-leader ownership requirement, and exact two-file verification. It has no scanning fallback.
Why hooks at all
Section titled “Why hooks at all”Farhelm’s job on a restart is to bring back the conversation you were in, not just the agent. Until now it worked that
out from the outside, by watching which conversation file the agent created around the time you typed your first prompt.
That guess is right most of the time, but it is a guess, and it has one blind spot it cannot fix: when you run /clear
(Claude) or /new (Codex), the agent starts a brand-new conversation with a new id, and nothing on disk says “this
replaced that one”. A restart would then resume the conversation you had just thrown away.
Claude and Codex offer a session-start hook — a command they run whenever a conversation begins — and the hook receives
the conversation id. Grok’s three configured lifecycle hooks split the job: SessionStart selects the UUID, while
UserPromptSubmit and Stop can supply or refresh the exact saved-record path for that same selection. For Codex and
Grok, Farhelm also requires foreground-process attribution and vendor-owned record evidence: another process can inherit
the credential without becoming the conversation in your terminal. No status, control operation, or extra permission
rides on the identity report.
Goose exposes the same fact as AGENT_SESSION_ID to its MCP extensions. Pi exposes it to extensions together with its
optional persisted session file. OMP exposes it to extensions through separate session events — session_start,
session_switch (which carries the reason: a new, resumed, or forked conversation), session_branch, and agent_end —
and reports each conversation’s exact id and, when one exists, its session file. Their reporters feed the same
authenticated supervisor message as the hooks, but they never inspect or search the vendors’ own state directories.
OMP’s reporter is loaded the way Pi’s is: Farhelm materializes a private extension under its own state directory
(integrations/omp/, written with exact-bytes verification and private permissions) and loads it with -e <path> on
launches whose invocation is an interactive-shaped omp command — utility subcommands, occurrences of -p/--print,
--mode, --export, --alias, help/version/license/--list-models flags, reserved-word rejecting forms, internal
worker selectors, a genuine end-of-options --, and --trusted-extension launches (which OMP refuses to combine with
-e) get no extension. Farhelm’s own instructions pointer also yields: an invocation that already carries
--append-system-prompt (OMP keeps only the last occurrence) gets the reporter without a second occurrence that would
silently replace the user’s instructions. The reporter executable is named by the FARHELM_OMP_REPORTER_EXE environment
variable, and the extension never reads or writes OMP’s own state: what it knows comes from the session events it
subscribes to.
The no-errors policy
Section titled “The no-errors policy”The reporter must never be something you notice. The Claude, Codex, and manually configured Grok hooks, Goose’s reporter, and the Pi and OMP reporting subprocesses print nothing to your terminal, always exit successfully, and do no identity reporting outside a Farhelm session. The hooks and Goose’s reporter allow up to 30 seconds for reading the vendor’s payload and reporting it. If no supervisor is running, the hook keeps trying for about four seconds; if one is running but busy, the hook waits up to the full 30 seconds. Pi and OMP keep their published 2-second child timers, so their reporters can still be cut short while the hook is retrying; with no supervisor running, each report can now run until that timer ends it. If the vendor kills that child, its final hook-log line may be absent; writing the line afterwards is best-effort and unbounded, but by then the agent already has its answer. Goose is different because its credential-free reporter declaration persists in conversation metadata: outside Farhelm it still starts as a valid empty MCP server, but without Farhelm launch credentials it reports nothing and exposes no tools.
If a reporter fails, the session itself is unaffected. Farhelm gains no new conversation to resume. A new session whose agent has not reported offers a fresh launch instead. The one visible thing is the Codex warning line, and that is Codex talking, not the reporter.
Does my invocation get the hook?
Section titled “Does my invocation get the hook?”| invocation shape | kind farhelm derives | hook injected? | what you get instead |
|---|---|---|---|
claude <any flags> |
Claude | yes | — |
claude --settings <x> … |
Claude | no | a fresh launch on restart without a report — Claude honors only the LAST --settings, so injecting ours would silently drop yours |
codex <any flags>, codex resume … |
Codex | yes | — |
codex --dangerously-bypass-hook-trust …, codex -c hooks.… …, codex -c features.hooks… … |
Codex | no | no scanning fallback — you already control the hook configuration, and a second bypass flag could break the launch |
goose session … |
Goose | fresh only | resumes use the reporter Goose already persisted; utility, help, ambiguous, and reporter-name-collision forms are left unchanged |
pi … |
Pi | yes | the static extension reports the exact ID and optional persisted file; utility/help forms are left unchanged |
omp … |
OMP | interactive launches | the static extension reports the exact ID and optional session file; utility subcommands, print/mode/export/alias/help/version/license/list-models occurrences, reserved-word rejecting forms, internal worker selectors, --trusted-extension launches (OMP refuses to combine those with our -e), and a genuine end-of-options -- are left unchanged and runnable. A genuine -- additionally cannot be CREATED with the derived OMP resume template (the appended --resume would land in prompt position); an explicit resume template or a -- consumed as an option value creates normally |
grok --no-leader … |
Grok | configured manually | the three entries in the Grok guide report selection and exact saved-record evidence. Farhelm injects no Grok hook and never edits Grok configuration |
claude … -- <prompt>, codex … -- <prompt> |
either | no | Without a report, restart offers a fresh launch. After a bare --, injected flags would become prompt text |
/opt/bin/my-wrapper … |
generic | no | set the kind explicitly and forward the injected flags; without a report, restart offers a fresh launch. See agent wrappers |
env FOO=1 claude … |
generic | no | no hook as written, and no {cwd} needed — set the kind, and write the resume invocation out by hand, since the derived default would be env --resume … |
bash -c 'claude …' |
generic | no | no hook as written: the flags are appended to the argv, so they land as the shell’s $0 and the following positional parameters rather than reaching the agent inside the script string — a script that forwards "$@" does pass them on |
The wrapper path is absolute on purpose: farhelm does not expand ~ in an invocation. The fallback resume invocation
also has to be runnable as written — one carrying an unfilled {conversation} is refused rather than garbled, which
lands back on the fresh-launch offer. For the generic rows, setting the profile’s agent kind is what turns the
integration back on; farhelm then appends the flags to the END of whatever argv the profile names, which only helps if
that argv’s tail actually reaches the real agent. Agent wrappers covers both halves.
How the kind is decided
Section titled “How the kind is decided”Codex has additional process-chain restrictions even when injection succeeds; see
Codex launchers and wrappers. Grok has its own native process and
--no-leader requirements in the Grok guide.
By the basename of the invocation’s first word, compared for exact equality: claude is Claude, codex is Codex,
goose is Goose, pi is Pi, omp is OMP, grok is Grok, and everything else is generic. A path in front makes no
difference (/opt/bin/goose is still Goose); a decoration around it does (goose-wrapper and a raw env FOO=1 goose
invocation are both generic). That is deliberately dumb rather than clever, because a wrapper that silently inherited an
integration would look integrated and never capture anything. The profile’s agent-kind field overrides the derivation,
and is the supported way to tell farhelm what your wrapper really launches. Pi and OMP both additionally run a
VENDOR-SPECIFIC shape check before the extension rides along, and the two checks are not the same: OMP’s excludes its
utility subcommands, print/mode/export/alias occurrences, reserved-word rejecting forms, worker selectors, and
--trusted-extension launches, while Pi’s excludes only its own utility commands and help/version/export forms — an
unrecognized Pi flag or an option VALUE that merely looks excluded stays hooked for Pi (see the table above). Once a
structured launch identifies the kind, injection can preserve a simple leading env NAME=value … prefix; option-bearing
forms such as env -i … remain untouched.
What the hook does
Section titled “What the hook does”The farhelm binary itself is the hook, invoked as farhelm internal hook --vendor <adapter> --announce by an absolute
path for injected hooks — the announce flag is present by default; see “Turning it off” below for the switch that
removes it. Grok’s manual command is farhelm internal hook --vendor grok without --announce. The --vendor flag
names which adapter this hook invocation is (Claude, Codex, Pi, or OMP from an injected command and Grok from its manual
configuration; the Goose helper supplies its own internally), so the supervisor can refuse a report addressed to a
session of another kind before consulting any vendor state. It reads one callback payload from stdin and forwards the
conversation id, the vendor’s source, and any transcript path, event name, and subagent identity. Claude uses the
source for diagnostics. Codex requires SessionStart with source startup, resume, clear, or compact, plus
foreground attribution and exact-record validation. Grok requires SessionStart, UserPromptSubmit, or Stop; its
selecting event also carries source new or load and a timestamp used to reject delayed replacement reports. These
fields go over supervisor.sock in the supervisor’s state directory, authenticated with the per-session credential
already in the launch environment. There is no per-session socket. The hook always exits 0 — including on a panic — and
uses one 30-second budget for stdin and the round trip. A live connection can use that budget while the supervisor is
briefly busy; a restart gap gets a separate short reconnect window before the hook gives up quietly. Outside a farhelm
session there is no credential, so identity reporting exits immediately and touches no socket — though if --announce
was passed on the command line, the pointer line described below still prints regardless, since it needs no credential
at all.
It never prints a diagnostic, on either descriptor. It does print one deliberate line, on stdout, unless you have turned
that off: the pointer telling the agent that $farhelm ... in your message means the farhelm agent CLI and that
farhelm agent instructions explains it. Claude and Codex feed a SessionStart hook’s plain-text stdout into the
model’s context, which is the whole delivery mechanism — nothing is written to disk and nothing reaches your terminal.
See the “Talking to Farhelm from inside a session” in
old_readme.md, and FARHELM_AGENT_INSTRUCTIONS below.
What you will see
Section titled “What you will see”Claude: nothing new. The session row offers “resume conversation” within seconds of launch, before you have typed
anything, because Claude fires the hook at process start. Only a hook run by the session’s own foreground Claude counts:
the pane process, or its direct child under a one-level wrapper. A claude that the session starts through its shell (a
shelled-out sub-agent) inherits the session’s credential, but if it reports a conversation Farhelm refuses it, and the
hook log records a refused conflict line. A nested invocation cannot replace its parent’s target.
Codex: on the launches that get the flags, the ⚠ --dangerously-bypass-hook-trust is enabled line above the composer,
and the resume offer only after your first prompt — Codex fires SessionStart at first prompt submission, not at
launch. After a /new the identity updates the same way, on your next prompt rather than immediately. This was verified
against Codex 0.149.0, 0.149.1, and 0.155.1. On 0.155.1, /clear also switches the conversation at the next prompt,
while compaction retains it. On a version that accepts the flags but does not fire the hook, the hook log stays absent
and Farhelm gains no exact Codex resume target.
Codex’s native executable must be named codex; a wrapper may launch it, but renaming the native executable makes
foreground attribution unavailable. Farhelm verifies the exact root transcript named by the hook, including when
CODEX_HOME points somewhere else. A nested persistent or ephemeral invocation cannot replace its parent’s target.
After an attributed /clear, an unwritten transcript means no resume offer yet, not permission to resume the discarded
conversation. Farhelm waits for that exact file rather than searching for another one. Historical bare captured IDs
remain stored but are not treated as verified resume targets; no transcript is deleted or automatically selected
instead.
NOTE: with the bypass flag in place, any hook you have configured in Codex’s active configuration home ($CODEX_HOME
when it is set, ~/.codex otherwise) but have not trusted will also run during farhelm-launched Codex sessions. If that
is not what you want, turn injection off for Codex (below). Farhelm launches it without the bypass flag, but cannot
capture new exact resume targets without an attributable report.
OMP: the resume offer follows the extension’s session-event reports. Typing /new starts a conversation that OMP 18.2.4
persists at once, so the fresh conversation can be resumable immediately, not only after a first assistant message.
Farhelm does not scan OMP’s own state directory to guess at anything; what it knows about your conversations comes from
those reports alone — with one bounded exception: before a resume, Farhelm reads a bounded prefix of the exact session
file the report named, wherever it lives (usually inside OMP’s state directory), to verify it still belongs to that
conversation. The sidebar shows no waiting status for OMP — an approval prompt is the ordinary running/idle
classification.
Grok: the manually configured SessionStart selects the UUID, normally leaving a fresh conversation pending until
UserPromptSubmit or Stop supplies its exact updates.jsonl. Resume appears only while that file and its sibling
summary.json both identify the selected UUID. See the Grok guide for setup, the timestamp
ordering rule, and the accepted /new delivery race.
Turning it off
Section titled “Turning it off”FARHELM_AGENT_HOOKS in the supervisor’s environment, read once when the supervisor starts:
- unset, empty, or
all— every automatically configured kind gets its reporter. The default. none— no automatically configured kind gets its reporter.- a comma-separated list of kinds,
claude,codex,goose,pi, and/oromp— only those kinds get it. Whitespace around each name is trimmed and case does not matter. Grok is absent because this switch does not own its manual configuration.
An unrecognized value is not honored in part: the supervisor warns, names the token it did not recognize, and behaves as if the variable were unset. An opt-out with a typo in it must not quietly become “opt out of everything”.
The variable only shapes command lines the supervisor builds after it has read it, so it changes nothing about agents
that are already running. A Codex session launched before you switched injection off keeps its bypass flag and keeps
reporting across every /new until that session is restarted.
Turning injection off also silences the instructions pointer for those launches, because every kind delivers the pointer
through the same integration that reports identity. To keep identity capture and drop only the pointer, use
FARHELM_AGENT_INSTRUCTIONS instead: on (the default, and what unset or empty means) or off, read once when the
supervisor starts, same as above. Anything else warns, names what you wrote, and behaves as if it were unset — a switch
whose off position removes a feature must not be flipped by a typo.
Turning FARHELM_AGENT_HOOKS off prevents new conversations from being saved for Resume by Claude, Codex, Goose, Pi,
and OMP. A session with no saved conversation offers a fresh launch on restart. Turning the switch off does not erase a
conversation already saved for the current launch. Grok’s manual hooks are independent of this switch; remove or disable
those entries in Grok itself when you want them off.
When something goes wrong
Section titled “When something goes wrong”The symptom is a session that should offer “resume conversation” and does not, or that offers a stale one. Look in this order.
1. The per-session hook log, <state dir>/hook-log/<session id>.log, where <state dir> is the supervisor’s state
directory ($XDG_STATE_HOME/farhelm, or ~/.local/state/farhelm by default). One line per hook run, shaped
<unix-seconds> <outcome> [<detail> ]<conversation-id> <source>; the trailing id and source appear only once the
payload has parsed — before that there is no id to name — and <source> is - when the vendor sent none or sent
something that is not a string. The outcome word is the whole diagnosis:
acked— the report landed and the supervisor accepted it. The healthy case.refused— the supervisor said no; the detail carries its error kind and message. Some refusals appear nowhere else, so read this detail before the supervisor log.no-credential— the agent was not launched by farhelm: the flags were there, the session environment was not.bad-payload— nothing usable came out of stdin. The detail says where it went wrong:oversizedorunreadablefor the read itself,no-reader: <error>when the reader thread could not even be started, andunparsable,missing-session-id,session-id-not-a-string,empty-session-id, oroversized-session-idfor the JSON. A vendor renaming the field shows up asmissing-session-id. Grok adds precise reasons for conflicting dual spellings and malformed event, source, timestamp, path, or child fields.connect-failed— the round trip never completed; the detail is<phase>: <error>. A missing or refused socket is retried for about four seconds before this line; a long-lived connection that drops gets a fresh short reconnect window, while repeated immediate drops remain within the current window.connect:is the common one and usually means a supervisor that stayed down;handshake:covers a protocol-version mismatch between the hook binary and the supervisor;runtime:is this process failing to build its own async runtime.timeout— the 30-second budget ran out in the named phase (stdin,connect,handshake,send,reply). Claude’s, Goose’s, and Pi’s report may still be applied after the hook gives up; Codex’s, Grok’s, and OMP’s is lost, but a later subscribed event may report again. For Claude and Codex that is a laterSessionStart; Grok may also retry the selected UUID throughUserPromptSubmitorStop.refused— the supervisor answered and deliberately rejected the report. The admission failure is final and is not queued for a later write, but a later subscribed event may report again.panic— a bug in the hook. Worth reporting, and harmless to the session.
No file at all usually means the vendor never ran the hook: check that injected flags reached the process (step 3), or
that Grok loaded all three manual entries, and for Codex check whether you have typed a prompt yet. Two other things
also leave no file. The path is derived from the session id and the socket’s directory, so a run missing either of those
from its environment has nowhere to write and cannot even leave its no-credential line. (A run missing only the token
still writes one, which is deliberate: that is exactly the half-configured case someone comes to this file for.) And
logging is best-effort by contract — an uncreatable directory, an unwritable path, or a full disk is ignored rather than
turned into a failure. Pi and OMP can also lose the line when the supervisor is down: their published two-second child
timer can kill the reporter while the hook is still retrying.
2. The supervisor log. Every line here carries the session id.
conversation hook flags injected— at launch, naming the kind and carryingannounce=trueorannounce=falsefor whether--announcewas included (FARHELM_AGENT_INSTRUCTIONS’s only visible effect on this log).conversation hook flags not injected— the skip and its reason:invocation already passes --settings,invocation already configures codex hooks,invocation contains a bare --, ordisabled by FARHELM_AGENT_HOOKS. A generic session logs nothing — no integration means there was never a hook to skip. Every one of these launches still runs. Sessions keep running without gaining a new conversation to resume from that launch.recorded the conversation identity this session's agent reported— an accepted report, with the conversation and the vendor’ssourceword. When it displaced a claim naming a DIFFERENT id, a second line says so:this session's agent reported a conversation identity that replaces the one previously claimed for it. A report that beats its own session’s in-memory entry into existence is accepted too, and says so differently:this session's agent reported a conversation identity before its entry was published; the durable row carries it until the entry appears.- Refusals are warn-level, and only some of them reach this log. Three shapes do: one starting
refused a reported conversation identity(an id this build will not store), one startingcould not record the conversation identity(the durable write failed, and the supervisor queues no speculative retry), and two endingthe report is discarded— one for a report about a launch that has since been replaced, one for a session row that could not be read at all. The rest are answered on the wire and logged nowhere here: a credential the store rejects or cannot validate, and a supervisor that is no longer recording. For those, the hook log’srefuseddetail is the only record there is. this session was launched with a conversation hook but holds no conversation identity— the tripwire, once per launch, 65 seconds after the first input if no conversation is saved. A resumed session with a saved conversation does not warn.
3. Confirm the reporter is active. Use ps -o args= -p <agent pid> for injected reporters. Claude’s flags are
--settings followed by a JSON blob naming the farhelm binary; Codex’s are --dangerously-bypass-hook-trust plus two
-c overrides, one for features.hooks=true and one for hooks.SessionStart. For Grok, use /hooks inside Grok and
confirm that the SessionStart, UserPromptSubmit, and Stop entries name the absolute Farhelm binary.
In every one of these failure cases the session keeps working. The only thing at stake is which conversation the restart offer points at. Without an accepted report, a new session offers a fresh launch instead of Resume. Previously saved conversations remain available under the agent’s usual Resume rules; a failed or skipped reporter supplies no new conversation to resume.