feat(claude): hook-driven observed harness state with wrapper terminal writes - #324
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f93b3e8f0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
f93b3e8 to
25a007e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25a007e3a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
25a007e to
c36233d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c36233d489
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
c36233d to
c86a7f7
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c86a7f72d8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
c86a7f7 to
35fd435
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 35fd4358be
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
35fd435 to
461ecc2
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 461ecc2a63
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
461ecc2 to
2b2d0df
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b2d0dfd4c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
2b2d0df to
d487e1c
Compare
d487e1c to
4a46358
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4a46358ddd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| fn observe(event: &str) -> serde_json::Value { | ||
| serde_json::json!([{ "hooks": [{ | ||
| "type": "command", | ||
| "command": format!("$ST_HOOKS/claude-observe.sh {event}"), |
There was a problem hiding this comment.
Quote generated Claude hook executable paths
When ST_HOOKS resolves to a supported custom or XDG state path containing whitespace, materialization expands this unquoted reference into a command such as /state root/sets/.../claude-observe.sh Stop; Claude's command runner then treats only /state as the executable, and the fail-open hook silently drops every affected transition. Shell-quote or otherwise escape the hook-set path in all generated command strings.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a7ea487 (+ snapshot 94af370) on schickling/hs-06-claude: every generated hook command double-quotes the $ST_HOOKS executable, and the managed-reference recognizer reads the quoted executable token rather than the first whitespace-delimited segment, so whitespace-containing custom/XDG roots resolve to one executable again.
Posted on behalf of @schickling
| field | value |
|---|---|
agent_identity |
unknown |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.0.3 |
agent_runtime |
OMP 18.0.3 |
tooling_profile |
dotfiles@f33cd9c-dirty |
| target.retain_mut(|element| { | ||
| if patch.contains(element) || !contains_owned_string(element) { | ||
| return true; | ||
| } | ||
| keep_after_supersession(element) |
There was a problem hiding this comment.
Scope managed-hook pruning to canonical Claude merges
When any hand-authored json-upsert arrays="union" targets an array containing a string that matches the managed-hook path heuristic, this branch deletes that existing element even if the patch is unrelated to Claude hooks. Because union mode is parsed as a generic json-upsert option and documented as append/deduplicate behavior, this can unexpectedly remove user data from arbitrary JSON; apply st2 hook supersession only to the canonical Claude settings registration rather than every union merge.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a7ea487 on schickling/hs-06-claude: st2-hook supersession is now applied only when the union merge targets the canonical Claude settings registration (.claude/settings.local.json); any other arrays="union" merge unions by exact equality alone and can never delete user elements that merely match the managed-path heuristic.
Posted on behalf of @schickling
| field | value |
|---|---|
agent_identity |
unknown |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.0.3 |
agent_runtime |
OMP 18.0.3 |
tooling_profile |
dotfiles@f33cd9c-dirty |
…tbeat, terminal writes Hook-driven turn transitions land through a new fail-open claude-observe.sh (shipped in the immutable hook set) and 'st2 driver claude-observe': UserPromptSubmit/PreToolUse/PostToolUse -> active, Stop -> idle, PermissionRequest -> active blocked-on-human, SessionStart -> idle. Subagent events (non-empty agent_id) never move top-level state. The shared provider wrapper gains a SessionObserver that re-stamps the record on the presence cadence without clobbering hook-written state, writes the terminal record on every reaped child, and writes it BEFORE SIGKILL-escalating its own process group so st2's own teardown no longer leaves a dead agent reading active. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…catalog-first hook resolution Review-pass fixes: SessionObserver pins one session-start timestamp so its per-tick fresh writers never re-stamp a predecessor session's record; ptySession carries the wrapper's runtime ID end to end (wrapper env ST2_CLAUDE_RUNTIME_ID -> hook script -> claude-observe --runtime-id), aliasing the identity only on driver-expanded seats; and the hook script resolves --catalog CATALOG-first — its sibling hooks' ST_ROOT-first order is for bus writes, and under a custom bus root declaration resolution would silently drop every transition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude Code 2.1.237 serializes tool execution around an open permission prompt — no hook event fires while a prompt is up, in either batch ordering — so the shipped blocked-exit rule is correct, not merely conservative. The residual limit is the eventless deny path. The measured grant sequence is replayed verbatim as a mapping fixture, and the phantom SubagentStop's emergent shape is pinned from this build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nsion Driver-declared Claude seats now receive the same .claude/settings.local.json hook registration a hand-authored seat carries, closing the gap where they had no observed-state producer and no lifecycle hooks at all. The registration has one canonical shape (hooks::claude_settings_registration) shared by expansion and pinned against the maintained example declaration by a consistency test, so the two surfaces cannot drift. The materializer admits the new destination verbatim — its $ST_HOOKS references are render variables, not executable paths — and driver seats now meet the same verified-hook-set gate hand-authored $ST_HOOKS renders always met. Render output is not part of the launch fingerprint, so live seats converge without disruption on their next materialization and session start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and ask classification json-upsert gains arrays="union" (exact-equality array union, default stays replace) so the generated hook registration joins user-declared hooks instead of clobbering them each materialization; SessionStart marks the writer's session boundary; PermissionRequest classifies its ask kind from tool_name (AskUserQuestion -> question, else permission); the catalog resolves CATALOG-first in claude-observe.sh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and hook-set supersession The wrapper mints and exports its incarnation token (ST2_CLAUDE_SESSION; wrapperless hooks fall back to Claude's own session_id), late hooks can no longer overwrite the session's terminal record, pi gains a terminal-only observer so the pre-escalation ended write is real for its stop path, union merges supersede st2's own prior hook-set entries while never touching foreign ones, and the maintained example states its hooks-only limitation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k recognition The observer claims the ownership sequence at construction and exports it beside its token (ST2_CLAUDE_SESSION_SEQ); hooks adopt the pair, so a hook straggling from a superseded session is refused. Union supersession recognizes st2's entries structurally — a managed basename under any set-shaped path (sets/sha256-*), or $ST_HOOKS at a token boundary — so a relocated hook root cannot duplicate registrations and $ST_HOOKS_SUFFIX-style foreign variables are never misclassified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…supersession SessionObserver performs the written ownership claim at construction (fatal if unwritable — an unclaimed writer would be refused by every record); union supersession descends into matcher groups, removing only the superseded managed entries so a group holding user and st2 hooks keeps the user's, and dropping only genuine husks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion, and single-home managed leaves A wrapperless seat's SessionStart performs the written claim (its only session boundary — token-only writers never claim, so session 2 would otherwise be refused forever), legacy deliver-mcp seats render the same canonical hook registration their claude-session wrapper assumes, and a managed entry survives only inside its canonical group — nested in a user's group it is superseded rather than registered twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e wrapper is not Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eligibility and the written takeover are one act under the record lock; ineligible and unwritable both degrade to token-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spawn and liveness-check error arms end the record honestly (ended, exit unknown, launch-error) when an observer is present, so the claim placeholder never stands as the visible state of a launch that never ran — while the ordinary nonzero-exit path keeps its real exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e canonical registration The $ST_HOOKS expansion in every generated hook command is now double-quoted — a whitespace-containing custom or XDG root made Claude treat only the first path segment as the executable, silently dropping transitions — and the managed-reference recognizer reads the quoted executable token. st2-hook supersession was a property of EVERY arrays="union" merge, so an unrelated union merge could delete user array elements that merely match the managed-path heuristic. It is now the canonical Claude settings registration's maintenance rule only; other destinations union by exact equality alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@f33cd9c-dirty
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@f33cd9c-dirty
4a46358 to
94af370
Compare
The Claude producer (#268 §C entry + §D), where no observed-state machinery existed at all.
hooks/claude-observe.sh(immutable hook set) +st2 driver claude-observe: hook events map to transitions — UserPromptSubmit/PreToolUse/PostToolUse → active, Stop → idle, PermissionRequest → active +blockedOn: human, SessionStart → idle. Hooks are fail-open (always exit 0; a record write failure never blocks a turn). Any payload carrying a non-emptyagent_idnever moves top-level state (the phantom-SubagentStop hazard, with the emergent-property caveat in a comment).Blocked exit edge: the next PreToolUse/PostToolUse/Stop clears
blockedOn— measured-correct, not merely conservative: the DQ-H1 batched-permission capture (Claude Code 2.1.237, 4 turns, verbatim replay fixture) shows tool execution serializes around an open permission prompt, so the predicted batched false-clear cannot occur. The residual limit is the eventless deny path ("No" emits zero events, even withPermissionDeniedregistered), pinned in DQ-H1.SessionObserverin the shared wrapper: heartbeat on the presence tick via a fresh writer each time (never clobbers hook-written state), with one pinned session-start timestamp so a restarted wrapper never re-stamps a predecessor session's record; terminal record with the real exit on every reaped child; and the §D ordering fix — the terminal record lands before the SIGKILL escalation of the wrapper's own group, now proven against the real wrapper binary (tests/harness_state_teardown.rs).Driver-declared seats get the producer too:
expand_clauderenders the canonical hook registration (hooks::claude_settings_registration, consistency-tested againstexamples/native/agent-claude.kdlso the two surfaces cannot drift). Live seats converge without restarts — the merged settings land on the next materialization pass and take effect at the next session start.The hook script resolves
--catalogCATALOG-first: its sibling hooks' ST_ROOT-first order is for bus writes, and under a custom bus root declaration resolution would silently drop every transition.ptySessioncarries the wrapper's runtime ID end to end (ST2_CLAUDE_RUNTIME_ID→ hook →--runtime-id), aliasing the identity only on driver-expanded seats.Cycle-2:
json-upsertgains opt-inarrays="union"so the generated hook registration joins user-declared hooks instead of clobbering them;PermissionRequestclassifies its ask kind fromtool_name(AskUserQuestion → question, else permission); SessionStart marks the session boundary.Cycle-3: union merges supersede st2's own prior hook-set entries (recognized by the hook root they reference) instead of accumulating them across content-addressed upgrades, while foreign entries are never touched; hook writes carry the wrapper's exported incarnation token so a late hook cannot overwrite the session's terminal record; the maintained example documents its hooks-only limitation.
Cycle-4: hooks adopt the wrapper's exported claim (token + seq) so superseded-session stragglers are refused; hook-set supersession is structural (managed basename under any sets/sha256-* path; $ST_HOOKS only at a token boundary) — relocation-proof and never matching foreign variables.
Cycle-6: wrapperless seats gained working session succession (SessionStart performs the written claim, guarded so an interactive interloper can never supersede a live wrapper's record); legacy
deliver "mcp"seats render the canonical hook registration their claude-session wrapper assumes; managed entries live only in their canonical group — nested in a user's group they are superseded, never doubled.Part of the #268 stack (#319→#326).
🤖 Generated with Claude Code