Skip to content

Commit 29adfab

Browse files
committed
fix(hooks): enter-plan stays silent when monocle engine is unreachable
Previously enter-plan always emitted a "Monocle is running for this session..." PreToolUse context even when client.Connect failed, which misled Claude in repos where the user never launched monocle. It now exits 0 with no stdout when the engine is down, matching the other three hooks (exit-plan, mark-activity, on-stop) and deferring to Claude's native plan-mode flow.
1 parent a01dcc2 commit 29adfab

2 files changed

Lines changed: 70 additions & 16 deletions

File tree

cmd/monocle/hooks.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -263,23 +263,23 @@ func (cmd *EnterPlanHookCmd) runClaude(in hookInput) error {
263263
return nil
264264
}
265265

266-
// Start with the base context. If the engine is reachable, layer on any
267-
// pending reviewer state. Timeout here is strict because the PreToolUse
268-
// hook runs with a 5-second timeout.
269-
context := "Monocle is running for this session. When you submit a plan via ExitPlanMode, it will be sent to a human reviewer who can approve or request changes — the approval flow is automatic, you do not need to run any review commands yourself."
270-
266+
// If the engine isn't reachable, stay out of Claude's way — the user
267+
// isn't running monocle and doesn't want plan-mode injection.
271268
c, err := client.Connect(socketPath)
272-
if err == nil {
273-
resp, err := c.Request(
274-
&protocol.GetReviewStatusMsg{Type: protocol.TypeGetReviewStatus},
275-
2*time.Second,
276-
)
277-
c.Close()
278-
if err == nil {
279-
if status, ok := resp.(*protocol.GetReviewStatusResponse); ok {
280-
if status.Status == "pending" || status.CommentCount > 0 {
281-
context += fmt.Sprintf(" There are %d unaddressed reviewer comment(s) — read them before finalizing the plan.", status.CommentCount)
282-
}
269+
if err != nil {
270+
return nil
271+
}
272+
resp, reqErr := c.Request(
273+
&protocol.GetReviewStatusMsg{Type: protocol.TypeGetReviewStatus},
274+
2*time.Second,
275+
)
276+
c.Close()
277+
278+
context := "Monocle is running for this session. When you submit a plan via ExitPlanMode, it will be sent to a human reviewer who can approve or request changes — the approval flow is automatic, you do not need to run any review commands yourself."
279+
if reqErr == nil {
280+
if status, ok := resp.(*protocol.GetReviewStatusResponse); ok {
281+
if status.Status == "pending" || status.CommentCount > 0 {
282+
context += fmt.Sprintf(" There are %d unaddressed reviewer comment(s) — read them before finalizing the plan.", status.CommentCount)
283283
}
284284
}
285285
}

cmd/monocle/hooks_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package main
33
import (
44
"bytes"
55
"encoding/json"
6+
"io"
7+
"os"
8+
"path/filepath"
69
"strings"
710
"testing"
811

@@ -152,6 +155,57 @@ func TestEmitClaudeStopBlock_FallbackReasonWhenEmpty(t *testing.T) {
152155
}
153156
}
154157

158+
// captureStdout runs fn with os.Stdout redirected to an in-memory buffer
159+
// and returns what fn wrote. Used to assert hooks stay silent when the
160+
// monocle engine isn't running.
161+
func captureStdout(t *testing.T, fn func()) string {
162+
t.Helper()
163+
orig := os.Stdout
164+
r, w, err := os.Pipe()
165+
if err != nil {
166+
t.Fatalf("pipe: %v", err)
167+
}
168+
os.Stdout = w
169+
done := make(chan []byte, 1)
170+
go func() {
171+
b, _ := io.ReadAll(r)
172+
done <- b
173+
}()
174+
fn()
175+
w.Close()
176+
os.Stdout = orig
177+
return string(<-done)
178+
}
179+
180+
// TestEnterPlanHook_SilentWhenEngineDown verifies that the enter-plan hook
181+
// exits 0 with no stdout when monocle isn't running. Without the quiet
182+
// exit Claude would see "Monocle is running for this session..." even in
183+
// repos where the user never launched monocle.
184+
func TestEnterPlanHook_SilentWhenEngineDown(t *testing.T) {
185+
// Use a workdir whose derived socket cannot exist.
186+
tmp := t.TempDir()
187+
cmd := &EnterPlanHookCmd{
188+
WorkDirFlag: WorkDirFlag{WorkDir: tmp},
189+
Agent: "claude",
190+
Socket: filepath.Join(tmp, "definitely-not-running.sock"),
191+
}
192+
193+
stdin := strings.NewReader(`{"session_id":"s","cwd":"` + tmp + `","tool_name":"ExitPlanMode","tool_input":{"plan":"# x"}}`)
194+
in, err := decodeHookInput(stdin)
195+
if err != nil {
196+
t.Fatalf("decode: %v", err)
197+
}
198+
199+
out := captureStdout(t, func() {
200+
if err := cmd.runClaude(in); err != nil {
201+
t.Errorf("runClaude returned error (should degrade silently): %v", err)
202+
}
203+
})
204+
if strings.TrimSpace(out) != "" {
205+
t.Errorf("expected empty stdout when engine down, got: %q", out)
206+
}
207+
}
208+
155209
func TestFirstHeading(t *testing.T) {
156210
cases := map[string]string{
157211
"# Title\nbody": "Title",

0 commit comments

Comments
 (0)