Skip to content

Commit 719ea07

Browse files
Ubuntuclaude
andcommitted
fix(claude-code,m4): rebuild ADR-027 W6 hooks with type=command shim (v1.0.659-alpha)
ADR-027 W11 fix-up wedge #3 — claude-code M4 boot regression caught on on-host smoke of v1.0.658: settings.local.json hooks fail validation with "Expected string, but received undefined" and claude refuses to launch. Root cause: ADR-027 W6 emitted hook entries of shape {"type": "mcp_tool", "tool": "mcp__termipod-host__hook_*", ...} which was a speculative design — claude-code's actual hook schema only supports {"type": "command", "command": <string>}. The `tool` field is unknown; `command` is missing; validator throws on first load. Hooks have been silently broken since v1.0.592; nobody noticed because most claude-code spawns run M2 stream-json, not M4 LocalLogTail. Fix (rebuild W6 properly): New host-runner subcommand `hook-fire` (internal/hookfire/) — a one-shot stdio bridge that wraps claude-code's hook contract over the existing UDS MCP gateway: - claude spawns `host-runner hook-fire --socket <uds> --event <Event>` - writes hook payload (single JSON object) to stdin, closes stdin - shim wraps as JSON-RPC `tools/call` with name=`hook_<event_snake>`, arguments=<payload> - dials per-spawn UDS, half-closes write side, reads one response line - extracts `result.content[0].text`, writes to stdout, exits 0 Failure semantics tuned for blocking hooks: transport blip → stdout "{}" + non-zero exit + stderr warning, same outcome as an uninstalled hook (claude defaults to "allow"). hooks_install.go rewrites appendTermipodMatcher to emit: {"matcher": "", "_termipod_managed": true, "hooks": [{"type": "command", "command": "host-runner hook-fire --socket '<sock>' --event <Event>", "timeout": <n>}]} `_termipod_managed` retained as the strip-then-append key — this lets v1.0.659 spawns self-heal stale workdirs that still hold the pre-v1.0.659 invalid mcp_tool entries. New test TestInstallClaudeHooks_SelfHealsStaleMcpToolEntries locks this. Side effect: the v1.0.657 turn.result emission in hookStop now actually fires (Stop hook reaches the adapter again). v1.0.657 symptom #4 fix becomes load-bearing for real. Tests: - internal/hookfire/run_test.go × 6 (round-trip, gateway error, dial failure, event coverage, CLI usage errors) - internal/hostrunner/hooks_install_test.go rewritten — schema validation, self-heal of stale workdirs, plus the 5 prior behaviour tests adapted to the new command-typed shape - launch_m4_locallogtail_test.go updated — substring assertions now check for "host-runner hook-fire" + "--event PreCompact" instead of the (now-absent) mcp__termipod-host__hook_pre_compact string Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8ff54b2 commit 719ea07

10 files changed

Lines changed: 767 additions & 41 deletions

File tree

docs/changelog.md

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Type:** reference
44
> **Status:** Current (2026-05-23)
55
> **Audience:** contributors, operators
6-
> **Last verified vs code:** v1.0.658
6+
> **Last verified vs code:** v1.0.659
77
88
**TL;DR.** Append-only record of what shipped in each tagged release.
99
One section per version, newest first. Format follows
@@ -23,6 +23,121 @@ binding). Seed entries prior to that are in
2323

2424
---
2525

26+
## v1.0.659-alpha — 2026-05-23
27+
28+
ADR-027 W11 fix-up wedge #3**claude-code M4 boot regression: hooks
29+
have been silently broken since v1.0.592**. On-host smoke of v1.0.658
30+
caught it on first start: claude-code refuses to launch with
31+
`Expected string, but received undefined. Hooks use a matcher + hooks
32+
array. ...`
33+
34+
### Root cause
35+
36+
`hub/internal/hostrunner/hooks_install.go` emitted hook entries of
37+
shape:
38+
39+
```json
40+
{"matcher": "*",
41+
"_termipod_managed": true,
42+
"hooks": [{"type": "mcp_tool",
43+
"tool": "mcp__termipod-host__hook_pre_tool_use",
44+
"timeout": 30}]}
45+
```
46+
47+
The `type: "mcp_tool"` form was an ADR-027 W6 speculative design —
48+
the idea was claude-code would route hook events through MCP tools
49+
on the `termipod-host` server. **claude-code's actual hook schema
50+
only supports `type: "command"` with a `command: <string>` field.**
51+
The `tool` field is unknown; `command` is missing; the validator
52+
fails at file load and the agent never gets past the welcome screen.
53+
54+
Why it never surfaced for so long: claude-code is mostly run in M2
55+
stream-json mode (no hook installation). M4 LocalLogTail spawns —
56+
introduced v1.0.592 — were rarely exercised end-to-end until the
57+
agy fix-up arc forced parallel smoke on claude-code M4 at v1.0.657.
58+
59+
### Fix: rebuild W6 with a `type: "command"` shim
60+
61+
New host-runner subcommand `hook-fire` (in
62+
`hub/internal/hookfire/`) — a one-shot stdio bridge that wraps the
63+
claude-code hook contract over the existing UDS MCP gateway:
64+
65+
```
66+
claude-code → spawns `host-runner hook-fire --socket <uds> --event <Event>`
67+
→ writes hook payload (single JSON object) to stdin
68+
→ reads response JSON from stdout
69+
hook-fire → parses stdin as JSON object
70+
→ wraps as JSON-RPC `tools/call` with name=`hook_<event>`
71+
→ dials the per-spawn UDS, half-closes write side
72+
→ reads response line, extracts `result.content[0].text`
73+
→ writes to stdout, exits 0
74+
```
75+
76+
Failure semantics tuned for blocking hooks: a transport blip yields
77+
`{}` on stdout (claude defaults to "allow") + non-zero exit + stderr
78+
warning — same outcome as an uninstalled hook. The previous design's
79+
parking semantics (PreToolUse / PreCompact / AskUserQuestion) still
80+
flow through the gateway's `dispatchHookTool``HookSink.OnHook`
81+
adapter; the only wire change is the front-end format.
82+
83+
`hooks_install.go` rewrites `appendTermipodMatcher` to emit:
84+
85+
```json
86+
{"matcher": "",
87+
"_termipod_managed": true,
88+
"hooks": [{"type": "command",
89+
"command": "host-runner hook-fire --socket '/path/to.sock' --event PreToolUse",
90+
"timeout": 30}]}
91+
```
92+
93+
The matcher is the empty string (claude-code canonical "match all"
94+
form). `_termipod_managed` is retained as the strip-then-append key —
95+
which lets v1.0.659 spawns self-heal stale workdirs that still hold
96+
the pre-v1.0.659 invalid `mcp_tool` entries (new test
97+
`TestInstallClaudeHooks_SelfHealsStaleMcpToolEntries` locks this).
98+
99+
`hostRunnerExe` + `udsPath` are now plumbed through `installClaudeHooks`
100+
from `launch_m4_locallogtail.go`; the latter already had both values
101+
in scope (the runner exe is `hostRunnerExePath()`, the UDS is
102+
`socketPath(ChildID)`).
103+
104+
### Cancel-button restoration
105+
106+
The v1.0.657 `hookStop``turn.result` emission has been dead code
107+
since v1.0.592 (hookStop never fired because no valid hook ever
108+
registered). With hooks fixed, that emission now lights up: end of
109+
turn → Stop hook fires → adapter posts `turn.result{reason:end_of_turn}`
110+
→ mobile `_isAgentBusy()` flips to idle → cancel-on-send overlay
111+
drops. v1.0.657's symptom #4 fix is now actually load-bearing.
112+
113+
### Test additions
114+
115+
- `hub/internal/hookfire/run_test.go` — 6 tests:
116+
- `TestTransport_RoundTrip` — stdin → tools/call → UDS → response
117+
JSON unwrap, with an in-process fake gateway.
118+
- `TestTransport_GatewayErrorSurfaces` — JSON-RPC `error` frame
119+
propagates as a Go error.
120+
- `TestTransport_DialFailure` — missing socket → error, no crash.
121+
- `TestEventToToolName_Complete` — locks the 9-event coverage
122+
against drift between `hookfire/run.go` and
123+
`hooks_install.go:claudeHookEvents`.
124+
- `TestRun_RejectsMissingSocket` + `TestRun_RejectsUnknownEvent`
125+
CLI contract (exit 2 on usage errors).
126+
127+
- `hub/internal/hostrunner/hooks_install_test.go` — rewritten:
128+
- `TestInstallClaudeHooks_NewFile_ValidSchema` — every emitted
129+
matcher block obeys claude-code's documented hook schema
130+
(matcher:string, hooks[] with type="command" + command:<string>).
131+
- `TestInstallClaudeHooks_SelfHealsStaleMcpToolEntries` — a stale
132+
workdir with the pre-v1.0.659 `type: "mcp_tool"` entries gets
133+
cleanly upgraded on next spawn.
134+
- The existing five behaviour tests are updated to the new shape
135+
(`extractCommands` replaces `extractToolNames`).
136+
137+
### Tag
138+
139+
- Tag: `v1.0.659-alpha`
140+
26141
## v1.0.658-alpha — 2026-05-23
27142

28143
ADR-027 W11 fix-up wedge #2 — the deferred symptom #5 from v1.0.657's

hub/cmd/host-runner/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import (
4848
"syscall"
4949

5050
"github.com/termipod/hub/internal/buildinfo"
51+
"github.com/termipod/hub/internal/hookfire"
5152
"github.com/termipod/hub/internal/hostrunner"
5253
"github.com/termipod/hub/internal/mcpbridge"
5354
"github.com/termipod/hub/internal/mcpudsbridge"
@@ -83,6 +84,8 @@ func main() {
8384
os.Exit(mcpbridge.Run(os.Args[2:]))
8485
case "mcp-uds-stdio":
8586
os.Exit(mcpudsbridge.Run(os.Args[2:]))
87+
case "hook-fire":
88+
os.Exit(hookfire.Run(os.Args[2:]))
8689
case "self-update":
8790
runSelfUpdate(os.Args[2:])
8891
case "doctor":
@@ -125,6 +128,12 @@ Commands:
125128
Used by claude-code M4 LocalLogTail spawns to reach the
126129
mcp__termipod-host__hook_* tools (ADR-027). Reads
127130
--socket / MCP_UDS_SOCKET.
131+
hook-fire One-shot stdin → UDS → stdout bridge for claude-code's
132+
settings.local.json hook entries (ADR-027 W6, rebuilt
133+
in v1.0.659). Reads the hook event payload from stdin,
134+
wraps it as a JSON-RPC tools/call for the corresponding
135+
hook_<event> handler on the gateway, writes the
136+
response object to stdout. Flags: --socket --event.
128137
self-update Fetch a release from GitHub, verify SHA256, replace this
129138
binary, and exit 75 so the supervisor respawns it
130139
(ADR-028). Flags: --version / --channel / --upstream-repo

hub/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// mobile and hub use the same x.y.z-alpha numbering. Use
1414
// `make bump VERSION=...` from the repo root to update both files
1515
// atomically.
16-
const Version = "1.0.658-alpha"
16+
const Version = "1.0.659-alpha"
1717

1818
var (
1919
Commit string

0 commit comments

Comments
 (0)